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 debuggingfix-changelog-json
parent
3dce6a6608
commit
65870b9b26
|
|
@ -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")
|
||||||
|
|
@ -1,140 +1,25 @@
|
||||||
type Release = {
|
import { fetchReleases } from "~/lib/changelog"
|
||||||
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[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400"
|
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"
|
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() {
|
export async function GET() {
|
||||||
const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", {
|
const releases = await fetchReleases()
|
||||||
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 fail = () =>
|
if (releases.length === 0) {
|
||||||
new Response(JSON.stringify({ releases: [] }), {
|
return new Response(JSON.stringify({ releases: [] }), {
|
||||||
status: 503,
|
status: 503,
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
"Cache-Control": error,
|
"Cache-Control": error,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
if (!response?.ok) return fail()
|
return new Response(JSON.stringify({ releases }), {
|
||||||
|
headers: {
|
||||||
const data = await response.json().catch(() => undefined)
|
"Content-Type": "application/json",
|
||||||
if (!Array.isArray(data)) return fail()
|
"Cache-Control": ok,
|
||||||
|
|
||||||
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,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Footer } from "~/component/footer"
|
||||||
import { Legal } from "~/component/legal"
|
import { Legal } from "~/component/legal"
|
||||||
import { config } from "~/config"
|
import { config } from "~/config"
|
||||||
import { For, Show, createSignal } from "solid-js"
|
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 }
|
type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string }
|
||||||
|
|
||||||
|
|
@ -22,26 +22,6 @@ type HighlightGroup = {
|
||||||
items: HighlightItem[]
|
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) {
|
function formatDate(dateString: string) {
|
||||||
const date = new Date(dateString)
|
const date = new Date(dateString)
|
||||||
return date.toLocaleDateString("en-US", {
|
return date.toLocaleDateString("en-US", {
|
||||||
|
|
@ -130,7 +110,7 @@ function CollapsibleSections(props: { sections: { title: string; items: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Changelog() {
|
export default function Changelog() {
|
||||||
const releases = createAsync(() => getReleases())
|
const releases = createAsync(() => changelog())
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main data-page="changelog">
|
<main data-page="changelog">
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue