fix: changelog page SSR not fetching releases on Cloudflare Workers

Internal fetch to /changelog.json during SSR fails silently on Cloudflare
Workers. Refactor to use SolidStart query() with 'use server' directive
to fetch directly from GitHub API during SSR.

- Extract shared release fetching logic to lib/changelog.ts
- Update changelog page to use the new changelog() query
- Simplify changelog.json.ts to use shared fetchReleases()
- Add error logging for debugging
This commit is contained in:
Ryan Vogel
2026-01-26 11:30:25 -05:00
parent 3dce6a6608
commit 65870b9b26
3 changed files with 153 additions and 147 deletions

View File

@@ -0,0 +1,141 @@
import { query } from "@solidjs/router"
type Release = {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
type HighlightItem = {
title: string
description: string
shortDescription?: string
media: HighlightMedia
}
type HighlightGroup = {
source: string
items: HighlightItem[]
}
export type ChangelogRelease = {
tag: string
name: string
date: string
url: string
highlights: HighlightGroup[]
sections: { title: string; items: string[] }[]
}
function parseHighlights(body: string): HighlightGroup[] {
const groups = new Map<string, HighlightItem[]>()
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
let match
while ((match = regex.exec(body)) !== null) {
const source = match[1]
const content = match[2]
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
let media: HighlightMedia | undefined
if (videoMatch) {
media = { type: "video", src: videoMatch[1] }
} else if (imgMatch) {
media = { type: "image", src: imgMatch[3], width: imgMatch[1], height: imgMatch[2] }
}
if (titleMatch && media) {
const item: HighlightItem = {
title: titleMatch[1],
description: pMatch?.[2] || "",
shortDescription: pMatch?.[1],
media,
}
if (!groups.has(source)) {
groups.set(source, [])
}
groups.get(source)!.push(item)
}
}
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
}
function parseMarkdown(body: string) {
const lines = body.split("\n")
const sections: { title: string; items: string[] }[] = []
let current: { title: string; items: string[] } | null = null
let skip = false
for (const line of lines) {
if (line.startsWith("## ")) {
if (current) sections.push(current)
const title = line.slice(3).trim()
current = { title, items: [] }
skip = false
} else if (line.startsWith("**Thank you")) {
skip = true
} else if (line.startsWith("- ") && !skip) {
current?.items.push(line.slice(2).trim())
}
}
if (current) sections.push(current)
const highlights = parseHighlights(body)
return { sections, highlights }
}
export async function fetchReleases(): Promise<ChangelogRelease[]> {
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console",
},
cf: {
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any).catch((e) => {
console.error("[changelog] Failed to fetch releases:", e)
return undefined
})
if (!response?.ok) {
if (response) {
console.warn(`[changelog] GitHub API returned ${response.status}`)
}
return []
}
const data = await response.json().catch(() => undefined)
if (!Array.isArray(data)) return []
const releases = data as Release[]
return releases.map((release) => {
const parsed = parseMarkdown(release.body || "")
return {
tag: release.tag_name,
name: release.name,
date: release.published_at,
url: release.html_url,
highlights: parsed.highlights,
sections: parsed.sections,
}
})
}
export const changelog = query(async () => {
"use server"
return fetchReleases()
}, "changelog")

View File

@@ -1,140 +1,25 @@
type Release = {
tag_name: string
name: string
body: string
published_at: string
html_url: string
}
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
type HighlightItem = {
title: string
description: string
shortDescription?: string
media: HighlightMedia
}
type HighlightGroup = {
source: string
items: HighlightItem[]
}
import { fetchReleases } from "~/lib/changelog"
const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400"
const error = "public, max-age=1, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400"
function parseHighlights(body: string): HighlightGroup[] {
const groups = new Map<string, HighlightItem[]>()
const regex = /<highlight\s+source="([^"]+)">([\s\S]*?)<\/highlight>/g
let match
while ((match = regex.exec(body)) !== null) {
const source = match[1]
const content = match[2]
const titleMatch = content.match(/<h2>([^<]+)<\/h2>/)
const pMatch = content.match(/<p(?:\s+short="([^"]*)")?>([^<]+)<\/p>/)
const imgMatch = content.match(/<img\s+width="([^"]+)"\s+height="([^"]+)"\s+alt="[^"]*"\s+src="([^"]+)"/)
const videoMatch = content.match(/^\s*(https:\/\/github\.com\/user-attachments\/assets\/[a-f0-9-]+)\s*$/m)
let media: HighlightMedia | undefined
if (videoMatch) {
media = { type: "video", src: videoMatch[1] }
} else if (imgMatch) {
media = { type: "image", src: imgMatch[3], width: imgMatch[1], height: imgMatch[2] }
}
if (titleMatch && media) {
const item: HighlightItem = {
title: titleMatch[1],
description: pMatch?.[2] || "",
shortDescription: pMatch?.[1],
media,
}
if (!groups.has(source)) {
groups.set(source, [])
}
groups.get(source)!.push(item)
}
}
return Array.from(groups.entries()).map(([source, items]) => ({ source, items }))
}
function parseMarkdown(body: string) {
const lines = body.split("\n")
const sections: { title: string; items: string[] }[] = []
let current: { title: string; items: string[] } | null = null
let skip = false
for (const line of lines) {
if (line.startsWith("## ")) {
if (current) sections.push(current)
const title = line.slice(3).trim()
current = { title, items: [] }
skip = false
} else if (line.startsWith("**Thank you")) {
skip = true
} else if (line.startsWith("- ") && !skip) {
current?.items.push(line.slice(2).trim())
}
}
if (current) sections.push(current)
const highlights = parseHighlights(body)
return { sections, highlights }
}
export async function GET() {
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
headers: {
Accept: "application/vnd.github.v3+json",
"User-Agent": "OpenCode-Console",
},
cf: {
// best-effort edge caching (ignored outside Cloudflare)
cacheTtl: 60 * 5,
cacheEverything: true,
},
} as any).catch(() => undefined)
const releases = await fetchReleases()
const fail = () =>
new Response(JSON.stringify({ releases: [] }), {
if (releases.length === 0) {
return new Response(JSON.stringify({ releases: [] }), {
status: 503,
headers: {
"Content-Type": "application/json",
"Cache-Control": error,
},
})
}
if (!response?.ok) return fail()
const data = await response.json().catch(() => undefined)
if (!Array.isArray(data)) return fail()
const releases = data as Release[]
return new Response(
JSON.stringify({
releases: releases.map((release) => {
const parsed = parseMarkdown(release.body || "")
return {
tag: release.tag_name,
name: release.name,
date: release.published_at,
url: release.html_url,
highlights: parsed.highlights,
sections: parsed.sections,
}
}),
}),
{
headers: {
"Content-Type": "application/json",
"Cache-Control": ok,
},
return new Response(JSON.stringify({ releases }), {
headers: {
"Content-Type": "application/json",
"Cache-Control": ok,
},
)
})
}

View File

@@ -6,7 +6,7 @@ import { Footer } from "~/component/footer"
import { Legal } from "~/component/legal"
import { config } from "~/config"
import { For, Show, createSignal } from "solid-js"
import { getRequestEvent } from "solid-js/web"
import { changelog, type ChangelogRelease } from "~/lib/changelog"
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
@@ -22,26 +22,6 @@ type HighlightGroup = {
items: HighlightItem[]
}
type ChangelogRelease = {
tag: string
name: string
date: string
url: string
highlights: HighlightGroup[]
sections: { title: string; items: string[] }[]
}
async function getReleases() {
const event = getRequestEvent()
const url = event ? new URL("/changelog.json", event.request.url).toString() : "/changelog.json"
const response = await fetch(url).catch(() => undefined)
if (!response?.ok) return []
const json = await response.json().catch(() => undefined)
return Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : []
}
function formatDate(dateString: string) {
const date = new Date(dateString)
return date.toLocaleDateString("en-US", {
@@ -130,7 +110,7 @@ function CollapsibleSections(props: { sections: { title: string; items: string[]
}
export default function Changelog() {
const releases = createAsync(() => getReleases())
const releases = createAsync(() => changelog())
return (
<main data-page="changelog">