clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / hosts.ts
hosts.ts 6.9K
1// Per-site origins. Every deployed site gets its own hostname under the pages2// domain, so sites can't read each other's cookies, storage or service3// workers, and each builds with base "/":4//5// <repo>-<site>.pages.example a site of the forge's owner6// <repo>-<site>-<owner>.pages.example another user's site7// <that>--<branch>.pages.example a preview of a branch8//9// $FORGE_WWW/hosts/<label> is a relative symlink to the deploy it serves10// (../sites/cat/012/docs), so the directory itself is the registry: the hook11// registers a label when a site is deployed, the pages server resolves12// hostnames through it, and page builds read it to link each site.13//14// Run directly: node lib/hosts.ts register|unregister <target>15// url <owner>/<repo> <site> [branch]16// prune [--dry-run]17import fs from 'node:fs';18import path from 'node:path';19import { createHash } from 'node:crypto';20import { fileURLToPath } from 'node:url';21import { encRef } from './urls.ts';2223const NAME = /^[A-Za-z0-9_][A-Za-z0-9._-]*$/;24const LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;2526export interface HostsConfig {27 www: string;28 pagesDomain: string | null;29 owner: string | null;30 scheme: string;31}3233export function hostsConfig(env: NodeJS.ProcessEnv = process.env): HostsConfig {34 const root = path.resolve(env.FORGE_ROOT || 'demo/root');35 return {36 // A private build's live root is the private one; hostnames are in www's.37 www: env.FORGE_PUBLIC_WWW || env.FORGE_LIVE_WWW || env.FORGE_WWW || path.join(root, '.forge', 'www'),38 pagesDomain: env.FORGE_PAGES_DOMAIN || null,39 owner: env.FORGE_OWNER || null,40 scheme: /^http:/.test(env.FORGE_BASE_URL || '') ? 'http' : 'https',41 };42}4344// DNS-safe: lowercase letters, digits and single hyphens.45export const slug = (s: string): string => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');46const hash = (s: string): string => createHash('sha256').update(s).digest('hex').slice(0, 6);4748// The web-root path a deploy lives at (and its old path URL, minus slashes).49export function target(owner: string, repo: string, site: string, branch?: string | null): string {50 for (const n of [owner, repo, site]) if (!NAME.test(n)) throw new Error(`bad name: ${n}`);51 return branch ? `previews/${owner}/${repo}/${site}/${encRef(branch)}` : `sites/${owner}/${repo}/${site}`;52}5354export function parseTarget(t: string): { owner: string; repo: string; site: string; branch: string | null } | null {55 const p = t.split('/');56 if (p[0] === 'sites' && p.length === 4) return { owner: p[1], repo: p[2], site: p[3], branch: null };57 if (p[0] === 'previews' && p.length === 5) return { owner: p[1], repo: p[2], site: p[3], branch: p[4].replaceAll('~', '/') };58 return null;59}6061// The label a target asks for, before collisions.62function wanted(cfg: HostsConfig, t: string): string {63 const p = parseTarget(t);64 if (!p) throw new Error(`bad target: ${t}`);65 let label = `${slug(p.repo)}-${slug(p.site)}`;66 if (p.owner !== cfg.owner) label += `-${slug(p.owner)}`;67 if (p.branch) label += `--${slug(p.branch)}`;68 return label.length <= 63 ? label : `${label.slice(0, 56).replace(/-+$/, '')}-${hash(t)}`;69}7071export class Hosts {72 readonly cfg: HostsConfig;73 readonly dir: string;74 private byLabel = new Map<string, string>();75 private byTarget = new Map<string, string>();7677 constructor(cfg: HostsConfig) {78 this.cfg = cfg;79 this.dir = path.join(cfg.www, 'hosts');80 this.load();81 }8283 load(): void {84 this.byLabel.clear();85 this.byTarget.clear();86 let names: string[] = [];87 try {88 names = fs.readdirSync(this.dir);89 } catch {}90 for (const label of names) {91 if (!LABEL.test(label)) continue;92 let t: string;93 try {94 t = path.posix.normalize(fs.readlinkSync(path.join(this.dir, label))).replace(/^\.\.\//, '');95 } catch {96 continue;97 }98 this.byLabel.set(label, t);99 this.byTarget.set(t, label);100 }101 }102103 labelOf(t: string): string | null {104 return this.byTarget.get(t) ?? null;105 }106107 targetOf(label: string): string | null {108 return this.byLabel.get(label) ?? null;109 }110111 // The label this target has, or would get if registered now.112 assign(t: string): string {113 const have = this.labelOf(t);114 if (have) return have;115 const want = wanted(this.cfg, t);116 if (!this.byLabel.has(want)) return want;117 const alt = `${want.slice(0, 56).replace(/-+$/, '')}-${hash(t)}`;118 if (!this.byLabel.has(alt)) return alt;119 throw new Error(`no free label for ${t}`);120 }121122 url(t: string): string {123 if (!this.cfg.pagesDomain) return `/${t}/`;124 return `${this.cfg.scheme}://${this.assign(t)}.${this.cfg.pagesDomain}/`;125 }126127 register(t: string): string {128 const label = this.assign(t);129 if (!this.byLabel.has(label)) {130 fs.mkdirSync(this.dir, { recursive: true });131 const tmp = path.join(this.dir, `.${label}.${process.pid}`);132 fs.rmSync(tmp, { force: true });133 fs.symlinkSync(`../${t}`, tmp);134 fs.renameSync(tmp, path.join(this.dir, label));135 this.byLabel.set(label, t);136 this.byTarget.set(t, label);137 }138 return label;139 }140141 unregister(t: string): void {142 const label = this.labelOf(t);143 if (!label) return;144 fs.rmSync(path.join(this.dir, label), { force: true });145 this.byLabel.delete(label);146 this.byTarget.delete(t);147 }148149 // Drop labels whose deploy is gone (deleted refs, removed repos). A dry150 // run only lists them.151 prune(dry = false): string[] {152 const gone: string[] = [];153 for (const [label, t] of this.byLabel) {154 if (fs.existsSync(path.join(this.cfg.www, t))) continue;155 if (!dry) fs.rmSync(path.join(this.dir, label), { force: true });156 gone.push(label);157 }158 if (gone.length && !dry) this.load();159 return gone;160 }161}162163// Node runs a script by its real path, and the forge may live behind a symlink.164if (process.argv[1] && fs.realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {165 const [cmd, ...args] = process.argv.slice(2);166 const hosts = new Hosts(hostsConfig());167 try {168 if (cmd === 'register' && args.length === 1) {169 hosts.register(args[0]);170 console.log(hosts.url(args[0]));171 } else if (cmd === 'unregister' && args.length === 1) {172 hosts.unregister(args[0]);173 } else if (cmd === 'url' && (args.length === 2 || args.length === 3)) {174 const [owner, repo] = args[0].split('/');175 console.log(hosts.url(target(owner ?? '', repo ?? '', args[1], args[2] || null)));176 } else if (cmd === 'prune' && (!args.length || (args.length === 1 && args[0] === '--dry-run'))) {177 const dry = args.length === 1;178 for (const label of hosts.prune(dry)) console.log(`${dry ? 'would prune' : 'pruned'} ${label}`);179 } else {180 console.error('usage: hosts.ts register|unregister <target> | url <owner>/<repo> <site> [branch] | prune [--dry-run]');181 process.exit(2);182 }183 } catch (e) {184 console.error(`hosts: ${(e as Error).message}`);185 process.exit(1);186 }187}