-
Notifications
You must be signed in to change notification settings - Fork 20
feat: add dynamic sitemap route map #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,114 @@ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| import { buildDeno2AppSlug } from './utils/build-deno-url' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const GITHUB_API_BASE = 'https://api.github.com' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const ORG = 'ubiquity' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const CACHE_HEADERS = { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'Cache-Control': 'public, max-age=300', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type GitHubRepo = Readonly<{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| archived?: boolean | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| disabled?: boolean | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export type RouteMapEntry = Readonly<{ | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'app' | 'plugin' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| host: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| upstream: string | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }> | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function handleRouteMap(requestUrl: URL): Promise<Response | null> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (requestUrl.pathname === '/sitemap.xml') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const entries = await buildRouteMap(requestUrl.origin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new Response(toSitemapXml(entries), { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (requestUrl.pathname === '/routes.json' || requestUrl.pathname === '/sitemap.json') { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const entries = await buildRouteMap(requestUrl.origin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return null | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| export async function buildRouteMap(origin = 'https://ubq.fi'): Promise<RouteMapEntry[]> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const repos = await fetchOrgRepos() | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const apps = repos | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter((repo) => repo.name === 'ubq.fi' || repo.name.endsWith('.ubq.fi')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map((repo) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const subdomain = repo.name === 'ubq.fi' ? '' : repo.name.replace(/\.ubq\.fi$/, '') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const host = subdomain ? `${subdomain}.ubq.fi` : 'ubq.fi' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'app' as const, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: repo.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| host, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: `${originForHost(origin, host)}/`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| upstream: `https://${buildDeno2AppSlug(subdomain)}.ubiquity-dao.deno.net/`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const plugins = repos | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .filter((repo) => repo.name.startsWith('ubiquity-os-') || repo.name.startsWith('plugin-')) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map((repo) => { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const plugin = repo.name.replace(/^ubiquity-os-/, '').replace(/^plugin-/, '') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const host = `os-${plugin}.ubq.fi` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| type: 'plugin' as const, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| name: repo.name, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| host, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url: `${originForHost(origin, host)}/`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| upstream: `https://${plugin}-main.deno.dev/`, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return [...apps, ...plugins].sort((a, b) => a.host.localeCompare(b.host)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+58
to
+71
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Deduplicate by If both Proposed fix- return [...apps, ...plugins].sort((a, b) => a.host.localeCompare(b.host))
+ const merged = [...apps, ...plugins]
+ const deduped = Array.from(new Map(merged.map((entry) => [entry.host, entry])).values())
+ return deduped.sort((a, b) => a.host.localeCompare(b.host))📝 Committable suggestion
Suggested change
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| async function fetchOrgRepos(): Promise<GitHubRepo[]> { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const repos: GitHubRepo[] = [] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| for (let page = 1; page <= 10; page++) { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const response = await fetch(`${GITHUB_API_BASE}/orgs/${ORG}/repos?per_page=100&page=${page}`, { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| headers: { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Accept: 'application/vnd.github+json', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| 'User-Agent': 'ubq-fi-router', | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| }) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (!response.ok) break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const batch = await response.json() as GitHubRepo[] | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| repos.push(...batch.filter((repo) => !repo.archived && !repo.disabled)) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| if (batch.length < 100) break | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return repos | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function originForHost(origin: string, host: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const url = new URL(origin) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url.hostname = host | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url.pathname = '' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url.search = '' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| url.hash = '' | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return url.toString().replace(/\/$/, '') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function toSitemapXml(entries: RouteMapEntry[]): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| const urls = entries | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .map((entry) => ` <url>\n <loc>${escapeXml(entry.url)}</loc>\n </url>`) | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .join('\n') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${urls}\n</urlset>\n` | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| function escapeXml(value: string): string { | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| return value | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/&/g, '&') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/</g, '<') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/>/g, '>') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/"/g, '"') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| .replace(/'/g, ''') | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,55 @@ | ||
| import { describe, expect, test } from 'bun:test' | ||
| import worker, { type Env } from '../src/worker' | ||
| import { buildRouteMap } from '../src/sitemap' | ||
|
|
||
| const githubResponse = [ | ||
| { name: 'ubq.fi' }, | ||
| { name: 'pay.ubq.fi' }, | ||
| { name: 'ubiquity-os-kernel' }, | ||
| { name: 'old.ubq.fi', archived: true }, | ||
| ] | ||
|
|
||
| describe('dynamic route map', () => { | ||
| test('builds JSON route map from active GitHub repos', async () => { | ||
| globalThis.fetch = (async () => Response.json(githubResponse)) as unknown as typeof fetch | ||
|
|
||
| const entries = await buildRouteMap('https://ubq.fi') | ||
|
|
||
| expect(entries).toEqual([ | ||
| { | ||
| type: 'plugin', | ||
| name: 'ubiquity-os-kernel', | ||
| host: 'os-kernel.ubq.fi', | ||
| url: 'https://os-kernel.ubq.fi/', | ||
| upstream: 'https://kernel-main.deno.dev/', | ||
| }, | ||
| { | ||
| type: 'app', | ||
| name: 'pay.ubq.fi', | ||
| host: 'pay.ubq.fi', | ||
| url: 'https://pay.ubq.fi/', | ||
| upstream: 'https://pay-ubq-fi.ubiquity-dao.deno.net/', | ||
| }, | ||
| { | ||
| type: 'app', | ||
| name: 'ubq.fi', | ||
| host: 'ubq.fi', | ||
| url: 'https://ubq.fi/', | ||
| upstream: 'https://ubq-fi.ubiquity-dao.deno.net/', | ||
| }, | ||
| ]) | ||
| }) | ||
|
|
||
| test('serves sitemap xml through the worker', async () => { | ||
| globalThis.fetch = (async () => Response.json(githubResponse)) as unknown as typeof fetch | ||
|
|
||
| const res = await worker.fetch(new Request('https://ubq.fi/sitemap.xml'), {} as Env) | ||
| const body = await res.text() | ||
|
|
||
| expect(res.status).toBe(200) | ||
| expect(res.headers.get('content-type')).toContain('application/xml') | ||
| expect(res.headers.get('x-uos-router-revision')).toBe('local') | ||
| expect(body).toContain('<loc>https://pay.ubq.fi/</loc>') | ||
| expect(body).toContain('<loc>https://os-kernel.ubq.fi/</loc>') | ||
| }) | ||
| }) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: ubiquity/ubq.fi-router
Length of output: 175
🏁 Script executed:
Repository: ubiquity/ubq.fi-router
Length of output: 1965
🏁 Script executed:
Repository: ubiquity/ubq.fi-router
Length of output: 12674
Fail closed on GitHub API errors; dedupe plugin entries that normalize to the same host
fetchOrgRepos()stops pagination on!response.okand returns partial/emptyrepos, andhandleRouteMap()doesn’t handle failures—so/sitemap.xmland the JSON endpoints can silently return incomplete data with HTTP 200.Proposed fix
export async function handleRouteMap(requestUrl: URL): Promise<Response | null> { + const build = async () => { + const entries = await buildRouteMap(requestUrl.origin) + return entries + } + if (requestUrl.pathname === '/sitemap.xml') { - const entries = await buildRouteMap(requestUrl.origin) - return new Response(toSitemapXml(entries), { - headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' }, - }) + try { + const entries = await build() + return new Response(toSitemapXml(entries), { + headers: { ...CACHE_HEADERS, 'Content-Type': 'application/xml; charset=utf-8' }, + }) + } catch { + return new Response('Failed to build sitemap', { status: 502 }) + } } if (requestUrl.pathname === '/routes.json' || requestUrl.pathname === '/sitemap.json') { - const entries = await buildRouteMap(requestUrl.origin) - return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), { - headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' }, - }) + try { + const entries = await build() + return new Response(JSON.stringify({ generatedAt: new Date().toISOString(), entries }, null, 2), { + headers: { ...CACHE_HEADERS, 'Content-Type': 'application/json; charset=utf-8' }, + }) + } catch { + return new Response(JSON.stringify({ error: 'Failed to build route map' }), { + status: 502, + headers: { 'Content-Type': 'application/json; charset=utf-8' }, + }) + } }async function fetchOrgRepos(): Promise<GitHubRepo[]> { const repos: GitHubRepo[] = [] for (let page = 1; page <= 10; page++) { const response = await fetch(`${GITHUB_API_BASE}/orgs/${ORG}/repos?per_page=100&page=${page}`, { @@ - if (!response.ok) break + if (!response.ok) { + throw new Error(`GitHub API failed: ${response.status}`) + }Minor:
buildRouteMap()can emit duplicate plugin entries when bothubiquity-os-<x>andplugin-<x>exist, since both normalize to the samehost(os-<x>.ubq.fi). Consider deduping byhostbefore returning.📝 Committable suggestion