Skip to content
262 linesCodeBlameRaw
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 the app is fetched by name
7 * and runs only for as long as it answers. An app no one visits runs
8 * nothing and costs nothing. Each request it serves is held to
9 * `APP_LIMITS`, so one app cannot spend without bound. The one lookup is for an address an app
10 * had before its project moved: `DOMAINS` holds a redirect under the old
11 * hostname for as long as the old name is held, and it is followed before
12 * anything the old script would answer (such as a paused notice).
13 *
14 * Any other hostname is a project's custom domain, reaching here through
15 * Cloudflare for SaaS: the `DOMAINS` KV namespace, written by the
16 * deployments service, names its app, or the hostname it redirects to.
17 *
18 * Kept apart from g1t.sh, so apps share no cookies or origin with the site
19 * people sign in to.
20 */
21
22import {
23 APP_LIMITS,
24 DOMAIN,
25 FALLBACK,
26 REQUEST_ID_HEADER,
27 REQUEST_MARK,
28 overLimits,
29 parseEntry,
30 redirectTo,
31 route,
32 traceFor,
33 type AppTelemetry,
34 type DomainEntry,
35} from "./route.ts";
36
37type Env = {
38 APPS: DispatchNamespace;
39 /** Custom hostname, or an app's old g1t.page hostname, to `{ script, redirect }`. */
40 DOMAINS?: KVNamespace;
41};
42
43/** How long a custom hostname's entry is kept in this isolate and at the edge. */
44const ENTRY_TTL_MS = 60 * 1000;
45const entries = new Map<string, { at: number; entry: DomainEntry | null }>();
46
47function page(status: number, title: string, body: string, site = DOMAIN): Response {
48 const html = `<!doctype html>
49<html lang="en">
50<head>
51<meta charset="utf-8">
52<meta name="viewport" content="width=device-width, initial-scale=1">
53<meta name="robots" content="noindex">
54<title>${escape(title)} · ${escape(site)}</title>
55<style>
56 :root { color-scheme: dark; --bg: #121214; --fg: #ececf1; --muted: #9a9aa6; --accent: #b4a7f5; }
57 body { margin: 0; min-height: 100vh; display: grid; place-items: center; background: var(--bg); color: var(--fg);
58 font: 16px/1.6 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; padding: 0 16px; }
59 main { max-width: 32rem; }
60 h1 { font-size: 1.5rem; margin: 0 0 .5rem; letter-spacing: -.01em; }
61 p { color: var(--muted); margin: .5rem 0; }
62 a { color: var(--accent); }
63 code { font: .9em ui-monospace, SFMono-Regular, Menlo, monospace; color: var(--fg); }
64</style>
65</head>
66<body><main><h1>${escape(title)}</h1>${body}</main></body>
67</html>`;
68 return new Response(html, {
69 status,
70 headers: { "content-type": "text/html; charset=utf-8", "x-robots-tag": "noindex", "cache-control": "no-store" },
71 });
72}
73
74function escape(text: string): string {
75 return text.replace(/[&<>"']/g, (c) => `&#${c.charCodeAt(0)};`);
76}
77
78/** A custom hostname's entry: from this isolate, else KV (cached at the edge too). */
79async function lookup(env: Env, hostname: string): Promise<DomainEntry | null> {
80 const cached = entries.get(hostname);
81 if (cached && Date.now() - cached.at < ENTRY_TTL_MS) return cached.entry;
82 const value = env.DOMAINS ? await env.DOMAINS.get(hostname, { type: "json", cacheTtl: ENTRY_TTL_MS / 1000 }) : null;
83 const entry = parseEntry(value);
84 entries.set(hostname, { at: Date.now(), entry });
85 if (entries.size > 5000) entries.delete(entries.keys().next().value!);
86 return entry;
87}
88
89/**
90 * Runs the app `script` for the request; `shown` is the address named in
91 * its error pages. Each request gets an id and a trace, handed to the app
92 * (`x-g1t-request-id`, `traceparent`), and one line naming them, the app
93 * and its project (`telemetry`), which observability reads from this
94 * Worker's tail events and files with what the app's own tell it.
95 */
96async function dispatch(
97 env: Env,
98 request: Request,
99 script: string,
100 shown: string,
101 missing: () => Response,
102 telemetry: AppTelemetry | null,
103): Promise<Response> {
104 const { id, trace, traceparent } = traceFor(request.headers.get("traceparent"));
105 console.log(REQUEST_MARK, { id, script, trace_id: trace, app: telemetry });
106 const headers = new Headers(request.headers);
107 headers.set(REQUEST_ID_HEADER, id);
108 headers.set("traceparent", traceparent);
109 request = new Request(request, { headers });
110 let app: Fetcher;
111 try {
112 app = env.APPS.get(script, {}, { limits: APP_LIMITS });
113 } catch {
114 return missing();
115 }
116 try {
117 return await app.fetch(request);
118 } catch (error) {
119 if (/worker not found|script not found|does not exist/i.test(String(error))) return missing();
120 if (overLimits(error)) {
121 return page(
122 503,
123 "The app went over its limits",
124 `<p>The app at <code>${escape(shown)}</code> used more than ${APP_LIMITS.cpuMs} ms of CPU time, or made more than ${APP_LIMITS.subRequests} requests of its own, answering this request.</p>`,
125 shown,
126 );
127 }
128 return page(
129 502,
130 "The app failed",
131 `<p>The app at <code>${escape(shown)}</code> threw an error before it could answer.</p>`,
132 shown,
133 );
134 }
135}
136
137export default {
138 async fetch(request: Request, env: Env): Promise<Response> {
139 const host = new URL(request.url).hostname;
140 const where = route(host);
141 switch (where.kind) {
142 case "home":
143 return page(
144 200,
145 "g1t.page",
146 `<p>Apps deployed by <a href="https://g1t.sh">g1t</a>: every pull request's preview, and each repository's production.</p>
147 <p><a href="https://docs.g1t.sh/guides/deployments/">How deployments work</a></p>`,
148 );
149 case "fallback":
150 return page(
151 200,
152 "Custom domains on g1t",
153 `<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>
154 <p><a href="https://docs.g1t.sh/guides/deployments/#custom-domains">How custom domains work</a></p>`,
155 );
156 case "invalid":
157 return page(404, "Nothing here", "<p>This is not the address of an app on g1t.page.</p>");
158 case "custom":
159 return custom(request, env, where.hostname);
160 case "app": {
161 const label = where.label;
162 // An address the app had before its project moved (its workspace or
163 // repository was renamed or transferred) redirects to where it is
164 // now, whatever the app it named answers, paused or not.
165 const { moved, telemetry } = await appRedirect(request, env, label);
166 if (moved) return moved;
167 let response = await dispatch(env, request, label, `${label}.${DOMAIN}`, () => missing(label), telemetry);
168 // Previews are for the people reviewing a change, not search engines.
169 if (isPreview(label)) {
170 response = new Response(response.body, response);
171 response.headers.set("x-robots-tag", "noindex");
172 }
173 return response;
174 }
175 }
176 },
177} satisfies ExportedHandler<Env>;
178
179/**
180 * The redirect for an app's old address, if the deployments service left
181 * one (in `DOMAINS`, under the old hostname, as `{ script, redirect }`),
182 * else null. A lookup that fails serves the app as it is rather than an
183 * error.
184 */
185async function appRedirect(request: Request, env: Env, label: string): Promise<{ moved: Response | null; telemetry: AppTelemetry | null }> {
186 const hostname = `${label}.${DOMAIN}`;
187 let entry: DomainEntry | null;
188 try {
189 entry = await lookup(env, hostname);
190 } catch (error) {
191 console.error("could not read redirect", label, error);
192 return { moved: null, telemetry: null };
193 }
194 // Otherwise the entry is the app's own: what its requests are filed under.
195 if (!entry?.redirect || entry.redirect === hostname) return { moved: null, telemetry: entry?.telemetry ?? null };
196 const moved = new Response(null, {
197 status: 301,
198 headers: {
199 location: redirectTo(request.url, entry.redirect),
200 "x-robots-tag": "noindex",
201 "cache-control": "public, max-age=3600",
202 },
203 });
204 return { moved, telemetry: null };
205}
206
207/** A custom domain: its app, or a 308 to the hostname it is paired with. */
208async function custom(request: Request, env: Env, hostname: string): Promise<Response> {
209 let entry: DomainEntry | null;
210 try {
211 entry = await lookup(env, hostname);
212 } catch (error) {
213 console.error("could not read domain", hostname, error);
214 return page(503, "Try again in a moment", `<p><code>${escape(hostname)}</code> could not be looked up just now.</p>`, hostname);
215 }
216 if (!entry) {
217 return page(
218 404,
219 "This domain is not set up",
220 `<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>`,
221 hostname,
222 );
223 }
224 if (entry.redirect && entry.redirect !== hostname) {
225 return new Response(null, {
226 status: 308,
227 headers: { location: redirectTo(request.url, entry.redirect), "cache-control": "public, max-age=300" },
228 });
229 }
230 // The app's own entry names its project, for observability.
231 const own = await lookup(env, `${entry.script}.${DOMAIN}`).catch(() => null);
232 return dispatch(
233 env,
234 request,
235 entry.script,
236 hostname,
237 () =>
238 page(
239 404,
240 "Nothing deployed here yet",
241 `<p><code>${escape(hostname)}</code> serves a project's production, and it is not up. Deploying the project brings it here.</p>`,
242 hostname,
243 ),
244 own?.redirect ? null : (own?.telemetry ?? null),
245 );
246}
247
248/** A branch's preview: `<project>-git-<branch>-<workspace>`. */
249function isPreview(label: string): boolean {
250 return label.includes("-git-");
251}
252
253function missing(label: string): Response {
254 const preview = isPreview(label);
255 return page(
256 404,
257 preview ? "This preview is not up" : "Nothing deployed here",
258 preview
259 ? `<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>`
260 : `<p>No app is deployed at <code>${escape(label)}.${DOMAIN}</code>. Deployments are turned on per project, from its Settings on g1t.</p>`,
261 );
262}