Skip to content

Commit 81c5b9f

Browse files
DIYgodclaude
andauthored
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>
1 parent 0bba08f commit 81c5b9f

18 files changed

+1411
-32
lines changed
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
on:
2+
push:
3+
branches: [main, dev]
4+
5+
name: ☁️ Deploy to Cloudflare Workers
6+
concurrency:
7+
group: ${{ github.workflow }}-${{ github.ref }}
8+
cancel-in-progress: true
9+
jobs:
10+
deploy:
11+
name: Build & Deploy
12+
runs-on: ubuntu-latest
13+
strategy:
14+
matrix:
15+
node-version: [lts/*]
16+
steps:
17+
- name: Checkout code
18+
uses: actions/checkout@v6
19+
with:
20+
lfs: true
21+
22+
- name: Checkout LFS objects
23+
run: git lfs checkout
24+
25+
- name: Cache turbo build setup
26+
uses: actions/cache@v5
27+
with:
28+
path: .turbo
29+
key: ${{ runner.os }}-turbo-${{ github.sha }}
30+
restore-keys: |
31+
${{ runner.os }}-turbo-
32+
33+
- uses: pnpm/action-setup@v4
34+
35+
- name: Use Node.js ${{ matrix.node-version }}
36+
uses: actions/setup-node@v6
37+
with:
38+
node-version: ${{ matrix.node-version }}
39+
cache: "pnpm"
40+
41+
- name: Install dependencies
42+
run: pnpm install
43+
44+
- name: Build desktop web (SPA)
45+
run: pnpm exec turbo run Folo#build:web
46+
47+
- name: Build SSR Worker
48+
working-directory: apps/ssr
49+
run: pnpm run build:worker
50+
51+
- name: Copy WASM file
52+
run: cp node_modules/@resvg/resvg-wasm/index_bg.wasm apps/ssr/dist/worker/resvg.wasm
53+
54+
- name: Deploy to Cloudflare (dev)
55+
if: github.ref == 'refs/heads/dev'
56+
uses: cloudflare/wrangler-action@v3
57+
with:
58+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
59+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
60+
workingDirectory: apps/ssr
61+
command: deploy --env dev
62+
63+
- name: Deploy to Cloudflare (prod)
64+
if: github.ref == 'refs/heads/main'
65+
uses: cloudflare/wrangler-action@v3
66+
with:
67+
apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
68+
accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
69+
workingDirectory: apps/ssr
70+
command: deploy

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,5 @@ apps/desktop/build/appxmanifest.xml
2929

3030
.claude/settings.local.json
3131
.serena
32+
33+
.wrangler

apps/ssr/package.json

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44
"private": true,
55
"scripts": {
66
"build": "cross-env NODE_ENV=production vite build && tsx scripts/prepare-vercel-build.ts && tsdown && tsx scripts/cleanup-vercel-build.ts",
7+
"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",
8+
"deploy:dev": "wrangler deploy --env dev",
9+
"deploy:prod": "wrangler deploy",
710
"dev": "cross-env NODE_ENV=development tsx watch --include \"src/**/*.ts\" --exclude \"./*.ts\" --exclude \"./*.mjs\" index.ts",
811
"meta": "tsx helper/meta-map.ts --watch",
912
"start": "tsx index.ts",
@@ -54,6 +57,7 @@
5457
"@follow/shared": "workspace:*",
5558
"@follow/types": "workspace:*",
5659
"@follow/utils": "workspace:*",
60+
"@resvg/resvg-wasm": "2.6.2",
5761
"@types/html-minifier-terser": "7.0.2",
5862
"chokidar": "4.0.3",
5963
"code-inspector-plugin": "1.4.2",
@@ -62,6 +66,7 @@
6266
"es-toolkit": "1.44.0",
6367
"fast-glob": "3.3.3",
6468
"foxact": "0.2.52",
69+
"hono": "4.12.1",
6570
"html-minifier-terser": "7.2.0",
6671
"lightningcss": "1.31.1",
6772
"masonic": "4.1.0",
@@ -72,6 +77,7 @@
7277
"tsx": "4.21.0",
7378
"typescript": "catalog:",
7479
"vite": "7.3.1",
75-
"vite-plugin-route-builder": "0.4.1"
80+
"vite-plugin-route-builder": "0.4.1",
81+
"wrangler": "4.67.0"
7682
}
7783
}

apps/ssr/scripts/check-fonts.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
import fs from "node:fs"
2+
import { createRequire } from "node:module"
3+
4+
import path, { resolve } from "pathe"
5+
6+
const require = createRequire(import.meta.url)
7+
8+
const snPath = require.resolve("@fontsource/sn-pro")
9+
const filesDir = resolve(snPath, "../files")
10+
const files = fs.readdirSync(filesDir).filter((f) => !f.endsWith(".woff2") && !f.includes("italic"))
11+
files.forEach((f) => {
12+
const stat = fs.statSync(path.join(filesDir, f))
13+
console.info(f, `${(stat.size / 1024).toFixed(1)}KB`)
14+
})
15+
const kosePath = require.resolve("kose-font")
16+
const koseSize = fs.statSync(kosePath).size
17+
console.info(`kose-font: ${(koseSize / 1024).toFixed(1)}KB`)
18+
let total = files.reduce((sum, f) => sum + fs.statSync(path.join(filesDir, f)).size, 0)
19+
total += koseSize
20+
console.info(`Total: ${(total / 1024 / 1024).toFixed(1)}MB`)
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import fs from "node:fs"
2+
import { createRequire } from "node:module"
3+
import { fileURLToPath } from "node:url"
4+
5+
import path, { dirname, resolve } from "pathe"
6+
7+
const __dirname = dirname(fileURLToPath(import.meta.url))
8+
const require = createRequire(import.meta.url)
9+
10+
const weights = [
11+
{ name: "Thin", weight: 100 },
12+
{ name: "ExtraLight", weight: 200 },
13+
{ name: "Light", weight: 300 },
14+
{ name: "Regular", weight: 400 },
15+
{ name: "Italic", weight: 400 },
16+
{ name: "Medium", weight: 500 },
17+
{ name: "SemiBold", weight: 600 },
18+
{ name: "Bold", weight: 700 },
19+
{ name: "ExtraBold", weight: 800 },
20+
{ name: "Black", weight: 900 },
21+
] as const
22+
23+
const snFontDepsPath = require.resolve("@fontsource/sn-pro")
24+
const snFontsDirPath = resolve(snFontDepsPath, "../files")
25+
const snFontsDir = fs
26+
.readdirSync(snFontsDirPath)
27+
.filter((name) => !name.endsWith(".woff2") && !name.includes("italic"))
28+
29+
const fontsData: Record<string, string> = {}
30+
31+
for (const file of snFontsDir) {
32+
const weight = weights.find((w) => file.includes(w.weight.toString()))
33+
if (!weight) continue
34+
const data = fs.readFileSync(path.join(snFontsDirPath, file))
35+
fontsData[`sn-pro-${weight.weight}`] = data.toString("base64")
36+
}
37+
38+
// kose-font is too large (~24MB) to bundle, loaded from R2 at runtime instead
39+
40+
const outDir = path.join(__dirname, "../.generated")
41+
fs.mkdirSync(outDir, { recursive: true })
42+
fs.writeFileSync(
43+
path.join(outDir, "fonts-data.ts"),
44+
`export default ${JSON.stringify(fontsData)} as Record<string, string>`,
45+
)
46+
console.info("Generated fonts-data.ts with", Object.keys(fontsData).length, "fonts")
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import fs from "node:fs"
2+
3+
import { dirname, join } from "pathe"
4+
5+
const distDir = join(dirname(import.meta.url.replace("file://", "")), "../dist/worker")
6+
7+
const files = fs.readdirSync(distDir).filter((f) => f.endsWith(".mjs"))
8+
9+
for (const file of files) {
10+
const filePath = join(distDir, file)
11+
const code = fs.readFileSync(filePath, "utf-8")
12+
13+
// Fix createRequire(import.meta.url) - import.meta.url is undefined in Cloudflare Workers
14+
// Provide a fallback URL so createRequire can initialize properly
15+
const patched = code.replaceAll(
16+
"createRequire(import.meta.url)",
17+
'createRequire(import.meta.url || "file:///worker.mjs")',
18+
)
19+
20+
if (patched !== code) {
21+
fs.writeFileSync(filePath, patched)
22+
console.info(`Patched createRequire in ${file}`)
23+
}
24+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { execSync } from "node:child_process"
2+
import fs from "node:fs"
3+
import { createRequire } from "node:module"
4+
5+
import path, { resolve } from "pathe"
6+
7+
const require = createRequire(import.meta.url)
8+
9+
const BUCKET = "follow"
10+
const PREFIX = "ssr-fonts"
11+
const ACCOUNT_ID = "1f1d1678a2413a54c944b3081bab5c84"
12+
13+
const snFontDepsPath = require.resolve("@fontsource/sn-pro")
14+
const snFontsDirPath = resolve(snFontDepsPath, "../files")
15+
const snFontsDir = fs
16+
.readdirSync(snFontsDirPath)
17+
.filter((name) => !name.endsWith(".woff2") && !name.includes("italic"))
18+
19+
for (const file of snFontsDir) {
20+
const filePath = path.join(snFontsDirPath, file)
21+
const key = `${PREFIX}/${file}`
22+
console.info(`Uploading ${file}...`)
23+
execSync(
24+
`CLOUDFLARE_ACCOUNT_ID=${ACCOUNT_ID} npx wrangler r2 object put ${BUCKET}/${key} --file "${filePath}" --remote`,
25+
{ stdio: "inherit" },
26+
)
27+
}
28+
29+
const koseFontPath = require.resolve("kose-font")
30+
console.info("Uploading kose-font.ttf...")
31+
execSync(
32+
`CLOUDFLARE_ACCOUNT_ID=${ACCOUNT_ID} npx wrangler r2 object put ${BUCKET}/${PREFIX}/kose-font.ttf --file "${koseFontPath}" --remote`,
33+
{ stdio: "inherit" },
34+
)
35+
36+
console.info("All fonts uploaded successfully!")
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// No-op for Cloudflare Workers - environment variables are provided via wrangler config
2+
export {}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
import fontsBase64Data from "../../../.generated/fonts-data"
2+
3+
let cachedFonts: any[] | null = null
4+
let koseFont: any | null = null
5+
6+
// Global reference to R2 bucket, set by worker-entry.ts
7+
let _fontsBucket: R2Bucket | null = null
8+
export function setFontsBucket(bucket: R2Bucket) {
9+
_fontsBucket = bucket
10+
}
11+
12+
function decodeSNProFonts(): any[] {
13+
const fontsData: any[] = []
14+
for (const [key, base64] of Object.entries(fontsBase64Data)) {
15+
if (key === "kose-400") continue
16+
const weightStr = key.split("-").pop()!
17+
const weight = Number.parseInt(weightStr)
18+
const buf = Uint8Array.from(atob(base64), (c) => c.codePointAt(0)!)
19+
fontsData.push({
20+
name: "SN Pro",
21+
data: buf.buffer,
22+
weight,
23+
style: "normal" as const,
24+
})
25+
}
26+
return fontsData
27+
}
28+
29+
async function loadKoseFont(): Promise<any | null> {
30+
if (koseFont) return koseFont
31+
if (!_fontsBucket) {
32+
console.warn("R2 fonts bucket not configured, skipping kose-font")
33+
return null
34+
}
35+
try {
36+
const obj = await _fontsBucket.get("ssr-fonts/kose-font.ttf")
37+
if (!obj) {
38+
console.warn("Kose font not found in R2")
39+
return null
40+
}
41+
const data = await obj.arrayBuffer()
42+
koseFont = {
43+
name: "Kose",
44+
data,
45+
weight: 400,
46+
style: "normal" as const,
47+
}
48+
return koseFont
49+
} catch (e) {
50+
console.error("Failed to load kose font from R2:", e)
51+
return null
52+
}
53+
}
54+
55+
export async function getFonts(): Promise<any[]> {
56+
if (cachedFonts) return cachedFonts
57+
58+
const snProFonts = decodeSNProFonts()
59+
const kose = await loadKoseFont()
60+
61+
cachedFonts = kose ? [...snProFonts, kose] : snProFonts
62+
return cachedFonts
63+
}
64+
65+
// Default export for compatibility with original fonts module API
66+
export default [] as any[]
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import type { ReactElement } from "react"
2+
import type { SatoriOptions } from "satori"
3+
import satori from "satori"
4+
5+
import { getFonts } from "./fonts.worker"
6+
import { ensureInitialized, Resvg } from "./resvg-wasm-shim"
7+
8+
export async function renderToImage(
9+
node: ReactElement,
10+
options: {
11+
width?: number
12+
height: number
13+
debug?: boolean
14+
fonts?: SatoriOptions["fonts"]
15+
},
16+
) {
17+
await ensureInitialized()
18+
19+
const fonts = options.fonts || (await getFonts())
20+
21+
const svg = await satori(node, {
22+
...options,
23+
fonts,
24+
})
25+
26+
const w = new Resvg(svg)
27+
const image = w.render().asPng()
28+
29+
return {
30+
image,
31+
contentType: "image/png",
32+
}
33+
}

0 commit comments

Comments
 (0)