Files
hanzo.ai/scripts/edge.mjs

148 lines
6.9 KiB
JavaScript

#!/usr/bin/env node
/**
* Write hanzo.ai's edge rules into hanzoai/universe.
*
* lib/edge.ts is the rules; this renders them as the Middlewares and
* IngressRoutes of charts/app/values/hanzo/static-sites.yaml, between the two
* pairs of markers there, and leaves the rest of that file alone. Run it after
* MOVED, SHELVES or lib/data/retired.json change, commit universe, and sync the
* resources it names (it prints them).
*
* Usage: node scripts/edge.mjs <universe>/charts/app/values/hanzo/static-sites.yaml [--check]
*/
import { readFileSync, writeFileSync } from 'node:fs'
import { APP, CANONICAL, DATA, FRAME, MISSING, NOREFERRER, STUDIO_EDGE, WWW, kept, moved, shelves } from '../lib/edge.ts'
import { SHELVES } from '../lib/publish.ts'
const [file, mode] = process.argv.slice(2)
if (!file) {
console.error('usage: node scripts/edge.mjs <static-sites.yaml> [--check]')
process.exit(2)
}
const BEGIN = '# BEGIN hanzo.ai edge'
const END = '# END hanzo.ai edge'
const NOTE = `${BEGIN}: written by hanzo-apps/ai scripts/edge.mjs from lib/publish.ts and lib/data/retired.json. Regenerate, never edit.`
const HOSTS = '(Host(`hanzo.ai`) || Host(`www.hanzo.ai`))'
const q = (s) => `'${s.replace(/'/g, "''")}'`
const redirect = ({ name, regex, replacement }) =>
[`- name: ${name}`, ' spec:', ' redirectRegex:', ` regex: ${q(regex)}`, ` replacement: ${q(replacement)}`, ' permanent: true']
const chain = (name, members) => [`- name: ${name}`, ' spec:', ' chain:', ' middlewares:', ...members.map((m) => ` - name: ${m}`)]
const route = (name, match, priority, middlewares) => [
`- name: ${name}`,
' spec:',
' entryPoints:',
' - websecure',
' routes:',
` - match: ${q(match)}`,
' kind: Rule',
` priority: ${priority}`,
' middlewares:',
...middlewares.map((m) => ` - name: ${m}`),
' services:',
' - name: static-sink',
' port: 80',
' tls:',
' secretName: wildcard-hanzo-ai-tls',
]
const rules = moved()
const shelved = shelves()
const middlewares = [
NOTE,
'# A retired address answers with a 301 to its page in one hop, query kept; every',
'# page has one address (https, no www, no trailing slash, no .html); Next\'s own',
'# not-found files answer 404; every path under /dev is Dev\'s one page and every',
'# conversation under /chat is Chat\'s. hanzo-ai-edge',
'# is the chain the apex routes run.',
`- name: ${MISSING.name}`,
' spec:',
' replacePathRegex:',
` regex: ${q(MISSING.regex)}`,
` replacement: ${MISSING.replacement}`,
...APP.flatMap(({ name, regex, replacement }) => [`- name: ${name}`, ' spec:', ' replacePathRegex:', ` regex: ${q(regex)}`, ` replacement: ${replacement}`]),
...[CANONICAL, WWW, ...rules, ...shelved].flatMap(redirect),
...chain('hanzo-ai-moved', rules.map((r) => r.name)),
...chain('hanzo-ai-edge', [MISSING.name, 'hanzo-ai-moved', CANONICAL.name, WWW.name, ...APP.map((r) => r.name), 'hanzo-ai-csp', 'hanzo-ai-static']),
'# hanzo-ai-edge with Studio\'s policy, for /studio alone (studio-on-hanzo-ai).',
...chain(STUDIO_EDGE.name, [MISSING.name, 'hanzo-ai-moved', CANONICAL.name, WWW.name, ...APP.map((r) => r.name), STUDIO_EDGE.csp, 'hanzo-ai-static']),
'# The markdown twin of each page and Next\'s RSC payloads are for agents and the',
'# router, not the index: without this every page had an indexable duplicate.',
'- name: hanzo-ai-noindex',
' spec:',
' headers:',
' customResponseHeaders:',
' X-Robots-Tag: noindex',
'# An /auth/ page\'s address carries a code or a login hint: no Referer carries it on.',
`- name: ${NOREFERRER.name}`,
' spec:',
' headers:',
' customResponseHeaders:',
...Object.entries(NOREFERRER.headers).map(([k, v]) => ` ${k}: ${v}`),
'# A sign-in page is never framed, whatever the report-only policy says.',
`- name: ${FRAME.name}`,
' spec:',
' headers:',
' customResponseHeaders:',
...Object.entries(FRAME.headers).map(([k, v]) => ` ${k}: ${q(v)}`),
END,
]
const routes = [
NOTE,
'# `go get hanzo.ai/<module>` asks with ?go-get=1 and reads the page\'s tag, so it',
'# is served as asked, never redirected.',
...route('hanzo-ai-go', `${HOSTS} && Query(\`go-get\`, \`1\`)`, 170, ['hanzo-ai-csp', 'hanzo-ai-static']),
'# /chat and /dev answer every path under them with their page (dev-on-hanzo-ai at',
'# 300, chat-on-hanzo-ai at 200); their own data files and twins are served as',
'# they are, so this stands above both.',
...route(DATA.name, `${HOSTS} && PathRegexp(\`${DATA.regex}\`)`, DATA.priority, [WWW.name, 'hanzo-ai-noindex', 'hanzo-ai-csp', 'hanzo-ai-static']),
...route('hanzo-ai-twins', `${HOSTS} && PathRegexp(\`\\.(md|txt)$\`)`, 160, [WWW.name, 'hanzo-ai-noindex', 'hanzo-ai-csp', 'hanzo-ai-static']),
// Below the twins (160), so an /auth/ page's .txt keeps its noindex.
...route('hanzo-ai-auth', `${HOSTS} && PathPrefix(\`${NOREFERRER.prefix}\`)`, 155, [NOREFERRER.name, FRAME.name, 'hanzo-ai-edge']),
// The sign-in pages, each at its own address and every path under it.
...route('hanzo-ai-signin', `${HOSTS} && PathRegexp(\`^/(${FRAME.pages.map((p) => p.slice(1)).join('|')})(/|$)\`)`, 155, [FRAME.name, 'hanzo-ai-edge']),
...SHELVES.flatMap(({ from, keep }, i) =>
route(
shelved[i].name,
`${HOSTS} && PathPrefix(\`${from}\`)${keep.length ? ` && !PathRegexp(\`${kept(from, keep)}\`)` : ''}`,
150,
['hanzo-ai-moved', shelved[i].name],
),
),
END,
]
const text = readFileSync(file, 'utf8')
const blocks = text.split(BEGIN)
if (blocks.length !== 3) throw new Error(`${file} needs two ${BEGIN} markers, one in middlewares: and one in routes:`)
/** Replace the marked block at or after `from`; answer the text and where the block ends. */
const splice = (source, from, lines) => {
const at = source.indexOf(BEGIN, from)
const stop = source.indexOf(END, at) + END.length
const block = lines.join('\n')
return [source.slice(0, at) + block + source.slice(stop), at + block.length]
}
const [once, after] = splice(text, 0, middlewares)
const [next] = splice(once, after, routes)
const names = [
...middlewares.filter((l) => l.startsWith('- name: ')).map((l) => ['Middleware', l.slice(8)]),
...routes.filter((l) => l.startsWith('- name: ')).map((l) => ['IngressRoute', l.slice(8)]),
]
const dupes = names.map((k) => k.join('/')).filter((n, i, all) => all.indexOf(n) !== i)
if (dupes.length) throw new Error(`two rules share a name: ${dupes.join(', ')}`)
if (mode === '--check') {
if (next !== text) {
console.error(`${file} is not what lib/edge.ts renders: run node scripts/edge.mjs ${file}`)
process.exit(1)
}
console.log(`edge: ${file} is current`)
} else {
writeFileSync(file, next)
console.log(`edge: ${rules.length} redirect rules, ${shelved.length} shelves -> ${file}`)
}
console.log(JSON.stringify(names.map(([kind, name]) => ({ group: 'hanzo.ai', kind, name, namespace: 'hanzo' }))))