Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 114 additions & 0 deletions src/sitemap.ts
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' },
})
Comment on lines +23 to +35

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant parts of the file
FILE="src/sitemap.ts"
if [ ! -f "$FILE" ]; then
  echo "Missing $FILE"
  exit 1
fi

echo "=== ${FILE} (around handleRouteMap) ==="
sed -n '1,220p' "$FILE" | nl -ba | sed -n '1,220p' | sed -n '1,220p'

echo "=== ${FILE} (search for fetchOrgRepos / buildRouteMap / originForHost / escapeXml) ==="
rg -n "fetchOrgRepos|buildRouteMap|originForHost|escapeXml|toSitemapXml|handleRouteMap" "$FILE" || true

echo "=== ${FILE} (exact definitions) ==="
# Print full function bodies (best-effort by naive line ranges)
# If functions are large, we at least capture their internals around matches.
for pat in "export async function handleRouteMap" "async function buildRouteMap" "async function fetchOrgRepos" "function originForHost" "function escapeXml"; do
  line=$(rg -n "$pat" "$FILE" | head -n1 | cut -d: -f1 || true)
  if [ -n "${line:-}" ]; then
    start=$((line-5))
    end=$((line+220))
    echo "--- $pat at line $line (showing $start-$end) ---"
    sed -n "${start},${end}p" "$FILE" | nl -ba | sed -n "1,260p"
  fi
done

Repository: ubiquity/ubq.fi-router

Length of output: 175


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/sitemap.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }

echo "=== Lines around handleRouteMap ==="
line=$(rg -n "export async function handleRouteMap" "$FILE" | head -n1 | cut -d: -f1)
echo "handleRouteMap at: $line"
start=$((line-25)); end=$((line+120))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== Function bodies / key helpers ==="
for pat in "buildRouteMap" "fetchOrgRepos" "originForHost" "escapeXml" "toSitemapXml"; do
  l=$(rg -n "${pat}" "$FILE" | head -n1 | cut -d: -f1 || true)
  if [ -n "${l:-}" ]; then
    echo "--- ${pat} at ${l} ---"
    s=$((l-20)); e=$((l+180))
    sed -n "${s},${e}p" "$FILE" | cat -n | head -n 260
  fi
done

Repository: ubiquity/ubq.fi-router

Length of output: 1965


🏁 Script executed:

#!/bin/bash
set -euo pipefail

FILE="src/sitemap.ts"
[ -f "$FILE" ] || { echo "Missing $FILE"; exit 1; }

echo "=== handleRouteMap block ==="
line=$(rg -n "export async function handleRouteMap" "$FILE" | head -n1 | cut -d: -f1)
echo "handleRouteMap at: $line"
start=$((line-20)); [ $start -lt 1 ] && start=1
end=$((line+90))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== buildRouteMap block ==="
line=$(rg -n "async function buildRouteMap" "$FILE" | head -n1 | cut -d: -f1)
echo "buildRouteMap at: $line"
start=$((line-20)); [ $start -lt 1 ] && start=1
end=$((line+140))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== fetchOrgRepos block ==="
line=$(rg -n "async function fetchOrgRepos" "$FILE" | head -n1 | cut -d: -f1)
echo "fetchOrgRepos at: $line"
start=$((line-20)); [ $start -lt 1 ] && start=1
end=$((line+180))
sed -n "${start},${end}p" "$FILE" | cat -n

echo "=== originForHost + escapeXml blocks ==="
for pat in "function originForHost" "function escapeXml"; do
  line=$(rg -n "$pat" "$FILE" | head -n1 | cut -d: -f1 || true)
  if [ -z "${line:-}" ]; then echo "Not found: $pat"; continue; fi
  echo "$pat at: $line"
  start=$((line-10)); [ $start -lt 1 ] && start=1
  end=$((line+120))
  sed -n "${start},${end}p" "$FILE" | cat -n
done

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.ok and returns partial/empty repos, and handleRouteMap() doesn’t handle failures—so /sitemap.xml and 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 both ubiquity-os-<x> and plugin-<x> exist, since both normalize to the same host (os-<x>.ubq.fi). Consider deduping by host before returning.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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' },
})
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') {
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') {
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' },
})
}
}

}

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Deduplicate by host after app/plugin merge.

If both ubiquity-os-foo and plugin-foo exist, this produces duplicate os-foo.ubq.fi entries in sitemap/JSON.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
.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))
.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/`,
}
})
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))

}

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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;')
}
6 changes: 6 additions & 0 deletions src/worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
resolveDenoUrl,
} from './utils/build-deno-url'
import { buildPluginUrl } from './utils/build-plugin-url'
import { handleRouteMap } from './sitemap'

declare const __UOS_ROUTER_REVISION__: string | undefined

Expand Down Expand Up @@ -61,6 +62,11 @@ export default {
async fetch(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url)

const routeMapResponse = await handleRouteMap(url)
if (routeMapResponse) {
return withRouterRevision(routeMapResponse)
}

if (url.pathname === '/__health') {
if (shouldLog('health', request, url, env)) {
try {
Expand Down
55 changes: 55 additions & 0 deletions tests/sitemap.test.ts
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>')
})
})
Loading