chore(infra): migrate desktop web to Cloudflare (#4869)

* chore(infra): migrate desktop web from Vercel to Cloudflare Workers

Replaces separate Vercel deployments (follow SPA + follow-external-ssr) with unified Cloudflare Workers + Assets deployment. Implements meta tag injection, OG image generation, and environment variable management in Hono-based Worker. Adds GitHub Actions CI/CD for automatic deployment on push to dev/main branches.

- Replace Fastify with Hono for Cloudflare Workers compatibility
- Create Worker entry point with SSR routes and SPA fallback
- Add AsyncLocalStorage-based request context shim
- Implement WASM-based OG image rendering with R2 font storage
- Split SPA and SSR routing: /share/* and auth routes use SSR, others fallback to SPA
- Add Cloudflare wrangler configuration with dev/prod environments
- Create GitHub Actions workflow for automated deployments
- Add build scripts for font data and WASM patching

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(ssr): exclude worker files from typecheck and fix tsdown config

Worker-specific files (*.worker.ts) use Cloudflare Workers types and
generated modules that aren't available during the main tsc typecheck.
Exclude them from tsconfig since they're only used via tsdown aliases.
Also fix broken path.resolve reference in tsdown.worker.config.ts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
DIYgod 2026-02-22 20:58:17 +08:00 committed by GitHub
parent 0bba08f103
commit 81c5b9f2e0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 1411 additions and 32 deletions

70
.github/workflows/deploy-cloudflare.yml vendored Normal file
View File

@ -0,0 +1,70 @@
on:
push:
branches: [main, dev]
name: ☁️ Deploy to Cloudflare Workers
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
deploy:
name: Build & Deploy
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [lts/*]
steps:
- name: Checkout code
uses: actions/checkout@v6
with:
lfs: true
- name: Checkout LFS objects
run: git lfs checkout
- name: Cache turbo build setup
uses: actions/cache@v5
with:
path: .turbo
key: ${{ runner.os }}-turbo-${{ github.sha }}
restore-keys: |
${{ runner.os }}-turbo-
- uses: pnpm/action-setup@v4
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v6
with:
node-version: ${{ matrix.node-version }}
cache: "pnpm"
- name: Install dependencies
run: pnpm install
- name: Build desktop web (SPA)
run: pnpm exec turbo run Folo#build:web
- name: Build SSR Worker
working-directory: apps/ssr
run: pnpm run build:worker
- name: Copy WASM file
run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm
- name: Deploy to Cloudflare (dev)
if: github.ref == 'refs/heads/dev'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy --env dev
- name: Deploy to Cloudflare (prod)
if: github.ref == 'refs/heads/main'
uses: cloudflare/wrangler-action@v3
with:
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
workingDirectory: apps/ssr
command: deploy

2
.gitignore vendored
View File

@ -29,3 +29,5 @@ apps/desktop/build/appxmanifest.xml
.claude/settings.local.json
.serena
.wrangler

View File

@ -4,6 +4,9 @@
"private": true,
"scripts": {
"build": "cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsdown && tsx scripts/cleanup-vercel-build.ts",
"build:worker": "cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsx scripts/generate-font-data.ts && tsdown --config tsdown.worker.config.ts && tsx scripts/patch-worker-build.ts && cp -r dist/dist-external ../desktop/out/web/dist-external",
"deploy:dev": "wrangler deploy --env dev",
"deploy:prod": "wrangler deploy",
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
"meta": "tsx helper/meta-map.ts --watch",
"start": "tsx index.ts",
@ -54,6 +57,7 @@
"@follow/shared": "workspace:*",
"@follow/types": "workspace:*",
"@follow/utils": "workspace:*",
"@resvg/resvg-wasm": "2.6.2",
"@types/html-minifier-terser": "7.0.2",
"chokidar": "4.0.3",
"code-inspector-plugin": "1.4.2",
@ -62,6 +66,7 @@
"es-toolkit": "1.44.0",
"fast-glob": "3.3.3",
"foxact": "0.2.52",
"hono": "4.12.1",
"html-minifier-terser": "7.2.0",
"lightningcss": "1.31.1",
"masonic": "4.1.0",
@ -72,6 +77,7 @@
"tsx": "4.21.0",
"typescript": "catalog:",
"vite": "7.3.1",
"vite-plugin-route-builder": "0.4.1"
"vite-plugin-route-builder": "0.4.1",
"wrangler": "4.67.0"
}
}

View File

@ -0,0 +1,20 @@
import fs from "node:fs"
import { createRequire } from "node:module"
import path, { resolve } from "pathe"
const require = createRequire(import.meta.url)
const snPath = require.resolve("@fontsource/sn-pro")
const filesDir = resolve(snPath, "../files")
const files = fs.readdirSync(filesDir).filter((f) => !f.endsWith(".woff2") && !f.includes("italic"))
files.forEach((f) => {
const stat = fs.statSync(path.join(filesDir, f))
console.info(f, `${(stat.size / 1024).toFixed(1)}KB`)
})
const kosePath = require.resolve("kose-font")
const koseSize = fs.statSync(kosePath).size
console.info(`kose-font: ${(koseSize / 1024).toFixed(1)}KB`)
let total = files.reduce((sum, f) => sum + fs.statSync(path.join(filesDir, f)).size, 0)
total += koseSize
console.info(`Total: ${(total / 1024 / 1024).toFixed(1)}MB`)

View File

@ -0,0 +1,46 @@
import fs from "node:fs"
import { createRequire } from "node:module"
import { fileURLToPath } from "node:url"
import path, { dirname, resolve } from "pathe"
const __dirname = dirname(fileURLToPath(import.meta.url))
const require = createRequire(import.meta.url)
const weights = [
{ name: "Thin", weight: 100 },
{ name: "ExtraLight", weight: 200 },
{ name: "Light", weight: 300 },
{ name: "Regular", weight: 400 },
{ name: "Italic", weight: 400 },
{ name: "Medium", weight: 500 },
{ name: "SemiBold", weight: 600 },
{ name: "Bold", weight: 700 },
{ name: "ExtraBold", weight: 800 },
{ name: "Black", weight: 900 },
] as const
const snFontDepsPath = require.resolve("@fontsource/sn-pro")
const snFontsDirPath = resolve(snFontDepsPath, "../files")
const snFontsDir = fs
.readdirSync(snFontsDirPath)
.filter((name) => !name.endsWith(".woff2") && !name.includes("italic"))
const fontsData: Record<string, string> = {}
for (const file of snFontsDir) {
const weight = weights.find((w) => file.includes(w.weight.toString()))
if (!weight) continue
const data = fs.readFileSync(path.join(snFontsDirPath, file))
fontsData[`sn-pro-${weight.weight}`] = data.toString("base64")
}
// kose-font is too large (~24MB) to bundle, loaded from R2 at runtime instead
const outDir = path.join(__dirname, "../.generated")
fs.mkdirSync(outDir, { recursive: true })
fs.writeFileSync(
path.join(outDir, "fonts-data.ts"),
`export default ${JSON.stringify(fontsData)} as Record<string, string>`,
)
console.info("Generated fonts-data.ts with", Object.keys(fontsData).length, "fonts")

View File

@ -0,0 +1,24 @@
import fs from "node:fs"
import { dirname, join } from "pathe"
const distDir = join(dirname(import.meta.url.replace("file://", "")), "../dist/worker")
const files = fs.readdirSync(distDir).filter((f) => f.endsWith(".mjs"))
for (const file of files) {
const filePath = join(distDir, file)
const code = fs.readFileSync(filePath, "utf-8")
// Fix createRequire(import.meta.url) - import.meta.url is undefined in Cloudflare Workers
// Provide a fallback URL so createRequire can initialize properly
const patched = code.replaceAll(
"createRequire(import.meta.url)",
'createRequire(import.meta.url || "file:///worker.mjs")',
)
if (patched !== code) {
fs.writeFileSync(filePath, patched)
console.info(`Patched createRequire in ${file}`)
}
}

View File

@ -0,0 +1,36 @@
import { execSync } from "node:child_process"
import fs from "node:fs"
import { createRequire } from "node:module"
import path, { resolve } from "pathe"
const require = createRequire(import.meta.url)
const BUCKET = "follow"
const PREFIX = "ssr-fonts"
const ACCOUNT_ID = "1f1d1678a2413a54c944b3081bab5c84"
const snFontDepsPath = require.resolve("@fontsource/sn-pro")
const snFontsDirPath = resolve(snFontDepsPath, "../files")
const snFontsDir = fs
.readdirSync(snFontsDirPath)
.filter((name) => !name.endsWith(".woff2") && !name.includes("italic"))
for (const file of snFontsDir) {
const filePath = path.join(snFontsDirPath, file)
const key = `${PREFIX}/${file}`
console.info(`Uploading ${file}...`)
execSync(
`CLOUDFLARE_ACCOUNT_ID=${ACCOUNT_ID} npx wrangler r2 object put ${BUCKET}/${key} --file "${filePath}" --remote`,
{ stdio: "inherit" },
)
}
const koseFontPath = require.resolve("kose-font")
console.info("Uploading kose-font.ttf...")
execSync(
`CLOUDFLARE_ACCOUNT_ID=${ACCOUNT_ID} npx wrangler r2 object put ${BUCKET}/${PREFIX}/kose-font.ttf --file "${koseFontPath}" --remote`,
{ stdio: "inherit" },
)
console.info("All fonts uploaded successfully!")

View File

@ -0,0 +1,2 @@
// No-op for Cloudflare Workers - environment variables are provided via wrangler config
export {}

View File

@ -0,0 +1,66 @@
import fontsBase64Data from "../../../.generated/fonts-data"
let cachedFonts: any[] | null = null
let koseFont: any | null = null
// Global reference to R2 bucket, set by worker-entry.ts
let _fontsBucket: R2Bucket | null = null
export function setFontsBucket(bucket: R2Bucket) {
_fontsBucket = bucket
}
function decodeSNProFonts(): any[] {
const fontsData: any[] = []
for (const [key, base64] of Object.entries(fontsBase64Data)) {
if (key === "kose-400") continue
const weightStr = key.split("-").pop()!
const weight = Number.parseInt(weightStr)
const buf = Uint8Array.from(atob(base64), (c) => c.codePointAt(0)!)
fontsData.push({
name: "SN Pro",
data: buf.buffer,
weight,
style: "normal" as const,
})
}
return fontsData
}
async function loadKoseFont(): Promise<any | null> {
if (koseFont) return koseFont
if (!_fontsBucket) {
console.warn("R2 fonts bucket not configured, skipping kose-font")
return null
}
try {
const obj = await _fontsBucket.get("ssr-fonts/kose-font.ttf")
if (!obj) {
console.warn("Kose font not found in R2")
return null
}
const data = await obj.arrayBuffer()
koseFont = {
name: "Kose",
data,
weight: 400,
style: "normal" as const,
}
return koseFont
} catch (e) {
console.error("Failed to load kose font from R2:", e)
return null
}
}
export async function getFonts(): Promise<any[]> {
if (cachedFonts) return cachedFonts
const snProFonts = decodeSNProFonts()
const kose = await loadKoseFont()
cachedFonts = kose ? [...snProFonts, kose] : snProFonts
return cachedFonts
}
// Default export for compatibility with original fonts module API
export default [] as any[]

View File

@ -0,0 +1,33 @@
import type { ReactElement } from "react"
import type { SatoriOptions } from "satori"
import satori from "satori"
import { getFonts } from "./fonts.worker"
import { ensureInitialized, Resvg } from "./resvg-wasm-shim"
export async function renderToImage(
node: ReactElement,
options: {
width?: number
height: number
debug?: boolean
fonts?: SatoriOptions["fonts"]
},
) {
await ensureInitialized()
const fonts = options.fonts || (await getFonts())
const svg = await satori(node, {
...options,
fonts,
})
const w = new Resvg(svg)
const image = w.render().asPng()
return {
image,
contentType: "image/png",
}
}

View File

@ -0,0 +1,22 @@
import { initWasm } from "@resvg/resvg-wasm"
let initialized = false
let _wasmModule: WebAssembly.Module | null = null
export function setWasmModule(mod: WebAssembly.Module) {
_wasmModule = mod
}
async function ensureInitialized() {
if (!initialized) {
if (_wasmModule) {
await initWasm(_wasmModule)
}
initialized = true
}
}
export { ensureInitialized }
export { Resvg } from "@resvg/resvg-wasm"

View File

@ -0,0 +1,43 @@
import { AsyncLocalStorage } from "node:async_hooks"
// Shim for @fastify/request-context that works in Workers
// Uses AsyncLocalStorage to provide per-request context
const storage = new AsyncLocalStorage<Map<string, any>>()
export const requestContext = {
get(key: string) {
const store = storage.getStore()
return store?.get(key)
},
set(key: string, value: any) {
const store = storage.getStore()
store?.set(key, value)
},
}
export function runWithRequestContext<T>(fn: () => T | Promise<T>): T | Promise<T> {
const store = new Map<string, any>()
return storage.run(store, fn)
}
// Provide a req-like object with requestContext for compatibility
export function createRequestProxy(
url: string,
headers: Record<string, string>,
params: Record<string, string> = {},
) {
return {
originalUrl: url,
headers,
params,
requestContext: {
get(key: string) {
return requestContext.get(key)
},
set(key: string, value: any) {
requestContext.set(key, value)
},
},
}
}

View File

@ -40,5 +40,5 @@
"./tailwind.config.ts",
"./helper/**/*.ts"
],
"exclude": ["node_modules"]
"exclude": ["node_modules", "./src/**/*.worker.ts"]
}

View File

@ -0,0 +1,28 @@
import { dirname, resolve } from "pathe"
import { defineConfig } from "tsdown"
const __dirname = dirname(import.meta.url.replace("file://", ""))
export default defineConfig({
entry: ["./worker-entry.ts"],
outDir: "dist/worker",
clean: true,
format: ["esm"],
external: ["node:*", /\.wasm$/],
noExternal: ["**"],
treeshake: true,
splitting: false,
alias: {
"./src/lib/og/render-to-image": "./src/lib/og/render-to-image.worker",
"./src/lib/og/fonts": "./src/lib/og/fonts.worker",
"../../lib/og/render-to-image": "../../lib/og/render-to-image.worker",
"./src/lib/load-env": "./src/lib/load-env.worker",
"@fastify/request-context": resolve(__dirname, "src/lib/worker-request-context.ts"),
},
define: {
__DEV__: JSON.stringify(false),
},
})

71
apps/ssr/worker-app.ts Normal file
View File

@ -0,0 +1,71 @@
import { fastifyRequestContext } from "@fastify/request-context"
import { env } from "@follow/shared/env.ssr"
import type { FastifyRequest } from "fastify"
import Fastify from "fastify"
import { nanoid } from "nanoid"
import { FetchError } from "ofetch"
import { MetaError } from "./src/meta-handler"
import { globalRoute } from "./src/router/global"
import { ogRoute } from "./src/router/og"
declare module "@fastify/request-context" {
interface RequestContextData {
req: FastifyRequest
upstreamEnv: "prod" | "dev"
upstreamOrigin: string
}
}
export const createApp = () => {
const app = Fastify({})
// Test: minimal route to verify Fastify works in Workers
app.get("/healthz", async () => ({ ok: true }))
app.register(fastifyRequestContext)
app.after(() => {
app.setErrorHandler(function (err, req, reply) {
this.log.error(err)
const traceId = nanoid(8)
if (err instanceof FetchError) {
reply
.status((err as FetchError).response?.status || 500)
.send({ ok: false, traceId, message: err.message })
} else if (err instanceof MetaError) {
reply.status(err.status).send({ ok: false, traceId, message: err.metaMessage })
} else {
const message = (err as any).message || "Internal Server Error"
const status = Number.parseInt((err as any).code as string) || 500
reply.status(status).send({ ok: false, message, traceId })
}
})
app.addHook("onRequest", (req, reply, done) => {
req.requestContext.set("req", req)
const { host } = req.headers
const forwardedHost = req.headers["x-forwarded-host"]
const finalHost = forwardedHost || host
const upstreamEnv = finalHost?.includes("dev") ? "dev" : "prod"
req.requestContext.set("upstreamEnv", upstreamEnv)
if (upstreamEnv === "prod") {
req.requestContext.set("upstreamOrigin", env.VITE_WEB_PROD_URL || env.VITE_WEB_URL)
} else {
req.requestContext.set("upstreamOrigin", env.VITE_WEB_DEV_URL || env.VITE_WEB_URL)
}
reply.header("x-handled-host", finalHost)
done()
})
ogRoute(app)
globalRoute(app)
})
return app
}

334
apps/ssr/worker-entry.ts Normal file
View File

@ -0,0 +1,334 @@
import "./global"
// Global route dependencies
import { env } from "@follow/shared/env.ssr"
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 { 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_EXTERNAL_DEV_API_URL: string
VITE_EXTERNAL_PROD_API_URL: string
VITE_WEB_DEV_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 () => {
// Determine upstream env from host
const host = c.req.header("host") || ""
const forwardedHost = c.req.header("x-forwarded-host")
const finalHost = forwardedHost || host
const upstreamEnv =
finalHost === "dev.folo.is" || finalHost?.includes("folo-ssr-dev") ? "dev" : "prod"
// 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)
requestContext.set("upstreamEnv", upstreamEnv)
if (upstreamEnv === "prod") {
requestContext.set("upstreamOrigin", env.VITE_WEB_PROD_URL || env.VITE_WEB_URL)
} else {
requestContext.set("upstreamOrigin", env.VITE_WEB_DEV_URL || env.VITE_WEB_URL)
}
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(e?.message || "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 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 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 upstreamEnv = requestContext.get("upstreamEnv") as string
const upstreamOrigin = requestContext.get("upstreamOrigin") as string
if (upstreamEnv) {
document.head.prepend(document.createComment(`upstreamEnv: ${upstreamEnv}`))
const injectScript = (apiUrl: string) => {
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":"${apiUrl}","VITE_EXTERNAL_API_URL":"${apiUrl}","VITE_WEB_URL":"${upstreamOrigin}"})`
const $script = document.createElement("script")
$script.innerHTML = scriptContent
document.head.prepend($script)
}
if (upstreamEnv === "dev" && env.VITE_EXTERNAL_DEV_API_URL) {
injectScript(env.VITE_EXTERNAL_DEV_API_URL)
}
if (upstreamEnv === "prod" && env.VITE_EXTERNAL_PROD_API_URL) {
injectScript(env.VITE_EXTERNAL_PROD_API_URL)
}
}
}
// 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": {
const script = document.createElement("script")
script.innerHTML = `
window.__HYDRATE__ = window.__HYDRATE__ || {}
window.__HYDRATE__[${JSON.stringify(meta.key)}] = JSON.parse(${JSON.stringify(JSON.stringify(meta.data))})
`
document.head.append(script)
break
}
}
}
return document
}

74
apps/ssr/wrangler.jsonc Normal file
View File

@ -0,0 +1,74 @@
{
"$schema": "./node_modules/wrangler/config-schema.json",
"name": "folo-ssr",
"main": "dist/worker/worker-entry.mjs",
"compatibility_date": "2026-02-01",
"compatibility_flags": ["nodejs_compat"],
"account_id": "1f1d1678a2413a54c944b3081bab5c84",
"placement": {
"mode": "smart",
},
"limits": {
"cpu_ms": 30000,
},
"rules": [
{
"type": "CompiledWasm",
"globs": ["**/*.wasm"],
"fallthrough": true,
},
],
"assets": {
"directory": "../desktop/out/web",
"not_found_handling": "none",
"html_handling": "none",
"binding": "ASSETS",
},
"workers_dev": true,
"routes": [
{
"pattern": "app.folo.is/*",
"zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63",
},
],
"r2_buckets": [
{
"binding": "FONTS_BUCKET",
"bucket_name": "follow",
},
],
"vars": {
"VITE_API_URL": "https://api.folo.is",
"VITE_WEB_URL": "https://app.folo.is",
"VITE_EXTERNAL_DEV_API_URL": "https://api.dev.folo.is",
"VITE_EXTERNAL_PROD_API_URL": "https://api.folo.is",
"VITE_WEB_DEV_URL": "https://dev.folo.is",
"VITE_SENTRY_DSN": "https://e5bccf7428aa4e881ed5cb713fdff181@o4507542488023040.ingest.us.sentry.io/4507570439979008",
},
"env": {
"dev": {
"name": "folo-ssr-dev",
"workers_dev": true,
"routes": [
{
"pattern": "dev.folo.is/*",
"zone_id": "115ea8e6a7865dbfc1cf4530d5f87f63",
},
],
"r2_buckets": [
{
"binding": "FONTS_BUCKET",
"bucket_name": "follow",
},
],
"vars": {
"VITE_API_URL": "https://api.dev.folo.is",
"VITE_WEB_URL": "https://dev.folo.is",
"VITE_EXTERNAL_DEV_API_URL": "https://api.dev.folo.is",
"VITE_EXTERNAL_PROD_API_URL": "https://api.folo.is",
"VITE_WEB_DEV_URL": "https://dev.folo.is",
"VITE_SENTRY_DSN": "https://e5bccf7428aa4e881ed5cb713fdff181@o4507542488023040.ingest.us.sentry.io/4507570439979008",
},
},
},
}

File diff suppressed because it is too large Load Diff