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:
parent
0bba08f103
commit
81c5b9f2e0
|
|
@ -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
|
||||
|
|
@ -29,3 +29,5 @@ apps/desktop/build/appxmanifest.xml
|
|||
|
||||
.claude/settings.local.json
|
||||
.serena
|
||||
|
||||
.wrangler
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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`)
|
||||
|
|
@ -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")
|
||||
|
|
@ -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}`)
|
||||
}
|
||||
}
|
||||
|
|
@ -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!")
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
// No-op for Cloudflare Workers - environment variables are provided via wrangler config
|
||||
export {}
|
||||
|
|
@ -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[]
|
||||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -40,5 +40,5 @@
|
|||
"./tailwind.config.ts",
|
||||
"./helper/**/*.ts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
"exclude": ["node_modules", "./src/**/*.worker.ts"]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
562
pnpm-lock.yaml
562
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue