mirror of
https://github.com/RSSNext/Folo.git
synced 2026-10-17 05:08:13 +08:00
2bd1b03c90
Share pages only recognized not found errors from ofetch, but the API client throws FollowAPIError, so missing feeds, lists and profiles were served with status 200 and indexed as soft 404s. In the browser the feed page then kept spinning and the list page stayed blank, also when the API failed for another reason. Treat API 404s as not found and pass other API statuses on, and show the not found page or a translated retry view on the feed and list pages, sharing the profile page's view. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
313 lines
9.0 KiB
TypeScript
313 lines
9.0 KiB
TypeScript
import "./global"
|
|
|
|
// Global route dependencies
|
|
import { env } from "@follow/shared/env.ssr"
|
|
import { FollowAPIError } from "@follow-app/client-sdk"
|
|
import { Hono } from "hono"
|
|
import { minify } from "html-minifier-terser"
|
|
import { parseHTML } from "linkedom"
|
|
import { FetchError } from "ofetch"
|
|
import xss from "xss"
|
|
|
|
// @ts-expect-error - WASM import handled by Wrangler
|
|
import resvgWasm from "./resvg.wasm"
|
|
// OG image rendering
|
|
import { createFollowClient } from "./src/lib/api-client"
|
|
import { injectHydrationScript } from "./src/lib/hydration-script"
|
|
import { NotFoundError } from "./src/lib/not-found"
|
|
import { setFontsBucket } from "./src/lib/og/fonts.worker"
|
|
import { setWasmModule } from "./src/lib/og/resvg-wasm-shim"
|
|
import { buildSeoMetaTags } from "./src/lib/seo"
|
|
import {
|
|
createRequestProxy,
|
|
requestContext,
|
|
runWithRequestContext,
|
|
} from "./src/lib/worker-request-context"
|
|
import { injectMetaHandler, MetaError } from "./src/meta-handler"
|
|
import { renderFeedOG } from "./src/router/og/feed"
|
|
import { renderListOG } from "./src/router/og/list"
|
|
import { renderUserOG } from "./src/router/og/user"
|
|
|
|
Object.assign(globalThis, {
|
|
__DEV__: false,
|
|
})
|
|
|
|
// Initialize WASM module
|
|
setWasmModule(resvgWasm)
|
|
|
|
interface Env {
|
|
FONTS_BUCKET: R2Bucket
|
|
ASSETS: Fetcher
|
|
VITE_API_URL: string
|
|
VITE_WEB_URL: string
|
|
VITE_SENTRY_DSN: string
|
|
}
|
|
|
|
const app = new Hono<{ Bindings: Env }>()
|
|
|
|
let envInitialized = false
|
|
|
|
// Redirects (migrated from vercel.json)
|
|
app.get("/feed/:id", (c) => {
|
|
return c.redirect(`/share/feeds/${c.req.param("id")}`, 301)
|
|
})
|
|
app.get("/list/:id", (c) => {
|
|
return c.redirect(`/share/lists/${c.req.param("id")}`, 301)
|
|
})
|
|
app.get("/profile/:path{.*}", (c) => {
|
|
return c.redirect(`/share/users/${c.req.param("path")}`, 301)
|
|
})
|
|
|
|
// Middleware: set up env vars and request context
|
|
app.use("*", async (c, next) => {
|
|
if (!envInitialized) {
|
|
const bindings = c.env
|
|
for (const [key, value] of Object.entries(bindings)) {
|
|
if (typeof value === "string") {
|
|
process.env[key] = value
|
|
}
|
|
}
|
|
if (bindings.FONTS_BUCKET) {
|
|
setFontsBucket(bindings.FONTS_BUCKET)
|
|
}
|
|
envInitialized = true
|
|
}
|
|
|
|
return runWithRequestContext(async () => {
|
|
const host = c.req.header("host") || ""
|
|
const forwardedHost = c.req.header("x-forwarded-host")
|
|
const finalHost = forwardedHost || host
|
|
|
|
// Create a req-like proxy for compatibility with existing modules
|
|
const headers: Record<string, string> = {}
|
|
c.req.raw.headers.forEach((value, key) => {
|
|
headers[key] = value
|
|
})
|
|
|
|
const reqProxy = createRequestProxy(
|
|
c.req.path + (c.req.raw.url.includes("?") ? `?${c.req.raw.url.split("?")[1]}` : ""),
|
|
headers,
|
|
)
|
|
|
|
// Set request context values
|
|
requestContext.set("req", reqProxy)
|
|
|
|
await next()
|
|
|
|
c.header("x-handled-host", finalHost)
|
|
})
|
|
})
|
|
|
|
// OG image route
|
|
app.get("/og/:type/:id", async (c) => {
|
|
const type = c.req.param("type")
|
|
const id = c.req.param("id")
|
|
|
|
const apiClient = createFollowClient()
|
|
let imageRes: { image: Buffer; contentType: string } | null = null
|
|
|
|
try {
|
|
switch (type) {
|
|
case "feed": {
|
|
imageRes = await renderFeedOG(apiClient, id)
|
|
break
|
|
}
|
|
case "user": {
|
|
imageRes = await renderUserOG(apiClient, id)
|
|
break
|
|
}
|
|
case "list": {
|
|
imageRes = await renderListOG(apiClient, id)
|
|
break
|
|
}
|
|
default: {
|
|
return c.text("Not found", 404)
|
|
}
|
|
}
|
|
} catch (e: any) {
|
|
if (typeof e === "number") {
|
|
return c.text(e === 404 ? "Not found" : "Internal server error", e)
|
|
}
|
|
console.error("OG render error:", e)
|
|
return c.text("Internal server error", 500)
|
|
}
|
|
|
|
if (!imageRes) {
|
|
return c.text("Not found", 404)
|
|
}
|
|
|
|
return new Response(imageRes.image, {
|
|
headers: {
|
|
"Content-Type": imageRes.contentType,
|
|
"Cache-Control": "max-age=3600, s-maxage=3600, stale-while-revalidate=600",
|
|
"Cloudflare-CDN-Cache-Control": "max-age=3600, s-maxage=3600, stale-while-revalidate=600",
|
|
"CDN-Cache-Control": "max-age=3600, s-maxage=3600, stale-while-revalidate=600",
|
|
},
|
|
})
|
|
})
|
|
|
|
// SSR pages reference hashed bundles from /dist-external, so these requests
|
|
// must stay on the SSR worker instead of falling through to the SPA worker.
|
|
app.get("/dist-external/*", (c) => {
|
|
return c.env.ASSETS.fetch(c.req.raw)
|
|
})
|
|
|
|
// SSR routes - use SSR template with meta injection
|
|
// These routes correspond to the vercel.json rewrites to follow-external-ssr
|
|
const ssrHandler = async (c: any) => {
|
|
// @ts-ignore - dynamic import of generated template
|
|
const template = await import("./.generated/index.template").then((m) => m.default)
|
|
const { document } = parseHTML(template)
|
|
|
|
// Inject meta tags
|
|
try {
|
|
await injectMetaToTemplate(document, c)
|
|
} catch (e) {
|
|
console.error("inject meta error", e)
|
|
|
|
if (e instanceof NotFoundError) {
|
|
c.status(404)
|
|
document.documentElement.dataset.notFound = "true"
|
|
} else if (e instanceof FetchError && e.response?.status) {
|
|
c.status(e.response.status as any)
|
|
} else if (e instanceof FollowAPIError && e.status) {
|
|
c.status(e.status as any)
|
|
} else if (e instanceof MetaError) {
|
|
return c.json({ ok: false, message: e.metaMessage }, e.status as any)
|
|
}
|
|
}
|
|
|
|
injectEnvToDocument(document)
|
|
|
|
const html = await minify(document.toString(), {
|
|
removeComments: true,
|
|
html5: true,
|
|
minifyJS: true,
|
|
minifyCSS: true,
|
|
removeTagWhitespace: true,
|
|
collapseWhitespace: true,
|
|
collapseBooleanAttributes: true,
|
|
collapseInlineTagWhitespace: true,
|
|
})
|
|
|
|
return c.html(html)
|
|
}
|
|
|
|
app.get("/share/*", ssrHandler)
|
|
app.get("/login", ssrHandler)
|
|
app.get("/register", ssrHandler)
|
|
app.get("/forget-password", ssrHandler)
|
|
app.get("/reset-password", ssrHandler)
|
|
|
|
// SPA catch-all - fetch index.html from Assets and inject env vars
|
|
app.get("*", async (c) => {
|
|
const assetResponse = await c.env.ASSETS.fetch(new Request("http://fakehost/index.html"))
|
|
const spaHtml = await assetResponse.text()
|
|
const { document } = parseHTML(spaHtml)
|
|
|
|
injectEnvToDocument(document)
|
|
|
|
const html = document.toString()
|
|
return c.html(html)
|
|
})
|
|
|
|
// Error handling
|
|
app.onError((err, c) => {
|
|
console.error(err)
|
|
|
|
if (err instanceof FetchError) {
|
|
return c.json({ ok: false, message: err.message }, (err.response?.status as any) || 500)
|
|
}
|
|
if (err instanceof MetaError) {
|
|
return c.json({ ok: false, message: err.metaMessage }, err.status as any)
|
|
}
|
|
|
|
return c.json({ ok: false, message: err.message || "Internal Server Error" }, 500)
|
|
})
|
|
|
|
export default app
|
|
|
|
// Helper: inject env vars into HTML document
|
|
function injectEnvToDocument(document: any) {
|
|
const scriptContent = `function injectEnv(env2) {
|
|
for (const key in env2) {
|
|
if (env2[key] === void 0) continue;
|
|
globalThis["__followEnv"] ??= {};
|
|
globalThis["__followEnv"][key] = env2[key];
|
|
}
|
|
}
|
|
injectEnv({"VITE_API_URL":"${env.VITE_API_URL}","VITE_WEB_URL":"${env.VITE_WEB_URL}"})`
|
|
const $script = document.createElement("script")
|
|
$script.innerHTML = scriptContent
|
|
document.head.prepend($script)
|
|
}
|
|
|
|
// Helper: inject meta tags into HTML document
|
|
async function injectMetaToTemplate(document: Document, c: any) {
|
|
// Create a req/res proxy compatible with injectMetaHandler
|
|
const reqProxy = requestContext.get("req")
|
|
const resProxy = {
|
|
status(code: number) {
|
|
c.status(code)
|
|
},
|
|
raw: { statusMessage: "" },
|
|
}
|
|
|
|
const injectMetadata = await injectMetaHandler(reqProxy as any, resProxy as any)
|
|
|
|
if (!injectMetadata) return document
|
|
|
|
for (const meta of injectMetadata) {
|
|
switch (meta.type) {
|
|
case "openGraph": {
|
|
const $metaArray = buildSeoMetaTags(document, { openGraph: meta })
|
|
for (const $meta of $metaArray) {
|
|
document.head.append($meta)
|
|
}
|
|
break
|
|
}
|
|
case "meta": {
|
|
const $oldMeta = document.querySelector(`meta[name="${meta.property}"]`)
|
|
if ($oldMeta) {
|
|
$oldMeta.setAttribute("content", xss(meta.content))
|
|
} else {
|
|
const $meta = document.createElement("meta")
|
|
$meta.setAttribute("name", meta.property)
|
|
$meta.setAttribute("content", xss(meta.content))
|
|
document.head.append($meta)
|
|
}
|
|
break
|
|
}
|
|
case "title": {
|
|
if (meta.title) {
|
|
const $title = document.querySelector("title")
|
|
if ($title) {
|
|
$title.textContent = `${xss(meta.title)} | Folo`
|
|
} else {
|
|
const $head = document.querySelector("head")
|
|
if ($head) {
|
|
const $title = document.createElement("title")
|
|
$title.textContent = `${xss(meta.title)} | Folo`
|
|
$head.append($title)
|
|
}
|
|
}
|
|
}
|
|
break
|
|
}
|
|
case "description": {
|
|
const $meta = document.createElement("meta")
|
|
$meta.setAttribute("name", "description")
|
|
$meta.setAttribute("content", xss(meta.description))
|
|
document.head.append($meta)
|
|
break
|
|
}
|
|
case "hydrate": {
|
|
injectHydrationScript(document, meta.key, meta.data)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
|
|
return document
|
|
}
|