Ver Fonte

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
Ryan Vogel há 2 meses atrás
pai
commit
65870b9b26

+ 141 - 0
packages/console/app/src/lib/changelog.ts

@@ -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")

+ 10 - 125
packages/console/app/src/routes/changelog.json.ts

@@ -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,
     },
-  )
+  })
 }

+ 2 - 22
packages/console/app/src/routes/changelog/index.tsx

@@ -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">