| 1 | /** |
| 2 | * What a request's host is: g1t.page itself, an app's address on it, the |
| 3 | * hostname custom domains point at, or a custom domain. |
| 4 | * |
| 5 | * Kept free of imports so its tests run on Node as they are. |
| 6 | */ |
| 7 | |
| 8 | export const DOMAIN = "g1t.page"; |
| 9 | /** Where custom domains point (the Cloudflare for SaaS fallback origin). */ |
| 10 | export const FALLBACK = `domains.${DOMAIN}`; |
| 11 | |
| 12 | export type Route = |
| 13 | | { kind: "home" } |
| 14 | | { kind: "fallback" } |
| 15 | | { kind: "app"; label: string } |
| 16 | | { kind: "custom"; hostname: string } |
| 17 | | { kind: "invalid" }; |
| 18 | |
| 19 | const LABEL = /^[a-z0-9-]{1,63}$/; |
| 20 | const HOST = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/; |
| 21 | |
| 22 | export function route(rawHost: string): Route { |
| 23 | const host = rawHost.toLowerCase().replace(/\.$/, ""); |
| 24 | if (host === DOMAIN) return { kind: "home" }; |
| 25 | if (host === FALLBACK) return { kind: "fallback" }; |
| 26 | if (host.endsWith(`.${DOMAIN}`)) { |
| 27 | const label = host.slice(0, -DOMAIN.length - 1); |
| 28 | return LABEL.test(label) ? { kind: "app", label } : { kind: "invalid" }; |
| 29 | } |
| 30 | return HOST.test(host) && host.length <= 253 ? { kind: "custom", hostname: host } : { kind: "invalid" }; |
| 31 | } |
| 32 | |
| 33 | /** |
| 34 | * What the deployments service notes for an app, under its own hostname, |
| 35 | * for observability: each request's line names its project and deployment. |
| 36 | */ |
| 37 | export type AppTelemetry = { project: string; deployment: string | null; environment: string | null; service: string; commit: string | null }; |
| 38 | |
| 39 | /** |
| 40 | * What the deployments service writes under a custom hostname, an app's |
| 41 | * old hostname (a redirect), or an app's own hostname (its `telemetry`). |
| 42 | */ |
| 43 | export type DomainEntry = { script: string; redirect: string | null; telemetry?: AppTelemetry | null }; |
| 44 | |
| 45 | /** Reads a stored entry, or null if it is not one. */ |
| 46 | export function parseEntry(value: unknown): DomainEntry | null { |
| 47 | if (!value || typeof value !== "object") return null; |
| 48 | const entry = value as Record<string, unknown>; |
| 49 | if (typeof entry.script !== "string" || !LABEL.test(entry.script)) return null; |
| 50 | const redirect = typeof entry.redirect === "string" && HOST.test(entry.redirect) ? entry.redirect : null; |
| 51 | const t = entry.telemetry as Record<string, unknown> | null | undefined; |
| 52 | const text = (v: unknown) => (typeof v === "string" ? v : null); |
| 53 | const telemetry = |
| 54 | t && typeof t === "object" && typeof t.project === "string" |
| 55 | ? { project: t.project, deployment: text(t.deployment), environment: text(t.environment), service: text(t.service) ?? entry.script, commit: text(t.commit) } |
| 56 | : null; |
| 57 | return { script: entry.script, redirect, telemetry }; |
| 58 | } |
| 59 | |
| 60 | /** The same path and query on `target`, over https. */ |
| 61 | export function redirectTo(requestUrl: string, target: string): string { |
| 62 | const url = new URL(requestUrl); |
| 63 | return `https://${target}${url.pathname}${url.search}`; |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * What one request to an app may use: CPU time, not counting time spent |
| 68 | * waiting on the network, and requests it makes of its own. Static assets |
| 69 | * are served without running the app, and use neither. No plan sets |
| 70 | * others; the Deployments guide names these. |
| 71 | */ |
| 72 | export const APP_LIMITS = { cpuMs: 50, subRequests: 50 } as const; |
| 73 | |
| 74 | /** Whether an app's failure was going over `APP_LIMITS`, as the runtime words it. */ |
| 75 | export function overLimits(error: unknown): boolean { |
| 76 | return /exceeded (its )?cpu|cpu time limit|too many subrequests|subrequest limit/i.test(String(error)); |
| 77 | } |
| 78 | |
| 79 | /** The header each app is handed with its request's id, which its logs can name. */ |
| 80 | export const REQUEST_ID_HEADER = "x-g1t-request-id"; |
| 81 | /** The first word of each request's line, which observability reads (services/telemetry). */ |
| 82 | export const REQUEST_MARK = "g1t-request"; |
| 83 | |
| 84 | function hex(bytes: number): string { |
| 85 | return [...crypto.getRandomValues(new Uint8Array(bytes))].map((b) => b.toString(16).padStart(2, "0")).join(""); |
| 86 | } |
| 87 | |
| 88 | /** |
| 89 | * A request's id and trace: the trace the caller's `traceparent` began, |
| 90 | * or a new one. The id is the dispatcher's span in it, which the app's |
| 91 | * own spans (from an OpenTelemetry SDK that reads `traceparent`) are under. |
| 92 | */ |
| 93 | export function traceFor(traceparent: string | null): { id: string; trace: string; traceparent: string; flags: string } { |
| 94 | const m = /^[0-9a-f]{2}-([0-9a-f]{32})-[0-9a-f]{16}-([0-9a-f]{2})$/.exec((traceparent ?? "").trim().toLowerCase()); |
| 95 | const trace = m && !/^0+$/.test(m[1]) ? m[1] : hex(16); |
| 96 | const flags = m ? m[2] : "01"; |
| 97 | const id = hex(8); |
| 98 | return { id, trace, traceparent: `00-${trace}-${id}-${flags}`, flags }; |
| 99 | } |