diff --git a/src/components/auth/session-gate.tsx b/src/components/auth/session-gate.tsx
new file mode 100644
index 00000000..c0bdb85d
--- /dev/null
+++ b/src/components/auth/session-gate.tsx
@@ -0,0 +1,33 @@
+import { AlertCircle, Loader2 } from 'lucide-react'
+import { useTranslation } from 'react-i18next'
+import { Button } from '@/components/ui/button'
+
+export function SessionGatePending() {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
{t('auth.session.loading')}
+
+
+ )
+}
+
+export function SessionGateError({ reset }: { reset: () => void }) {
+ const { t } = useTranslation()
+
+ return (
+
+
+
+
+
{t('auth.session.errorTitle')}
+
{t('auth.session.errorDescription')}
+
+
+
+
+ )
+}
diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json
index 060fe5c1..fe933cdf 100644
--- a/src/i18n/locales/en.json
+++ b/src/i18n/locales/en.json
@@ -853,6 +853,10 @@
"common.globalSearchPlaceholder": "Search files, shares, teams and settings…",
"common.globalSearchComingSoon": "Global search is coming soon",
"common.moreOptions": "More options",
+ "auth.session.loading": "Checking your session...",
+ "auth.session.errorTitle": "Unable to load your session",
+ "auth.session.errorDescription": "The server did not respond. Check your connection and try again.",
+ "auth.session.retry": "Try again",
"teams.title": "Teams",
"teams.tabMembers": "Members",
"teams.tabActivity": "Activity",
diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json
index dbc199b6..1e986828 100644
--- a/src/i18n/locales/zh.json
+++ b/src/i18n/locales/zh.json
@@ -853,6 +853,10 @@
"common.globalSearchPlaceholder": "搜索文件、分享、团队和设置…",
"common.globalSearchComingSoon": "全局搜索即将推出",
"common.moreOptions": "更多选项",
+ "auth.session.loading": "正在检查登录状态...",
+ "auth.session.errorTitle": "无法加载登录状态",
+ "auth.session.errorDescription": "服务器没有响应,请检查网络连接后重试。",
+ "auth.session.retry": "重试",
"teams.title": "团队",
"teams.tabMembers": "成员",
"teams.tabActivity": "动态",
diff --git a/src/lib/api.test.ts b/src/lib/api.test.ts
index d449072a..8965053e 100644
--- a/src/lib/api.test.ts
+++ b/src/lib/api.test.ts
@@ -1616,14 +1616,32 @@ describe('api', () => {
const [url, init] = vi.mocked(fetch).mock.calls[0] as [string, RequestInit]
expect(url).toBe('/api/auth/get-session')
expect(init.credentials).toBe('include')
+ expect(init.signal).toBeInstanceOf(AbortSignal)
})
- it('returns null when response is not ok', async () => {
+ it('throws ApiError when response is not ok', async () => {
vi.mocked(fetch).mockResolvedValueOnce(makeResponse({ error: 'unauthorized' }, false, 401))
- const result = await getSession()
+ const promise = getSession()
- expect(result).toBeNull()
+ await expect(promise).rejects.toMatchObject({ name: 'ApiError', status: 401 })
+ })
+
+ it('aborts and throws when the session request times out', async () => {
+ vi.useFakeTimers()
+ vi.mocked(fetch).mockImplementationOnce(
+ (_url, init) =>
+ new Promise((_resolve, reject) => {
+ init?.signal?.addEventListener('abort', () => reject(new DOMException('Aborted', 'AbortError')))
+ }),
+ )
+
+ const promise = getSession()
+ const assertion = expect(promise).rejects.toThrow('Session request timed out')
+ await vi.advanceTimersByTimeAsync(10_000)
+
+ await assertion
+ vi.useRealTimers()
})
})
diff --git a/src/lib/api.ts b/src/lib/api.ts
index 73789255..8c62035c 100644
--- a/src/lib/api.ts
+++ b/src/lib/api.ts
@@ -128,6 +128,8 @@ export class ApiError extends Error {
}
}
+const SESSION_REQUEST_TIMEOUT_MS = 10_000
+
export interface NameConflictBody extends ApiErrorBody {
code: 'NAME_CONFLICT'
conflictingName: string
@@ -1051,9 +1053,22 @@ export function disconnectCloud() {
// Auth API — Better Auth passthrough, not typed via Hono RPC
export async function getSession(): Promise<{ session: unknown; user: unknown } | null> {
- const res = await fetch('/api/auth/get-session', { credentials: 'include' })
- if (!res.ok) return null
- return res.json()
+ const controller = new AbortController()
+ const timeout = window.setTimeout(() => controller.abort(), SESSION_REQUEST_TIMEOUT_MS)
+
+ try {
+ const res = await fetch('/api/auth/get-session', { credentials: 'include', signal: controller.signal })
+ if (!res.ok) {
+ const body = (await res.json().catch(() => ({}))) as ApiErrorBody
+ throw new ApiError(res.status, body)
+ }
+ return res.json()
+ } catch (error) {
+ if (controller.signal.aborted) throw new Error('Session request timed out')
+ throw error
+ } finally {
+ window.clearTimeout(timeout)
+ }
}
export interface UploadProgress {
diff --git a/src/routes/_authenticated/route.tsx b/src/routes/_authenticated/route.tsx
index db7972ea..c182d635 100644
--- a/src/routes/_authenticated/route.tsx
+++ b/src/routes/_authenticated/route.tsx
@@ -1,5 +1,6 @@
import { createFileRoute, Outlet, redirect, useMatchRoute } from '@tanstack/react-router'
import { SiteAnnouncements } from '@/components/announcements/site-announcements'
+import { SessionGateError, SessionGatePending } from '@/components/auth/session-gate'
import { AppSidebar } from '@/components/layout/app-sidebar'
import { GlobalSearchBar } from '@/components/layout/global-search-bar'
import { MusicPlayerButton } from '@/components/music/music-player-button'
@@ -17,6 +18,8 @@ export const Route = createFileRoute('/_authenticated')({
}
return { user: data.user }
},
+ pendingComponent: SessionGatePending,
+ errorComponent: SessionGateError,
component: AuthenticatedLayout,
})
diff --git a/src/routes/store/checkout.tsx b/src/routes/store/checkout.tsx
index f77d5fb6..b7b325cd 100644
--- a/src/routes/store/checkout.tsx
+++ b/src/routes/store/checkout.tsx
@@ -2,6 +2,7 @@ import { createFileRoute, Link, redirect } from '@tanstack/react-router'
import { Loader2 } from 'lucide-react'
import { useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
+import { SessionGateError, SessionGatePending } from '@/components/auth/session-gate'
import { Button } from '@/components/ui/button'
import {
ApiError,
@@ -30,6 +31,8 @@ export const Route = createFileRoute('/store/checkout')({
throw redirect({ to: '/sign-in', search: { redirect: redirectUrl } as never })
}
},
+ pendingComponent: SessionGatePending,
+ errorComponent: SessionGateError,
component: StorageCheckoutPage,
})
diff --git a/workers/bootstrap.ts b/workers/bootstrap.ts
index cb0d7ae7..eea71e80 100644
--- a/workers/bootstrap.ts
+++ b/workers/bootstrap.ts
@@ -16,11 +16,6 @@ interface Env {
[key: string]: unknown
}
-// Cache auth instance at isolate scope to avoid per-request DB queries
-// for OIDC config loading. Changes to OIDC provider configs or env vars
-// (BETTER_AUTH_URL, TRUSTED_ORIGINS) take effect on isolate recycle.
-let cachedAuth: Auth | null = null
-
const SHARE_TOKEN_RE = /^\/s\/([^/?#]+)/
export default {
@@ -30,24 +25,21 @@ export default {
throw new Error('BETTER_AUTH_SECRET is not configured for this deployment.')
}
const platform = createCloudflarePlatform(env)
-
- if (!cachedAuth) {
- 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]
- cachedAuth = await createAuth(platform, BETTER_AUTH_SECRET, baseURL, trustedOrigins)
- }
+ 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]
+ const auth = await createAuth(platform, BETTER_AUTH_SECRET, baseURL, trustedOrigins)
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 handleShareSsr(request, env, ctx, shareMatch[1], platform, auth)
}
- return createApp(platform, cachedAuth).fetch(request, env, ctx)
+ return createApp(platform, auth).fetch(request, env, ctx)
},
async scheduled(event: ScheduledEvent, env: Env): Promise {