mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-21 13:20:33 +08:00
better-auth starts its $context init synchronously inside betterAuth(), within whichever request constructs the instance. Init eagerly resolves all social providers, and ours were 35 async functions doing one D1 query each. When the isolate's first request didn't touch auth (share SSR, /r/*, public APIs) or disconnected mid-init, its I/O context died with the queries in flight and $context never settled — on Workers a pending promise awaited from a later request never resolves, so the cached auth instance hung every subsequent auth call in the isolate (the recurring "get-session pending forever / 10s timeout" reports). - load all OAuth provider configs with one snapshot query; register builtin providers as static objects (init does zero per-provider I/O) - await auth.$context before returning from createAuth so a cached instance can never carry a pending promise tied to its creating request - only load captcha config for captcha-protected endpoints instead of every /api/auth/* request - cache the resolved site public origin at module scope (the WeakMap was keyed by the per-request db instance and never hit on Workers); cache settled values only, never promises - client: share one in-flight get-session across callers regardless of TTL, cache resolved values for 5s, never cache failures Regression tests pin the invariants: createAuth performs exactly one DB query during init and returns with $context already settled. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
162 lines
5.3 KiB
TypeScript
162 lines
5.3 KiB
TypeScript
import { createApp } from '../server/app'
|
|
import type { Auth } from '../server/auth'
|
|
import { createAuth } from '../server/auth'
|
|
import { createCloudflarePlatform } from '../server/platform/cloudflare'
|
|
import { platformContext } from '../server/platform/context'
|
|
import { type ArchiveJobMessage, runArchiveJobMessage } from '../server/services/archive-jobs'
|
|
import { resolveShareByToken } from '../server/services/share'
|
|
import { DirType } from '../shared/constants'
|
|
import { handleScheduled } from './scheduled'
|
|
|
|
interface Env {
|
|
DB: D1Database
|
|
BETTER_AUTH_SECRET: string
|
|
BETTER_AUTH_URL?: string
|
|
TRUSTED_ORIGINS?: string
|
|
ASSETS: Fetcher
|
|
[key: string]: unknown
|
|
}
|
|
|
|
const SHARE_TOKEN_RE = /^\/s\/([^/?#]+)/
|
|
|
|
// Cache auth instance at isolate scope to avoid per-request DB queries and
|
|
// better-auth init CPU. createAuth resolves $context before returning, so the
|
|
// cached instance never carries a pending promise tied to its creating request
|
|
// (which would hang every later auth call in the isolate). Changes to OAuth
|
|
// provider configs or env vars (BETTER_AUTH_URL, TRUSTED_ORIGINS) take effect
|
|
// on isolate recycle.
|
|
let cachedAuth: Auth | null = null
|
|
|
|
export default {
|
|
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
|
const { BETTER_AUTH_SECRET } = env
|
|
if (!BETTER_AUTH_SECRET) {
|
|
throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.')
|
|
}
|
|
const platform = createCloudflarePlatform(env)
|
|
const origin = new URL(request.url).origin
|
|
const baseURL = env.BETTER_AUTH_URL || origin
|
|
const trustedOrigins = env.TRUSTED_ORIGINS?.split(',')
|
|
.map((o) => o.trim())
|
|
.filter(Boolean) || [origin]
|
|
|
|
if (!cachedAuth) {
|
|
cachedAuth = await createAuth(platform, BETTER_AUTH_SECRET, baseURL, trustedOrigins)
|
|
}
|
|
|
|
return platformContext.run(platform, async () => {
|
|
const url = new URL(request.url)
|
|
const shareMatch = SHARE_TOKEN_RE.exec(url.pathname)
|
|
|
|
if (shareMatch && request.method === 'GET') {
|
|
return handleShareSsr(request, env, ctx, shareMatch[1], platform, cachedAuth!)
|
|
}
|
|
|
|
return createApp(platform, cachedAuth!).fetch(request, env, ctx)
|
|
})
|
|
},
|
|
|
|
async scheduled(event: ScheduledEvent, env: Env): Promise<void> {
|
|
await handleScheduled(event, env)
|
|
},
|
|
|
|
async queue(batch: MessageBatch<ArchiveJobMessage>, env: Env): Promise<void> {
|
|
const platform = createCloudflarePlatform(env)
|
|
for (const message of batch.messages) {
|
|
await runArchiveJobMessage(platform, message.body)
|
|
message.ack()
|
|
}
|
|
},
|
|
}
|
|
|
|
interface ShareMeta {
|
|
title: string
|
|
description: string
|
|
imageUrl: string
|
|
}
|
|
|
|
async function fetchShareMeta(
|
|
platform: ReturnType<typeof createCloudflarePlatform>,
|
|
origin: string,
|
|
token: string,
|
|
): Promise<ShareMeta> {
|
|
const fallback: ShareMeta = {
|
|
title: 'Share unavailable',
|
|
description: 'Shared via ZPan',
|
|
imageUrl: `${origin}/logo-512.png`,
|
|
}
|
|
|
|
try {
|
|
const resolved = await resolveShareByToken(platform.db, token)
|
|
if (resolved.status !== 'ok') return fallback
|
|
if (resolved.share.kind !== 'landing') return fallback
|
|
|
|
const { share, matter } = resolved
|
|
const expiry = share.expiresAt ? ` · Expires ${new Date(share.expiresAt).toLocaleDateString()}` : ''
|
|
const description = `Shared via ZPan${expiry}`
|
|
const isImage = matter.type.startsWith('image/') && matter.dirtype === DirType.FILE
|
|
|
|
return {
|
|
title: matter.name,
|
|
description,
|
|
imageUrl: isImage ? `${origin}/api/share/${token}/download` : `${origin}/logo-512.png`,
|
|
}
|
|
} catch {
|
|
return fallback
|
|
}
|
|
}
|
|
|
|
function escapeAttr(s: string): string {
|
|
return s.replace(/&/g, '&').replace(/"/g, '"')
|
|
}
|
|
|
|
function buildOgTags(meta: ShareMeta, pageUrl: string): string {
|
|
return [
|
|
`<meta property="og:title" content="${escapeAttr(meta.title)}" />`,
|
|
`<meta property="og:description" content="${escapeAttr(meta.description)}" />`,
|
|
`<meta property="og:image" content="${escapeAttr(meta.imageUrl)}" />`,
|
|
`<meta property="og:type" content="website" />`,
|
|
`<meta property="og:url" content="${escapeAttr(pageUrl)}" />`,
|
|
`<meta name="twitter:card" content="summary_large_image" />`,
|
|
`<meta name="twitter:title" content="${escapeAttr(meta.title)}" />`,
|
|
`<meta name="twitter:description" content="${escapeAttr(meta.description)}" />`,
|
|
`<meta name="twitter:image" content="${escapeAttr(meta.imageUrl)}" />`,
|
|
].join('\n ')
|
|
}
|
|
|
|
async function handleShareSsr(
|
|
request: Request,
|
|
env: Env,
|
|
ctx: ExecutionContext,
|
|
token: string,
|
|
platform: ReturnType<typeof createCloudflarePlatform>,
|
|
auth: Auth,
|
|
): Promise<Response> {
|
|
const url = new URL(request.url)
|
|
const origin = url.origin
|
|
|
|
const [meta, spaRes] = await Promise.all([
|
|
fetchShareMeta(platform, origin, token),
|
|
env.ASSETS.fetch(new Request(`${origin}/index.html`, { headers: request.headers })),
|
|
])
|
|
|
|
if (!spaRes.ok) {
|
|
return createApp(platform, auth).fetch(request, env, ctx)
|
|
}
|
|
|
|
const html = await spaRes.text()
|
|
const ogTags = buildOgTags(meta, url.href)
|
|
const injected = html.replace(
|
|
'<title>ZPan</title>',
|
|
`<title>${meta.title.replace(/</g, '<')} — ZPan</title>\n ${ogTags}`,
|
|
)
|
|
|
|
return new Response(injected, {
|
|
status: 200,
|
|
headers: {
|
|
'Content-Type': 'text/html; charset=UTF-8',
|
|
'Cache-Control': 'no-store',
|
|
},
|
|
})
|
|
}
|