diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index f87c790..557a1e4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -53,6 +53,9 @@ jobs: name: 🚀 Deploy to production needs: sha-guard if: needs.sha-guard.outputs.should_deploy == 'true' + env: + CLOUDFLARE_ACCOUNT_ID: + ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} environment: name: production url: ${{ steps.deploy.outputs.url }} diff --git a/.github/workflows/preview.yml b/.github/workflows/preview.yml index baa50d6..1e5d9ee 100644 --- a/.github/workflows/preview.yml +++ b/.github/workflows/preview.yml @@ -52,6 +52,9 @@ jobs: deploy: runs-on: ubuntu-latest name: 🔎 Deploy Preview Resources + env: + CLOUDFLARE_ACCOUNT_ID: + ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} environment: name: preview-${{ github.event.pull_request.number || inputs.pr_number || @@ -433,6 +436,9 @@ jobs: cleanup: runs-on: ubuntu-latest name: 🧹 Cleanup Preview Resources + env: + CLOUDFLARE_ACCOUNT_ID: + ${{ vars.CLOUDFLARE_ACCOUNT_ID || secrets.CLOUDFLARE_ACCOUNT_ID }} if: >- (github.event_name == 'pull_request' && github.event.action == 'closed' && diff --git a/client/app-root.tsx b/client/app-root.tsx new file mode 100644 index 0000000..e6a8f34 --- /dev/null +++ b/client/app-root.tsx @@ -0,0 +1,31 @@ +import { clientEntry, type EntryComponent, type Handle } from 'remix/ui' +import { type AppLoaderData } from '#shared/loader-data.ts' +import { App } from './app.tsx' +import { AppLoaderDataProvider } from './loader-data-context.tsx' +import { RouterLocationProvider } from './router-location.tsx' +import { type SessionInfo } from './session.ts' + +export const APP_ROOT_ENTRY_ID = '/client-entry.js#AppRoot' + +export type AppRootProps = { + url: string + session: SessionInfo | null + loaderData?: AppLoaderData + notFound?: boolean +} + +export const AppRoot: EntryComponent = clientEntry( + APP_ROOT_ENTRY_ID, + function AppRoot(handle: Handle) { + return () => ( + + + + + + ) + }, +) diff --git a/client/app.tsx b/client/app.tsx index b415dce..bfea0af 100644 --- a/client/app.tsx +++ b/client/app.tsx @@ -1,5 +1,6 @@ import { css, type Handle } from 'remix/ui' -import { clientRoutes } from './routes/index.tsx' +import { routes } from '#server/routes.ts' +import { clientRouteLoaders, clientRoutes } from './routes/index.tsx' import { getPathname, listenToRouterNavigation, @@ -10,14 +11,21 @@ import { type SessionInfo, type SessionStatus, } from './session.ts' +import { SessionProvider } from './session-context.tsx' import { buildAuthLink } from './auth-links.ts' import { colors, mq, spacing, typography } from './styles/tokens.ts' -export function App(handle: Handle) { - let session: SessionInfo | null = null - let sessionStatus: SessionStatus = 'idle' + +type AppProps = { + embeddedSession?: SessionInfo | null + notFound?: boolean +} + +export function App(handle: Handle) { + let session: SessionInfo | null = handle.props?.embeddedSession ?? null + let sessionStatus: SessionStatus = 'ready' let sessionRefreshInFlight = false let sessionRefreshQueued = false - let currentPathname = getPathname() + let currentPathname = getPathname(handle) function queueSessionRefresh() { sessionRefreshQueued = true if (sessionRefreshInFlight) return @@ -29,9 +37,22 @@ export function App(handle: Handle) { sessionRefreshQueued = false sessionRefreshInFlight = true handle.queueTask(async (signal) => { - const nextSession = await fetchSessionInfo(signal) + let nextSession: SessionInfo | null = null + try { + nextSession = await fetchSessionInfo(signal) + } catch (error) { + sessionRefreshInFlight = false + if (signal.aborted) return + console.error('Session refresh failed', error) + sessionStatus = 'ready' + handle.update() + return + } sessionRefreshInFlight = false - if (signal.aborted) return + if (signal.aborted) { + if (sessionRefreshQueued) queueSessionRefresh() + return + } session = nextSession sessionStatus = 'ready' handle.update() @@ -43,11 +64,13 @@ export function App(handle: Handle) { handle.update() } } - handle.queueTask(() => { - queueSessionRefresh() - }) + if (typeof window !== 'undefined') { + handle.queueTask(() => { + queueSessionRefresh() + }) + } listenToRouterNavigation(handle, () => { - currentPathname = getPathname() + currentPathname = getPathname(handle) queueSessionRefresh() handle.update() }) @@ -85,11 +108,12 @@ export function App(handle: Handle) { const isLoggedIn = isSessionReady && Boolean(sessionEmail) const showAuthLinks = isSessionReady && !isLoggedIn const oauthRedirectTo = - typeof window !== 'undefined' && currentPathname === '/oauth/authorize' + typeof window !== 'undefined' && + currentPathname === routes.oauthAuthorize.href() ? `${currentPathname}${window.location.search}` : null - const loginHref = buildAuthLink('/login', oauthRedirectTo) - const signupHref = buildAuthLink('/signup', oauthRedirectTo) + const loginHref = buildAuthLink(routes.login.href(), oauthRedirectTo) + const signupHref = buildAuthLink(routes.signup.href(), oauthRedirectTo) return (
- + - + Chat - + {sessionEmail} -
+ @@ -167,28 +199,32 @@ export function App(handle: Handle) { ) : null} - -

- Not Found -

-

- That route does not exist. -

- - } - /> + + +

+ Not Found +

+

+ That route does not exist. +

+ + } + /> +
) } diff --git a/client/client-router.tsx b/client/client-router.tsx index 451e2c0..db7c36b 100644 --- a/client/client-router.tsx +++ b/client/client-router.tsx @@ -1,8 +1,25 @@ import { addEventListeners, type Handle } from 'remix/ui' +import { createMultiMatcher } from 'remix/route-pattern/match' +import { + markNavigationDataStale, + setPreloadedNavigationData, +} from './navigation-data.ts' +import { + isRouteLoaderRedirect, + type RouteLoader, + type RouteLoaderResult, +} from './route-loader.ts' +import { + readRouterPathname, + readRouterUrl, + readSsrRouterUrl, +} from './router-location.tsx' type RouterSetup = { routes: Record fallback?: JSX.Element + loaders?: Record + notFound?: boolean } type FormMethod = 'get' | 'post' @@ -56,7 +73,18 @@ type FormSubmitDetails = { } export const routerEvents = new EventTarget() +const clientRouteOrigin = 'https://epicflare.local' +const routeMatchers = new WeakMap< + Record, + ReturnType +>() +const routeLoaderMatchers = new WeakMap< + Record, + ReturnType +>() let routerInitialized = false +let activeRouteLoaders: Record | null = null +let pendingProgrammaticNavigationPath: string | null = null function notify() { routerEvents.dispatchEvent(new Event('navigate')) @@ -73,28 +101,55 @@ function isSameOriginUrl(url: URL) { return url.origin === window.location.origin } -function compileRoutePattern(pattern: string) { - const regexPattern = pattern - .replace(/:([^/]+)/g, '([^/]+)') - .replace(/\*/g, '.*') +function createRouteMatcher(routes: Record) { + const matcher = createMultiMatcher() + for (const [pattern, routeElement] of Object.entries(routes)) { + matcher.add(pattern, routeElement) + } + return matcher +} - return { - pattern: new RegExp(`^${regexPattern}$`), +function createRouteLoaderMatcher(loaders: Record) { + const matcher = createMultiMatcher() + for (const [pattern, loader] of Object.entries(loaders)) { + matcher.add(pattern, loader) } + return matcher } -function matchRoute( +function getRouteMatcher(routes: Record) { + const existing = routeMatchers.get(routes) + if (existing) return existing + const matcher = createRouteMatcher(routes) + routeMatchers.set(routes, matcher) + return matcher +} + +function getRouteLoaderMatcher(loaders: Record) { + const existing = routeLoaderMatchers.get(loaders) + if (existing) return existing + const matcher = createRouteLoaderMatcher(loaders) + routeLoaderMatchers.set(loaders, matcher) + return matcher +} + +export function matchRoute( path: string, routes: Record, ): JSX.Element | null { - for (const [pattern, routeElement] of Object.entries(routes)) { - const { pattern: compiled } = compileRoutePattern(pattern) - const result = compiled.exec(path) - if (!result) continue - return routeElement - } + return ( + getRouteMatcher(routes).match(new URL(path, clientRouteOrigin))?.data ?? + null + ) +} - return null +function matchRouteLoader(path: string) { + if (!activeRouteLoaders) return null + return ( + getRouteLoaderMatcher(activeRouteLoaders).match( + new URL(path, clientRouteOrigin), + )?.data ?? null + ) } function shouldHandleClick(event: MouseEvent, anchor: HTMLAnchorElement) { @@ -244,12 +299,60 @@ function getPathWithSearchAndHashFromUrl(url: URL) { return `${url.pathname}${url.search}${url.hash}` } -function navigateWithRefreshForSamePath(destination: URL) { - if ( - getPathWithSearchAndHashFromUrl(destination) === - getCurrentPathWithSearchAndHash() - ) { - notify() +function consumeProgrammaticNavigation(path: string) { + if (pendingProgrammaticNavigationPath !== path) return false + pendingProgrammaticNavigationPath = null + return true +} + +async function preloadRouteData(destination: URL, signal: AbortSignal) { + const path = getPathWithSearchAndHashFromUrl(destination) + const loader = matchRouteLoader(path) + if (!loader) return null + return loader(destination, signal) +} + +function commitRouteLoaderResult( + destination: URL, + result: RouteLoaderResult | null, +) { + const path = getPathWithSearchAndHashFromUrl(destination) + if (!result) return false + if (isRouteLoaderRedirect(result)) { + window.location.assign(result.to) + return true + } + if (Object.keys(result).length > 0) { + setPreloadedNavigationData(path, result) + } + return false +} + +async function preloadAndCommitNavigationData( + destination: URL, + signal: AbortSignal, +) { + try { + return commitRouteLoaderResult( + destination, + await preloadRouteData(destination, signal), + ) + } catch (error) { + if (signal.aborted) return false + markNavigationDataStale(getPathWithSearchAndHashFromUrl(destination)) + console.error('Route loader failed', error) + return false + } +} + +async function navigateWithRefreshForSamePath(destination: URL) { + const path = getPathWithSearchAndHashFromUrl(destination) + if (path === getCurrentPathWithSearchAndHash()) { + const redirected = await preloadAndCommitNavigationData( + destination, + new AbortController().signal, + ) + if (!redirected) notify() return } navigate(destination.toString()) @@ -298,7 +401,7 @@ async function submitFormThroughRouter(details: FormSubmitDetails) { } const destination = await submitPostFormThroughRouter(details) - navigateWithRefreshForSamePath(destination) + await navigateWithRefreshForSamePath(destination) } function handleDocumentSubmit(event: Event) { @@ -339,10 +442,22 @@ function handleNavigationEvent(event: RouterNavigateEvent) { // handling is consistently supported across Navigation API browsers. return } + const destination = new URL(event.destination.url, window.location.href) + const nextPath = getPathWithSearchAndHashFromUrl(destination) + const isProgrammaticNavigation = consumeProgrammaticNavigation(nextPath) event.intercept({ - handler() { - notify() + async handler() { + if (isProgrammaticNavigation) { + notify() + return + } + const controller = new AbortController() + const redirected = await preloadAndCommitNavigationData( + destination, + controller.signal, + ) + if (!redirected) notify() }, }) } @@ -364,16 +479,24 @@ function ensureRouter() { } export function listenToRouterNavigation( - handle: Handle, + handle: Pick, listener: () => void, ) { + if (typeof document === 'undefined') return ensureRouter() addEventListeners(routerEvents, handle.signal, { navigate: () => listener(), }) } -export function getPathname() { +export function getPathname(handle?: Pick) { + if (handle) { + try { + return readRouterPathname(handle) + } catch { + // Router location context is unavailable outside the app tree. + } + } if (typeof window === 'undefined') return '/' return window.location.pathname } @@ -396,6 +519,7 @@ export function navigate(to: string) { const navigationApi = getNavigationApi() if (navigationApi) { + pendingProgrammaticNavigationPath = nextPath navigationApi.navigate(nextPath) return } @@ -404,13 +528,36 @@ export function navigate(to: string) { notify() } -export function Router(handle: Handle) { - listenToRouterNavigation(handle, () => { - void handle.update() - }) +type RouterHandle = Pick & { + props: RouterSetup +} + +function normalizeHref(href: string) { + const url = new URL(href, clientRouteOrigin) + return `${url.pathname}${url.search}${url.hash}` +} + +function isOnSsrUrl(handle: Pick) { + return ( + normalizeHref(readRouterUrl(handle)) === + normalizeHref(readSsrRouterUrl(handle)) + ) +} + +export function Router(handle: RouterHandle) { + activeRouteLoaders = handle.props.loaders ?? null + if (typeof document !== 'undefined') { + listenToRouterNavigation(handle, () => { + void handle.update() + }) + } return () => { - const path = getPathname() + if (handle.props.notFound && isOnSsrUrl(handle)) { + return handle.props.fallback ?? null + } + + const path = getPathname(handle) const routeElement = matchRoute(path, handle.props.routes) if (routeElement) return routeElement return handle.props.fallback ?? null diff --git a/client/entry.tsx b/client/entry.tsx index 3cd4ede..5e18215 100644 --- a/client/entry.tsx +++ b/client/entry.tsx @@ -1,9 +1,28 @@ -import { createRoot } from 'remix/ui' -import { App } from './app.tsx' +import { run } from 'remix/ui' +import { APP_ROOT_ENTRY_ID, AppRoot } from './app-root.tsx' -const rootElement = document.getElementById('root') ?? document.body -if (rootElement.childNodes.length > 0) { - // Remix alpha.3 auto-hydrates non-empty containers. We render from scratch. - rootElement.replaceChildren() +const clientRegistry: Record = { + AppRoot, } -createRoot(rootElement).render() + +const app = run({ + loadModule(moduleUrl, exportName) { + const expectedHref = APP_ROOT_ENTRY_ID.split('#')[0] + if (moduleUrl !== expectedHref) { + throw new Error(`Unknown client module URL: ${moduleUrl}`) + } + + const component = clientRegistry[exportName] + if (!component) { + throw new Error(`Unknown client export: ${exportName}`) + } + + return component + }, +}) + +app.addEventListener('error', (event) => { + console.error('Client hydration error:', event.error) +}) + +void app.ready() diff --git a/client/loader-data-context.tsx b/client/loader-data-context.tsx new file mode 100644 index 0000000..5dbbd5a --- /dev/null +++ b/client/loader-data-context.tsx @@ -0,0 +1,85 @@ +import { type Handle, type RemixNode } from 'remix/ui' +import { type AppLoaderData } from '#shared/loader-data.ts' +import { tryConsumePreloadedLoaderData } from './navigation-data.ts' +import { readSsrRouterUrl } from './router-location.tsx' + +export type AppLoaderDataContextValue = { + loaderData?: AppLoaderData + consumedKeys: Set +} + +const routerHrefOrigin = 'https://epicflare.local' + +function normalizeRouterHref(href: string) { + const url = new URL(href, routerHrefOrigin) + return `${url.pathname}${url.search}${url.hash}` +} + +function hrefMatchesSsrUrl(currentHref: string, ssrUrl: string) { + return normalizeRouterHref(currentHref) === normalizeRouterHref(ssrUrl) +} + +export function AppLoaderDataProvider( + handle: Handle< + { children?: RemixNode; loaderData?: AppLoaderData }, + AppLoaderDataContextValue + >, +) { + const consumedKeys = new Set() + handle.context.set({ + loaderData: handle.props.loaderData, + consumedKeys, + }) + + return () => handle.props.children +} + +export function tryConsumeEmbeddedLoaderData( + handle: Handle, + key: K, + currentHref: string, +): AppLoaderData[K] | undefined { + const ctx = handle.context.get(AppLoaderDataProvider) + const embedded = ctx.loaderData?.[key] + if (!embedded) return undefined + + let ssrUrl: string + try { + ssrUrl = readSsrRouterUrl(handle) + } catch { + return undefined + } + + if (!hrefMatchesSsrUrl(currentHref, ssrUrl)) return undefined + if (ctx.consumedKeys.has(key)) return undefined + + ctx.consumedKeys.add(key) + return embedded +} + +/** + * Route-loader consumption happens mid-render and callers apply the payload by + * mutating route closure state. A follow-up render re-derives any values that + * were computed before consumption, while consume-once semantics prevent loops. + */ +function scheduleCorrectiveRender(handle: Handle) { + handle.queueTask(() => { + void handle.update() + }) +} + +export function tryConsumeRouteLoaderData( + handle: Handle, + key: K, + currentHref: string, +): AppLoaderData[K] | undefined { + const embedded = tryConsumeEmbeddedLoaderData(handle, key, currentHref) + const data = + embedded !== undefined + ? embedded + : tryConsumePreloadedLoaderData(key, currentHref) + if (data !== undefined) { + scheduleCorrectiveRender(handle) + } + return data +} diff --git a/client/navigation-data.ts b/client/navigation-data.ts new file mode 100644 index 0000000..49a6554 --- /dev/null +++ b/client/navigation-data.ts @@ -0,0 +1,54 @@ +import { type AppLoaderData } from '#shared/loader-data.ts' + +const routerHrefOrigin = 'https://epicflare.local' + +function normalizeNavigationHref(href: string) { + const url = new URL(href, routerHrefOrigin) + return `${url.pathname}${url.search}${url.hash}` +} + +let preloadedSlot: { href: string; data: Partial } | null = null +let staleNavigationHref: string | null = null + +export function setPreloadedNavigationData( + href: string, + data: Partial, +) { + preloadedSlot = { + href: normalizeNavigationHref(href), + data: { ...data }, + } +} + +export function tryConsumePreloadedLoaderData( + key: K, + href: string, +): AppLoaderData[K] | undefined { + if (!preloadedSlot) return undefined + if (normalizeNavigationHref(href) !== preloadedSlot.href) return undefined + + const value = preloadedSlot.data[key] + if (value === undefined) return undefined + + delete preloadedSlot.data[key] + if (Object.keys(preloadedSlot.data).length === 0) { + preloadedSlot = null + } + return value +} + +export function markNavigationDataStale(href: string) { + staleNavigationHref = normalizeNavigationHref(href) +} + +export function consumeStaleNavigationData(href: string) { + if (staleNavigationHref === null) return false + const isStale = staleNavigationHref === normalizeNavigationHref(href) + staleNavigationHref = null + return isStale +} + +export function clearPreloadedNavigationData() { + preloadedSlot = null + staleNavigationHref = null +} diff --git a/client/route-loader.ts b/client/route-loader.ts new file mode 100644 index 0000000..6b92f72 --- /dev/null +++ b/client/route-loader.ts @@ -0,0 +1,26 @@ +import { type AppLoaderData } from '#shared/loader-data.ts' + +export class RouteLoaderRedirect { + readonly to: string + + constructor(to: string) { + this.to = to + } +} + +export function routeLoaderRedirect(to: string) { + return new RouteLoaderRedirect(to) +} + +export function isRouteLoaderRedirect( + value: unknown, +): value is RouteLoaderRedirect { + return value instanceof RouteLoaderRedirect +} + +export type RouteLoaderResult = Partial | RouteLoaderRedirect + +export type RouteLoader = ( + url: URL, + signal: AbortSignal, +) => Promise diff --git a/client/router-location.tsx b/client/router-location.tsx new file mode 100644 index 0000000..756c9da --- /dev/null +++ b/client/router-location.tsx @@ -0,0 +1,57 @@ +import { addEventListeners, type Handle, type RemixNode } from 'remix/ui' +import { routerEvents } from './client-router.tsx' + +export type RouterLocationValue = { + url: string + ssrUrl: string +} + +function getClientUrl() { + if (typeof window === 'undefined') return '/' + return `${window.location.pathname}${window.location.search}${window.location.hash}` +} + +export function RouterLocationProvider( + handle: Handle<{ url: string; children?: RemixNode }, RouterLocationValue>, +) { + const ssrUrl = handle.props.url + let currentUrl = ssrUrl + + if (typeof window !== 'undefined') { + handle.queueTask(() => { + currentUrl = getClientUrl() + handle.context.set({ url: currentUrl, ssrUrl }) + handle.update() + }) + + addEventListeners(routerEvents, handle.signal, { + navigate() { + const nextUrl = getClientUrl() + if (nextUrl === currentUrl) return + currentUrl = nextUrl + handle.context.set({ url: currentUrl, ssrUrl }) + handle.update() + }, + }) + } + + handle.context.set({ url: currentUrl, ssrUrl }) + + return () => handle.props.children +} + +export function readRouterUrl(handle: Pick) { + return handle.context.get(RouterLocationProvider).url +} + +export function readSsrRouterUrl(handle: Pick) { + return handle.context.get(RouterLocationProvider).ssrUrl +} + +export function readRouterPathname(handle: Pick) { + return new URL(readRouterUrl(handle), 'https://epicflare.local').pathname +} + +export function readRouterSearch(handle: Pick) { + return new URL(readRouterUrl(handle), 'https://epicflare.local').search +} diff --git a/client/routes/account.tsx b/client/routes/account.tsx index e49ae70..64d0bf1 100644 --- a/client/routes/account.tsx +++ b/client/routes/account.tsx @@ -1,10 +1,81 @@ import { css, type Handle } from 'remix/ui' +import { buildAuthLink } from '#client/auth-links.ts' +import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx' +import { consumeStaleNavigationData } from '#client/navigation-data.ts' +import { readRouterUrl } from '#client/router-location.tsx' +import { routeLoaderRedirect } from '#client/route-loader.ts' +import { readSession } from '#client/session-context.tsx' +import { fetchSessionInfo } from '#client/session.ts' import { colors, spacing, typography } from '#client/styles/tokens.ts' +import { routes } from '#server/routes.ts' +import { type AccountLoaderData } from '#shared/loader-data.ts' type AccountStatus = 'idle' | 'loading' | 'ready' | 'error' +type SessionPayload = { + ok?: boolean + session?: { + email?: unknown + } +} +function getCurrentHref(handle: Pick) { + try { + return readRouterUrl(handle) + } catch { + return typeof window === 'undefined' + ? routes.account.href() + : `${window.location.pathname}${window.location.search}${window.location.hash}` + } +} +function setLoginRedirect(url: URL) { + return routeLoaderRedirect( + buildAuthLink(routes.login.href(), `${url.pathname}${url.search}`), + ) +} +export async function loadAccountRouteData(url: URL, signal: AbortSignal) { + const session = await fetchSessionInfo(signal) + if (!session) return setLoginRedirect(url) + const account: AccountLoaderData = { + ok: true, + email: session.email, + } + return { + account, + } +} export function AccountRoute(handle: Handle) { - let status: AccountStatus = 'loading' - let email = '' + function readEmbeddedEmail() { + return readSession(handle)?.email.trim() ?? '' + } + const initialEmail = readEmbeddedEmail() + let status: AccountStatus = initialEmail ? 'ready' : 'loading' + let email = initialEmail let message: string | null = null + let loadInFlight = false + let lastLoaderHref: string | null = null + function syncRouteLoaderData() { + const href = getCurrentHref(handle) + const isStale = consumeStaleNavigationData(href) + if (!isStale && href === lastLoaderHref) return + lastLoaderHref = href + const data = tryConsumeRouteLoaderData(handle, 'account', href) + if (!data?.ok) return + email = data.email + status = 'ready' + message = null + } + function syncEmbeddedSession() { + const nextEmail = readEmbeddedEmail() + if (nextEmail) { + email = nextEmail + status = 'ready' + message = null + return + } + if (status === 'ready') { + email = '' + status = 'loading' + message = null + } + } async function loadAccount(signal: AbortSignal) { try { const response = await fetch('/session', { @@ -13,7 +84,9 @@ export function AccountRoute(handle: Handle) { signal, }) if (signal.aborted) return - const payload = await response.json().catch(() => null) + const payload = (await response + .json() + .catch(() => null)) as SessionPayload | null const sessionEmail = response.ok && payload?.ok && @@ -21,7 +94,7 @@ export function AccountRoute(handle: Handle) { ? payload.session.email.trim() : '' if (!sessionEmail) { - window.location.assign('/login') + window.location.assign(routes.login.href()) return } email = sessionEmail @@ -33,10 +106,19 @@ export function AccountRoute(handle: Handle) { status = 'error' message = 'Unable to load your account.' handle.update() + } finally { + loadInFlight = false } } return () => { - if (status === 'loading') { + syncRouteLoaderData() + syncEmbeddedSession() + if ( + typeof window !== 'undefined' && + status === 'loading' && + !loadInFlight + ) { + loadInFlight = true handle.queueTask(loadAccount) } return ( diff --git a/client/routes/chat.tsx b/client/routes/chat.tsx index ea5ebcd..20574d5 100644 --- a/client/routes/chat.tsx +++ b/client/routes/chat.tsx @@ -1,6 +1,7 @@ import { addEventListeners, css, on, type Handle } from 'remix/ui' +import { buildAuthLink } from '#client/auth-links.ts' import { ChatClient, type ChatClientSnapshot } from '#client/chat-client.ts' -import { navigate, routerEvents } from '#client/client-router.tsx' +import { getPathname, navigate, routerEvents } from '#client/client-router.tsx' import { EditableText } from '#client/editable-text.tsx' import { createInfiniteList, @@ -13,7 +14,12 @@ import { restoreScrollAnchorAfterPrepend, scrollToEdge, } from '#client/scroll-container.ts' +import { tryConsumeRouteLoaderData } from '#client/loader-data-context.tsx' +import { consumeStaleNavigationData } from '#client/navigation-data.ts' +import { readRouterUrl } from '#client/router-location.tsx' +import { routeLoaderRedirect } from '#client/route-loader.ts' import { createSpinDelay } from '#client/spin-delay.ts' +import { routes } from '#server/routes.ts' import { breakpoints, colors, @@ -30,16 +36,22 @@ import { type ChatThreadSummary, type ChatThreadUpdateResponse, } from '#shared/chat.ts' +import { type ChatLoaderData } from '#shared/loader-data.ts' type ThreadStatus = 'idle' | 'loading' | 'ready' | 'error' -function getSelectedThreadIdFromLocation() { - if (typeof window === 'undefined') return null - const prefix = '/chat/' - if (!window.location.pathname.startsWith(prefix)) return null - const threadId = window.location.pathname.slice(prefix.length).trim() - return threadId || null +function getSelectedThreadIdFromLocation(handle: Pick) { + return getSelectedThreadIdFromPathname(getPathname(handle)) } function buildThreadHref(threadId: string) { - return `/chat/${threadId}` + return routes.chatThread.href({ threadId }) +} +function getCurrentHref(handle: Pick) { + try { + return readRouterUrl(handle) + } catch { + return typeof window === 'undefined' + ? routes.chat.href() + : `${window.location.pathname}${window.location.search}${window.location.hash}` + } } function isMobileViewport() { return ( @@ -165,6 +177,75 @@ async function fetchThreadById(threadId: string, signal?: AbortSignal) { } return payload.thread } +async function loadChatThreadsData(url: URL, signal?: AbortSignal) { + const threadsUrl = new URL(routes.chatThreads.href(), url.origin) + threadsUrl.searchParams.set('limit', String(THREADS_PAGE_LIMIT)) + const response = await fetch(threadsUrl.toString(), { + credentials: 'include', + headers: { Accept: 'application/json' }, + signal, + }) + if (response.status === 401) { + return routeLoaderRedirect( + buildAuthLink(routes.login.href(), `${url.pathname}${url.search}`), + ) + } + const payload = (await response.json().catch(() => null)) as + | (ChatThreadListResponse & { error?: string }) + | null + if ( + !response.ok || + !payload?.ok || + !Array.isArray(payload.threads) || + typeof payload.totalCount !== 'number' || + typeof payload.hasMore !== 'boolean' + ) { + throw new Error(payload?.error || 'Unable to load threads.') + } + const selectedThreadId = getSelectedThreadIdFromPathname(url.pathname) + let selectedThread = payload.threads.find( + (thread) => thread.id === selectedThreadId, + ) + if (selectedThreadId && !selectedThread) { + const threadUrl = new URL(routes.chatThreads.href(), url.origin) + threadUrl.searchParams.set('threadId', selectedThreadId) + const threadResponse = await fetch(threadUrl.toString(), { + credentials: 'include', + headers: { Accept: 'application/json' }, + signal, + }) + const threadPayload = (await threadResponse.json().catch(() => null)) as + | (ChatThreadLookupResponse & { error?: string }) + | null + if (threadResponse.ok && threadPayload?.ok && threadPayload.thread) { + selectedThread = threadPayload.thread + } + } + const threads = + selectedThread && + !payload.threads.some((thread) => thread.id === selectedThread.id) + ? [selectedThread, ...payload.threads] + : payload.threads + const data: ChatLoaderData = { + ok: true, + threads, + hasMore: payload.hasMore, + nextCursor: payload.nextCursor, + totalCount: payload.totalCount, + selectedThread: selectedThread ?? null, + search: '', + } + return { chat: data } +} +function getSelectedThreadIdFromPathname(pathname: string) { + const prefix = `${routes.chat.href()}/` + if (!pathname.startsWith(prefix)) return null + const threadId = pathname.slice(prefix.length).trim() + return threadId || null +} +export async function loadChatRouteData(url: URL, signal: AbortSignal) { + return loadChatThreadsData(url, signal) +} async function createThread() { const response = await fetch('/chat-threads', { method: 'POST', @@ -373,10 +454,12 @@ export function ChatRoute(handle: Handle) { let threadListCursor: string | null = null let activeThreadId: string | null = null let threadSearch = '' + let lastLoaderHref: string | null = null let chatSnapshot = createInitialSnapshot() let activeClient: ChatClient | null = null let actionError: string | null = null let syncInFlight = false + let suppressThreadListSnapshotUpdate = false let shouldAutoScrollMessages = true let showMessageScrollFadeTop = false let showMessageScrollFadeBottom = false @@ -407,7 +490,9 @@ export function ChatRoute(handle: Handle) { } else { threadStatus = 'ready' } - update() + if (!suppressThreadListSnapshotUpdate) { + update() + } }, }) function update() { @@ -421,6 +506,45 @@ export function ChatRoute(handle: Handle) { threadError = nextError update() } + function applyChatLoaderData(data: ChatLoaderData) { + threadSearch = data.search + threadListCursor = data.nextCursor + suppressThreadListSnapshotUpdate = true + try { + threadList.replaceWindow({ + items: data.threads, + hasMore: data.hasMore, + totalCount: data.totalCount, + }) + } finally { + suppressThreadListSnapshotUpdate = false + } + threadStatus = 'ready' + threadError = null + const locationThreadId = getSelectedThreadIdFromLocation(handle) + const selectedThreadId = + locationThreadId && + data.threads.some((thread) => thread.id === locationThreadId) + ? locationThreadId + : (data.threads[0]?.id ?? null) + if (activeThreadId !== selectedThreadId) { + activeThreadId = selectedThreadId + resetChatSnapshot() + } + scheduleThreadListScrollFadeSync() + if (typeof window !== 'undefined') { + void handle.queueTask(async () => { + await syncActiveThreadFromLocation() + }) + } + } + function applyRouteLoaderData(href: string) { + const data = tryConsumeRouteLoaderData(handle, 'chat', href) + if (!data?.ok) return false + applyChatLoaderData(data) + lastLoaderHref = href + return true + } function resetChatSnapshot() { chatSnapshot = createInitialSnapshot() } @@ -644,7 +768,7 @@ export function ChatRoute(handle: Handle) { if (threadStatus !== 'ready' || syncInFlight) return syncInFlight = true try { - const locationThreadId = getSelectedThreadIdFromLocation() + const locationThreadId = getSelectedThreadIdFromLocation(handle) const threads = getThreads() if (threads.length === 0) { activeClient?.close() @@ -655,7 +779,7 @@ export function ChatRoute(handle: Handle) { setMessageScrollFades(false, false) update() if (locationThreadId) { - navigate('/chat') + navigate(routes.chat.href()) } return } @@ -746,13 +870,15 @@ export function ChatRoute(handle: Handle) { ) } } - addEventListeners(routerEvents, handle.signal, { - navigate: () => { - void handle.queueTask(async () => { - await syncActiveThreadFromLocation() - }) - }, - }) + if (typeof window !== 'undefined') { + addEventListeners(routerEvents, handle.signal, { + navigate: () => { + void handle.queueTask(async () => { + await syncActiveThreadFromLocation() + }) + }, + }) + } async function createAndSelectThread() { const thread = await createThread() navigate(buildThreadHref(thread.id)) @@ -792,7 +918,7 @@ export function ChatRoute(handle: Handle) { navigate(buildThreadHref(nextThread.id)) await connectThread(nextThread.id) } else { - navigate('/chat') + navigate(routes.chat.href()) } } catch (error) { actionError = @@ -865,7 +991,18 @@ export function ChatRoute(handle: Handle) { } } return () => { - if (threadStatus === 'loading') { + const currentHref = getCurrentHref(handle) + const appliedRouteData = applyRouteLoaderData(currentHref) + const needsStaleRefresh = + consumeStaleNavigationData(currentHref) && !appliedRouteData + if ( + !appliedRouteData && + (threadStatus === 'loading' || + needsStaleRefresh || + currentHref !== lastLoaderHref) && + typeof window !== 'undefined' + ) { + lastLoaderHref = currentHref handle.queueTask(refreshThreads) } const threads = getThreads() @@ -874,7 +1011,7 @@ export function ChatRoute(handle: Handle) { : null const showEmptyStateComposer = !activeThread && threads.length === 0 && threadStatus !== 'error' - const hasThreadInUrl = Boolean(getSelectedThreadIdFromLocation()) + const hasThreadInUrl = Boolean(getSelectedThreadIdFromLocation(handle)) return (
Remix 3

- Remix 3 components running on the client, backed by Remix 3 - routing in the worker. + Remix 3 components server-rendered by the worker, then hydrated + for client navigation.

diff --git a/client/routes/index.tsx b/client/routes/index.tsx index f0c7aa8..b59488f 100644 --- a/client/routes/index.tsx +++ b/client/routes/index.tsx @@ -1,19 +1,30 @@ -import { AccountRoute } from './account.tsx' -import { ChatRoute } from './chat.tsx' +import { routes } from '#server/routes.ts' +import { AccountRoute, loadAccountRouteData } from './account.tsx' +import { ChatRoute, loadChatRouteData } from './chat.tsx' import { HomeRoute } from './home.tsx' import { LoginRoute } from './login.tsx' -import { OAuthAuthorizeRoute } from './oauth-authorize.tsx' +import { + loadOAuthAuthorizeRouteData, + OAuthAuthorizeRoute, +} from './oauth-authorize.tsx' import { OAuthCallbackRoute } from './oauth-callback.tsx' import { ResetPasswordRoute } from './reset-password.tsx' export const clientRoutes = { - '/': , - '/chat': , + [routes.home.href()]: , + [routes.chat.href()]: , '/chat/:threadId': , - '/account': , - '/login': , - '/signup': , - '/reset-password': , - '/oauth/authorize': , - '/oauth/callback': , + [routes.account.href()]: , + [routes.login.href()]: , + [routes.signup.href()]: , + [routes.resetPassword.href()]: , + [routes.oauthAuthorize.href()]: , + [routes.oauthCallback.href()]: , +} + +export const clientRouteLoaders = { + [routes.chat.href()]: loadChatRouteData, + '/chat/:threadId': loadChatRouteData, + [routes.account.href()]: loadAccountRouteData, + [routes.oauthAuthorize.href()]: loadOAuthAuthorizeRouteData, } diff --git a/client/routes/login.tsx b/client/routes/login.tsx index ed312b9..5fd7395 100644 --- a/client/routes/login.tsx +++ b/client/routes/login.tsx @@ -4,6 +4,8 @@ import { getPathname, listenToRouterNavigation, } from '#client/client-router.tsx' +import { readRouterSearch } from '#client/router-location.tsx' +import { routes } from '#server/routes.ts' import { fetchSessionInfo, type SessionStatus } from '#client/session.ts' import { colors, @@ -15,10 +17,19 @@ import { } from '#client/styles/tokens.ts' type AuthMode = 'login' | 'signup' type AuthStatus = 'idle' | 'submitting' | 'success' | 'error' -function getSearchParams() { - return typeof window === 'undefined' - ? new URLSearchParams() - : new URLSearchParams(window.location.search) +type AuthPayload = { + error?: unknown +} +function getSearchParams(handle: Handle) { + if (typeof window !== 'undefined') { + return new URLSearchParams(window.location.search) + } + + try { + return new URLSearchParams(readRouterSearch(handle)) + } catch { + return new URLSearchParams() + } } function normalizeRedirectTo(value: string | null) { if (!value) return null @@ -27,24 +38,24 @@ function normalizeRedirectTo(value: string | null) { return value } function buildAuthPath(mode: AuthMode, redirectTo: string | null) { - const path = mode === 'signup' ? '/signup' : '/login' + const path = mode === 'signup' ? routes.signup.href() : routes.login.href() return buildAuthLink(path, redirectTo) } function getAuthModeFromPathname(pathname: string): AuthMode { - return pathname === '/signup' ? 'signup' : 'login' + return pathname === routes.signup.href() ? 'signup' : 'login' } -function getCurrentAuthMode() { - return getAuthModeFromPathname(getPathname()) +function getCurrentAuthMode(handle: Handle) { + return getAuthModeFromPathname(getPathname(handle)) } -function getCurrentRedirectTo() { - return normalizeRedirectTo(getSearchParams().get('redirectTo')) +function getCurrentRedirectTo(handle: Handle) { + return normalizeRedirectTo(getSearchParams(handle).get('redirectTo')) } export function LoginRoute(handle: Handle) { let status: AuthStatus = 'idle' let message: string | null = null let sessionStatus: SessionStatus = 'idle' let sessionEmail = '' - let activeMode = getCurrentAuthMode() + let activeMode = getCurrentAuthMode(handle) let routePath: string | null = null function setState(nextStatus: AuthStatus, nextMessage: string | null = null) { status = nextStatus @@ -57,30 +68,34 @@ export function LoginRoute(handle: Handle) { } listenToRouterNavigation(handle, () => { if (!routePath) return - if (getPathname() !== routePath) { + if (getPathname(handle) !== routePath) { resetAuthState() } }) - handle.queueTask(async (signal) => { - if (sessionStatus !== 'idle') return - sessionStatus = 'loading' - const session = await fetchSessionInfo(signal) - if (signal.aborted) return - sessionEmail = session?.email ?? '' - sessionStatus = 'ready' - if (sessionEmail && typeof window !== 'undefined') { - window.location.assign(getCurrentRedirectTo() ?? '/account') - return - } - handle.update() - }) + if (typeof window !== 'undefined') { + handle.queueTask(async (signal) => { + if (sessionStatus !== 'idle') return + sessionStatus = 'loading' + const session = await fetchSessionInfo(signal) + if (signal.aborted) return + sessionEmail = session?.email ?? '' + sessionStatus = 'ready' + if (sessionEmail) { + window.location.assign( + getCurrentRedirectTo(handle) ?? routes.account.href(), + ) + return + } + handle.update() + }) + } async function handleSubmit(event: SubmitEvent) { event.preventDefault() if (!(event.currentTarget instanceof HTMLFormElement)) return const formData = new FormData(event.currentTarget) const email = String(formData.get('email') ?? '').trim() const password = String(formData.get('password') ?? '') - const mode = getCurrentAuthMode() + const mode = getCurrentAuthMode(handle) const rememberMe = mode === 'login' && formData.get('rememberMe') === 'on' if (!email || !password) { setState('error', 'Email and password are required.') @@ -94,7 +109,9 @@ export function LoginRoute(handle: Handle) { credentials: 'include', body: JSON.stringify({ email, password, mode, rememberMe }), }) - const payload = await response.json().catch(() => null) + const payload = (await response + .json() + .catch(() => null)) as AuthPayload | null if (!response.ok) { const errorMessage = typeof payload?.error === 'string' @@ -104,22 +121,24 @@ export function LoginRoute(handle: Handle) { return } if (typeof window !== 'undefined') { - window.location.assign(getCurrentRedirectTo() ?? '/account') + window.location.assign( + getCurrentRedirectTo(handle) ?? routes.account.href(), + ) } } catch { setState('error', 'Network error. Please try again.') } } return () => { - const mode = getCurrentAuthMode() + const mode = getCurrentAuthMode(handle) if (!routePath) { - routePath = getPathname() + routePath = getPathname(handle) } if (mode !== activeMode) { activeMode = mode resetAuthState() } - const redirectTo = getCurrentRedirectTo() + const redirectTo = getCurrentRedirectTo(handle) const isSignup = mode === 'signup' const isSubmitting = status === 'submitting' const title = isSignup ? 'Create your account' : 'Welcome back' @@ -344,7 +363,7 @@ export function LoginRoute(handle: Handle) {
{!isSignup ? ( ) : null} +} +type OAuthAuthorizeDecisionPayload = { + error?: unknown + redirectTo?: unknown +} +function getSearch(handle: Handle) { + if (typeof window !== 'undefined') return window.location.search + + try { + return readRouterSearch(handle) + } catch { + return '' + } +} + +function getSearchParams(handle: Handle) { + return new URLSearchParams(getSearch(handle)) +} +function readQueryErrorFromParams(params: URLSearchParams) { + const description = params.get('error_description') + if (description) return description + const error = params.get('error') + return error ? `Authorization error: ${error}` : null +} +function getCurrentHref(handle: Pick) { + try { + return readRouterUrl(handle) + } catch { + return typeof window === 'undefined' + ? routes.oauthAuthorize.href() + : `${window.location.pathname}${window.location.search}${window.location.hash}` + } +} +async function fetchOAuthAuthorizeData( + url: URL, + signal?: AbortSignal, +): Promise { + const queryError = readQueryErrorFromParams(url.searchParams) + if (queryError) return { ok: false, error: queryError } + const response = await fetch(`${routes.oauthAuthorize.href()}-info${url.search}`, { + headers: { Accept: 'application/json' }, + credentials: 'include', + signal, + }) + const payload = (await response + .json() + .catch(() => null)) as OAuthAuthorizeInfoPayload | null + if ( + !response.ok || + !payload?.ok || + !payload.client || + !Array.isArray(payload.scopes) + ) { + return { + ok: false, + error: + typeof payload?.error === 'string' + ? payload.error + : 'Unable to load authorization details.', + } + } + return { + ok: true, + client: payload.client, + scopes: payload.scopes, + } +} +export async function loadOAuthAuthorizeRouteData( + url: URL, + signal: AbortSignal, +) { + return { + oauthAuthorize: await fetchOAuthAuthorizeData(url, signal), + } } export function OAuthAuthorizeRoute(handle: Handle) { let info: OAuthAuthorizeInfo | null = null let status: OAuthAuthorizeStatus = 'idle' - let message: OAuthAuthorizeMessage | null = null + const initialQueryError = readQueryError() + let message: OAuthAuthorizeMessage | null = initialQueryError + ? { type: 'error', text: initialQueryError } + : null let submitting = false let lastSearch = '' - let session: SessionInfo | null = null - let sessionStatus: SessionStatus = 'idle' + let lastLoaderHref: string | null = null + let session: SessionInfo | null = readSession(handle) + let infoLoadQueued = false function setMessage(next: OAuthAuthorizeMessage | null) { message = next handle.update() } function readQueryError() { - const params = getSearchParams() - const description = params.get('error_description') - if (description) return description - const error = params.get('error') - return error ? `Authorization error: ${error}` : null + return readQueryErrorFromParams(getSearchParams(handle)) } - async function loadInfo() { - status = 'loading' - const queryError = readQueryError() - if (queryError) { - message = { type: 'error', text: queryError } - } - try { - const query = typeof window === 'undefined' ? '' : window.location.search - const response = await fetch(`/oauth/authorize-info${query}`, { - headers: { Accept: 'application/json' }, - credentials: 'include', - }) - const payload = await response.json().catch(() => null) - if (!response.ok || !payload?.ok) { - const errorText = - typeof payload?.error === 'string' - ? payload.error - : 'Unable to load authorization details.' - info = null - status = 'error' - message = { type: 'error', text: errorText } - handle.update() - return - } + function syncEmbeddedSession() { + session = readSession(handle) + } + function applyLoaderData(data: OAuthAuthorizeLoaderData) { + if (data.ok) { info = { - client: payload.client, - scopes: payload.scopes, + client: data.client, + scopes: data.scopes, } status = 'ready' - if (!queryError) { + if (!readQueryError()) { message = null } + return + } + info = null + status = 'error' + message = { type: 'error', text: data.error } + } + function syncRouteLoaderData() { + const href = getCurrentHref(handle) + const data = tryConsumeRouteLoaderData(handle, 'oauthAuthorize', href) + if (!data) { + if (href !== lastLoaderHref) { + lastLoaderHref = href + } + return false + } + applyLoaderData(data) + lastSearch = getSearch(handle) + lastLoaderHref = href + return true + } + async function loadInfo() { + status = 'loading' + const url = new URL(getSearch(handle) || routes.oauthAuthorize.href(), window.location.href) + try { + applyLoaderData(await fetchOAuthAuthorizeData(url)) handle.update() } catch { + const queryError = readQueryError() info = null status = 'error' message = { type: 'error', - text: 'Unable to load authorization details.', + text: queryError ?? 'Unable to load authorization details.', } handle.update() } } - async function loadSession() { - if (sessionStatus !== 'idle') return - sessionStatus = 'loading' - session = await fetchSessionInfo() - sessionStatus = 'ready' - handle.update() - } async function submitDecision( decision: 'approve' | 'deny', form?: HTMLFormElement, @@ -132,7 +209,9 @@ export function OAuthAuthorizeRoute(handle: Handle) { credentials: 'include', body, }) - const payload = await response.json().catch(() => null) + const payload = (await response + .json() + .catch(() => null)) as OAuthAuthorizeDecisionPayload | null if (!response.ok) { const errorText = typeof payload?.error === 'string' @@ -143,7 +222,7 @@ export function OAuthAuthorizeRoute(handle: Handle) { handle.update() return } - if (payload?.redirectTo) { + if (typeof payload?.redirectTo === 'string') { window.location.assign(payload.redirectTo) return } @@ -168,26 +247,35 @@ export function OAuthAuthorizeRoute(handle: Handle) { ) } return () => { - const currentSearch = - typeof window === 'undefined' ? '' : window.location.search - if (currentSearch !== lastSearch) { + const href = getCurrentHref(handle) + const appliedRouteData = syncRouteLoaderData() + const needsStaleRefresh = + consumeStaleNavigationData(href) && !appliedRouteData + syncEmbeddedSession() + const currentSearch = getSearch(handle) + if ( + typeof window !== 'undefined' && + (needsStaleRefresh || currentSearch !== lastSearch) && + !infoLoadQueued + ) { lastSearch = currentSearch - void loadInfo() - } - if (sessionStatus === 'idle') { - void loadSession() + infoLoadQueued = true + handle.queueTask(async () => { + try { + await loadInfo() + } finally { + infoLoadQueued = false + } + }) } const clientLabel = info?.client?.name ?? 'Unknown client' const scopes = info?.scopes ?? [] const scopeLabel = scopes.length > 0 ? scopes.join(', ') : 'No scopes requested.' const sessionEmail = session?.email ?? '' - const isSessionReady = sessionStatus === 'ready' - const isSessionLoading = - sessionStatus === 'loading' || sessionStatus === 'idle' - const isLoggedIn = isSessionReady && Boolean(sessionEmail) - const actionsDisabled = status !== 'ready' || submitting || isSessionLoading - const formReady = status === 'ready' && !isSessionLoading + const isLoggedIn = Boolean(sessionEmail) + const actionsDisabled = status !== 'ready' || submitting + const formReady = status === 'ready' const authorizeLabel = submitting ? 'Submitting...' : isLoggedIn @@ -248,9 +336,6 @@ export function OAuthAuthorizeRoute(handle: Handle) { {scopeLabel}

- {isSessionLoading ? ( -

Checking your session…

- ) : null} {isLoggedIn ? (
('submit', handleSubmit), ]} > - {!isLoggedIn && isSessionReady ? ( + {!isLoggedIn ? ( <> { - const params = - typeof window === 'undefined' - ? new URLSearchParams() - : new URLSearchParams(window.location.search) + const params = getSearchParams(handle) const error = params.get('error') const description = params.get('error_description') const code = params.get('code') @@ -61,7 +73,7 @@ export function OAuthCallbackRoute(_handle: Handle) {

) : null}
null) + const payload = (await response + .json() + .catch(() => null)) as ResetPayload | null if (!response.ok) { const errorMessage = typeof payload?.error === 'string' @@ -50,7 +64,9 @@ export function ResetPasswordRoute(handle: Handle) { } setState( 'success', - payload?.message ?? 'Check your inbox for a reset link.', + typeof payload?.message === 'string' + ? payload.message + : 'Check your inbox for a reset link.', ) } catch { setState('error', 'Network error. Please try again.') @@ -72,7 +88,9 @@ export function ResetPasswordRoute(handle: Handle) { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token, password }), }) - const payload = await response.json().catch(() => null) + const payload = (await response + .json() + .catch(() => null)) as ResetPayload | null if (!response.ok) { const errorMessage = typeof payload?.error === 'string' @@ -87,7 +105,7 @@ export function ResetPasswordRoute(handle: Handle) { } } return () => { - const searchParams = getSearchParams() + const searchParams = getSearchParams(handle) const token = String(searchParams.get('token') ?? '').trim() const mode = token ? 'confirm' : 'request' const isSubmitting = status === 'submitting' @@ -242,7 +260,7 @@ export function ResetPasswordRoute(handle: Handle) {
, +) { + return () => { + handle.context.set({ session: handle.props.session }) + return handle.props.children + } +} + +export function readSession(handle: Pick) { + return handle.context.get(SessionProvider).session +} diff --git a/client/session.ts b/client/session.ts index db7666c..d4533e8 100644 --- a/client/session.ts +++ b/client/session.ts @@ -4,6 +4,13 @@ export type SessionInfo = { export type SessionStatus = 'idle' | 'loading' | 'ready' +type SessionPayload = { + ok?: boolean + session?: { + email?: unknown + } +} + export async function fetchSessionInfo( signal?: AbortSignal, ): Promise { @@ -14,7 +21,9 @@ export async function fetchSessionInfo( signal, }) if (signal?.aborted) return null - const payload = await response.json().catch(() => null) + const payload = (await response + .json() + .catch(() => null)) as SessionPayload | null const email = response.ok && payload?.ok && typeof payload?.session?.email === 'string' ? payload.session.email.trim() diff --git a/server/handlers/account.ts b/server/handlers/account.ts index d534576..8384932 100644 --- a/server/handlers/account.ts +++ b/server/handlers/account.ts @@ -1,28 +1,35 @@ import { type Action } from 'remix/router' -import { readAuthSessionResult } from '#server/auth-session.ts' +import { type AppEnv } from '#types/env-schema.ts' +import { + readAuthSessionResult, + setAuthSessionSecret, +} from '#server/auth-session.ts' import { redirectToLogin } from '#server/auth-redirect.ts' -import { Layout } from '#server/layout.ts' -import { render } from '#server/render.ts' import { type routes } from '#server/routes.ts' +import { renderAppPage } from '#server/ssr-render.tsx' -export const account = { - middleware: [], - async handler({ request }) { - const { session, setCookie } = await readAuthSessionResult(request) +export function createAccountHandler(appEnv: AppEnv) { + return { + middleware: [], + async handler({ request }) { + setAuthSessionSecret(appEnv.COOKIE_SECRET) + const { session } = await readAuthSessionResult(request) - if (!session) { - return redirectToLogin(request) - } + if (!session) { + return redirectToLogin(request) + } - const response = render( - Layout({ + return renderAppPage({ + request, + appEnv, title: 'Account', - }), - ) - if (setCookie) { - response.headers.set('Set-Cookie', setCookie) - } - - return response - }, -} satisfies Action + loaderData: { + account: { + ok: true, + email: session.email, + }, + }, + }) + }, + } satisfies Action +} diff --git a/server/handlers/auth-page.ts b/server/handlers/auth-page.ts index 38c2a62..da507a3 100644 --- a/server/handlers/auth-page.ts +++ b/server/handlers/auth-page.ts @@ -1,6 +1,9 @@ -import { readAuthSessionResult } from '#server/auth-session.ts' -import { Layout } from '#server/layout.ts' -import { render } from '#server/render.ts' +import { + readAuthSessionResult, + setAuthSessionSecret, +} from '#server/auth-session.ts' +import { renderAppPage } from '#server/ssr-render.tsx' +import { type AppEnv } from '#types/env-schema.ts' function normalizeRedirectTo(value: string | null) { if (!value) return null @@ -9,10 +12,11 @@ function normalizeRedirectTo(value: string | null) { return value } -export function createAuthPageHandler() { +export function createAuthPageHandler(appEnv: AppEnv) { return { middleware: [], async handler({ request }: { request: Request }) { + setAuthSessionSecret(appEnv.COOKIE_SECRET) const { session, setCookie } = await readAuthSessionResult(request) if (session) { const url = new URL(request.url) @@ -34,7 +38,7 @@ export function createAuthPageHandler() { return Response.redirect(redirectUrl, 302) } - return render(Layout({})) + return renderAppPage({ request, appEnv }) }, } } diff --git a/server/handlers/chat.ts b/server/handlers/chat.ts index a4c41ff..293fb1c 100644 --- a/server/handlers/chat.ts +++ b/server/handlers/chat.ts @@ -1,24 +1,62 @@ import { type Action } from 'remix/router' -import { readAuthSessionResult } from '#server/auth-session.ts' +import { type ChatLoaderData } from '#shared/loader-data.ts' +import { type AppEnv } from '#types/env-schema.ts' import { redirectToLogin } from '#server/auth-redirect.ts' -import { Layout } from '#server/layout.ts' -import { render } from '#server/render.ts' +import { readAuthenticatedAppUser } from '#server/authenticated-user.ts' +import { createChatThreadsStore } from '#server/chat-threads.ts' import { type routes } from '#server/routes.ts' +import { renderAppPage } from '#server/ssr-render.tsx' -export const chat = { - middleware: [], - async handler({ request }) { - const { session, setCookie } = await readAuthSessionResult(request) +const initialThreadLimit = 40 - if (!session) { - return redirectToLogin(request) - } +function getSelectedThreadId(request: Request) { + const url = new URL(request.url) + const prefix = '/chat/' + if (!url.pathname.startsWith(prefix)) return null + const threadId = url.pathname.slice(prefix.length).trim() + return threadId || null +} - const response = render(Layout({})) - if (setCookie) { - response.headers.set('Set-Cookie', setCookie) - } +export function createChatHandler(appEnv: AppEnv) { + const store = createChatThreadsStore(appEnv.APP_DB) - return response - }, -} satisfies Action + return { + middleware: [], + async handler({ request }) { + const user = await readAuthenticatedAppUser(request, appEnv as Env) + + if (!user) { + return redirectToLogin(request) + } + + const page = await store.listForUser(user.userId, { + limit: initialThreadLimit, + }) + const selectedThreadId = getSelectedThreadId(request) + const selectedThread = selectedThreadId + ? (await store.getForUser(user.userId, selectedThreadId)) ?? null + : null + const threads = + selectedThread && + !page.threads.some((thread) => thread.id === selectedThread.id) + ? [selectedThread, ...page.threads] + : page.threads + const loaderData: ChatLoaderData = { + ok: true, + threads, + hasMore: page.hasMore, + nextCursor: page.nextCursor, + totalCount: page.totalCount, + selectedThread, + search: '', + } + + return renderAppPage({ + request, + appEnv, + title: 'Chat', + loaderData: { chat: loaderData }, + }) + }, + } satisfies Action +} diff --git a/server/handlers/home.ts b/server/handlers/home.ts index 968518b..954b04f 100644 --- a/server/handlers/home.ts +++ b/server/handlers/home.ts @@ -1,11 +1,13 @@ import { type Action } from 'remix/router' -import { Layout } from '#server/layout.ts' -import { render } from '#server/render.ts' +import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' +import { renderAppPage } from '#server/ssr-render.tsx' -export const home = { - middleware: [], - async handler() { - return render(Layout({})) - }, -} satisfies Action +export function createHomeHandler(appEnv: AppEnv) { + return { + middleware: [], + async handler({ request }) { + return renderAppPage({ request, appEnv }) + }, + } satisfies Action +} diff --git a/server/handlers/login.ts b/server/handlers/login.ts index f01c5ee..f01333f 100644 --- a/server/handlers/login.ts +++ b/server/handlers/login.ts @@ -1,7 +1,8 @@ import { type Action } from 'remix/router' +import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' import { createAuthPageHandler } from './auth-page.ts' -export const login = createAuthPageHandler() satisfies Action< - typeof routes.login -> +export function createLoginHandler(appEnv: AppEnv) { + return createAuthPageHandler(appEnv) satisfies Action +} diff --git a/server/handlers/password-reset.ts b/server/handlers/password-reset.ts index 5921840..f4c69e7 100644 --- a/server/handlers/password-reset.ts +++ b/server/handlers/password-reset.ts @@ -8,10 +8,24 @@ import { toHex } from '#server/hex.ts' import { normalizeEmail } from '#server/normalize-email.ts' import { createPasswordHash } from '#server/password-hash.ts' import { type routes } from '#server/routes.ts' +import { renderAppPage } from '#server/ssr-render.tsx' const resetTokenBytes = 32 const resetTokenExpiryMs = 60 * 60 * 1000 +export function createResetPasswordPageHandler(appEnv: AppEnv) { + return { + middleware: [], + async handler({ request }) { + return renderAppPage({ + request, + appEnv, + title: 'Reset password', + }) + }, + } satisfies Action +} + const resetRequestSchema = object({ email: string(), }) diff --git a/server/handlers/signup.ts b/server/handlers/signup.ts index 4e971e0..3f361c6 100644 --- a/server/handlers/signup.ts +++ b/server/handlers/signup.ts @@ -1,7 +1,8 @@ import { type Action } from 'remix/router' +import { type AppEnv } from '#types/env-schema.ts' import { type routes } from '#server/routes.ts' import { createAuthPageHandler } from './auth-page.ts' -export const signup = createAuthPageHandler() satisfies Action< - typeof routes.signup -> +export function createSignupHandler(appEnv: AppEnv) { + return createAuthPageHandler(appEnv) satisfies Action +} diff --git a/server/loader-data-context.test.ts b/server/loader-data-context.test.ts new file mode 100644 index 0000000..e03f056 --- /dev/null +++ b/server/loader-data-context.test.ts @@ -0,0 +1,69 @@ +import { type Handle } from 'remix/ui' +import { expect, test } from 'vitest' +import { + AppLoaderDataProvider, + tryConsumeRouteLoaderData, +} from '#client/loader-data-context.tsx' +import { + clearPreloadedNavigationData, + setPreloadedNavigationData, +} from '#client/navigation-data.ts' + +function createStubHandle() { + const queuedTasks: Array<() => unknown> = [] + let updateCount = 0 + const handle = { + context: { + get(provider: unknown) { + if (provider === AppLoaderDataProvider) { + return { + loaderData: undefined, + consumedKeys: new Set(), + } + } + throw new Error('context unavailable') + }, + }, + queueTask(task: () => unknown) { + queuedTasks.push(task) + }, + update() { + updateCount++ + }, + } as unknown as Handle + + return { + handle, + queuedTasks, + getUpdateCount() { + return updateCount + }, + } +} + +test('consuming preloaded route data schedules one corrective render', () => { + clearPreloadedNavigationData() + setPreloadedNavigationData('/account', { + account: { + ok: true, + email: 'loader@example.com', + }, + }) + const { handle, queuedTasks, getUpdateCount } = createStubHandle() + + const consumed = tryConsumeRouteLoaderData(handle, 'account', '/account') + + expect(consumed?.email).toBe('loader@example.com') + expect(queuedTasks).toHaveLength(1) + for (const task of queuedTasks.splice(0)) { + task() + } + expect(getUpdateCount()).toBe(1) + + const reconsumed = tryConsumeRouteLoaderData(handle, 'account', '/account') + + expect(reconsumed).toBeUndefined() + expect(queuedTasks).toHaveLength(0) + expect(getUpdateCount()).toBe(1) + clearPreloadedNavigationData() +}) diff --git a/server/router.ts b/server/router.ts index 52cf054..dfac987 100644 --- a/server/router.ts +++ b/server/router.ts @@ -1,50 +1,57 @@ import { createRouter } from 'remix/router' import { type AppEnv } from '#types/env-schema.ts' -import { account } from './handlers/account.ts' +import { createAccountHandler } from './handlers/account.ts' import { createAuthHandler } from './handlers/auth.ts' -import { chat } from './handlers/chat.ts' +import { createChatHandler } from './handlers/chat.ts' import { createChatThreadsHandler, createDeleteChatThreadHandler, createUpdateChatThreadHandler, } from './handlers/chat-threads.ts' import { createHealthHandler } from './handlers/health.ts' -import { home } from './handlers/home.ts' -import { login } from './handlers/login.ts' +import { createHomeHandler } from './handlers/home.ts' +import { createLoginHandler } from './handlers/login.ts' import { logout } from './handlers/logout.ts' import { createPasswordResetConfirmHandler, + createResetPasswordPageHandler, createPasswordResetRequestHandler, } from './handlers/password-reset.ts' import { session } from './handlers/session.ts' -import { signup } from './handlers/signup.ts' -import { Layout } from './layout.ts' -import { render } from './render.ts' +import { createSignupHandler } from './handlers/signup.ts' +import { renderAppPage } from './ssr-render.tsx' import { routes } from './routes.ts' export function createAppRouter(appEnv: AppEnv) { const router = createRouter({ middleware: [], - async defaultHandler() { - return render(Layout({})) + async defaultHandler({ request }) { + return renderAppPage({ + request, + appEnv, + title: 'Not Found', + notFound: true, + status: 404, + }) }, }) const chatThreadsHandler = createChatThreadsHandler(appEnv) - router.map(routes.home, home) - router.map(routes.chat, chat) - router.map(routes.chatThread, chat) + router.map(routes.home, createHomeHandler(appEnv)) + router.map(routes.chat, createChatHandler(appEnv)) + router.map(routes.chatThread, createChatHandler(appEnv)) router.map(routes.chatThreads, chatThreadsHandler) router.map(routes.chatThreadsCreate, chatThreadsHandler) router.map(routes.chatThreadsUpdate, createUpdateChatThreadHandler(appEnv)) router.map(routes.chatThreadsDelete, createDeleteChatThreadHandler(appEnv)) router.map(routes.health, createHealthHandler(appEnv)) - router.map(routes.login, login) - router.map(routes.signup, signup) - router.map(routes.account, account) + router.map(routes.login, createLoginHandler(appEnv)) + router.map(routes.signup, createSignupHandler(appEnv)) + router.map(routes.account, createAccountHandler(appEnv)) router.map(routes.auth, createAuthHandler(appEnv)) router.map(routes.session, session) router.map(routes.logout, logout) + router.map(routes.resetPassword, createResetPasswordPageHandler(appEnv)) router.map( routes.passwordResetRequest, createPasswordResetRequestHandler(appEnv), diff --git a/server/routes.ts b/server/routes.ts index 26646d8..2f10947 100644 --- a/server/routes.ts +++ b/server/routes.ts @@ -12,6 +12,9 @@ export const routes = route({ login: '/login', signup: '/signup', account: '/account', + resetPassword: '/reset-password', + oauthAuthorize: '/oauth/authorize', + oauthCallback: '/oauth/callback', auth: post('/auth'), session: '/session', logout: post('/logout'), diff --git a/server/ssr-document.tsx b/server/ssr-document.tsx new file mode 100644 index 0000000..f6d8913 --- /dev/null +++ b/server/ssr-document.tsx @@ -0,0 +1,59 @@ +/** @jsxImportSource remix/ui */ +/** @jsxRuntime automatic */ +import { type Handle } from 'remix/ui' +import { + APP_ROOT_ENTRY_ID, + AppRoot, + type AppRootProps, +} from '#client/app-root.tsx' + +export type SsrDocumentProps = AppRootProps & { + title?: string +} + +const clientEntryHref = APP_ROOT_ENTRY_ID.split('#')[0] ?? '/client-entry.js' + +export function SsrDocument(handle: Handle) { + return () => ( + + + + + + + + + + + {handle.props.title ?? 'epicflare'} + + + + +
+ +
+ + + + ) +} diff --git a/server/ssr-render.test.ts b/server/ssr-render.test.ts new file mode 100644 index 0000000..164e9c3 --- /dev/null +++ b/server/ssr-render.test.ts @@ -0,0 +1,160 @@ +/// +import { expect, test } from 'vitest' +import { type AppEnv } from '#types/env-schema.ts' +import { createAuthCookie, setAuthSessionSecret } from './auth-session.ts' +import { renderAppPage } from './ssr-render.tsx' + +const testAppEnv = { + COOKIE_SECRET: 'test-cookie-secret-0123456789abcdef0123456789', + APP_DB: {} as D1Database, +} as AppEnv + +test('renderAppPage server-renders route content and hydration metadata', async () => { + const response = await renderAppPage({ + request: new Request('https://example.com/reset-password?token=abc123'), + appEnv: testAppEnv, + title: 'Reset password', + }) + + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toContain('text/html') + expect(response.headers.get('Cache-Control')).toBe('no-store') + + const html = await response.text() + expect(html).toContain('') + expect(html).not.toContain('loading-spinner') +}) + +test('renderAppPage preserves 404 state in the server-rendered document', async () => { + const response = await renderAppPage({ + request: new Request('https://example.com/missing'), + appEnv: testAppEnv, + title: 'Not Found', + notFound: true, + status: 404, + }) + + expect(response.status).toBe(404) + + const html = await response.text() + expect(html).toContain('Not Found') + expect(html).toContain('"notFound":true') +}) + +test('renderAppPage server-renders account session details', async () => { + setAuthSessionSecret(testAppEnv.COOKIE_SECRET) + const setCookie = await createAuthCookie( + { + id: '1', + email: 'signed-in@example.com', + rememberMe: false, + }, + true, + ) + const cookie = setCookie.split(';')[0] ?? '' + + const response = await renderAppPage({ + request: new Request('https://example.com/account', { + headers: { Cookie: cookie }, + }), + appEnv: testAppEnv, + title: 'Account', + }) + + expect(response.status).toBe(200) + const html = await response.text() + expect(html).toContain('Welcome, signed-in@example.com') + expect(html).not.toContain('Loading your account') +}) + +test('renderAppPage server-renders deep-linked chat loader data', async () => { + setAuthSessionSecret(testAppEnv.COOKIE_SECRET) + const setCookie = await createAuthCookie( + { + id: '1', + email: 'chat-user@example.com', + rememberMe: false, + }, + true, + ) + const cookie = setCookie.split(';')[0] ?? '' + + const response = await renderAppPage({ + request: new Request('https://example.com/chat/thread-1', { + headers: { Cookie: cookie }, + }), + appEnv: testAppEnv, + title: 'Chat', + loaderData: { + chat: { + ok: true, + threads: [ + { + id: 'thread-1', + title: 'Deep Linked Thread', + lastMessagePreview: null, + messageCount: 0, + createdAt: '2026-07-08T00:00:00.000Z', + updatedAt: '2026-07-08T00:00:00.000Z', + deletedAt: null, + }, + ], + hasMore: false, + nextCursor: null, + totalCount: 1, + selectedThread: null, + search: '', + }, + }, + }) + + expect(response.status).toBe(200) + const html = await response.text() + expect(html).toContain('Deep Linked Thread') + expect(html).toContain('Send a message') +}) + +test('renderAppPage server-renders oauth authorize query errors', async () => { + const response = await renderAppPage({ + request: new Request( + 'https://example.com/oauth/authorize?error=access_denied&error_description=Denied', + ), + appEnv: testAppEnv, + title: 'Authorize', + }) + + expect(response.status).toBe(200) + const html = await response.text() + expect(html).toContain('Denied') + expect(html).not.toContain('Checking your session') +}) + +test('renderAppPage server-renders oauth authorize session details', async () => { + setAuthSessionSecret(testAppEnv.COOKIE_SECRET) + const setCookie = await createAuthCookie( + { + id: '1', + email: 'oauth-user@example.com', + rememberMe: false, + }, + true, + ) + const cookie = setCookie.split(';')[0] ?? '' + + const response = await renderAppPage({ + request: new Request('https://example.com/oauth/authorize', { + headers: { Cookie: cookie }, + }), + appEnv: testAppEnv, + title: 'Authorize', + }) + + expect(response.status).toBe(200) + const html = await response.text() + expect(html).toContain('Signed in as oauth-user@example.com') + expect(html).not.toContain('Checking your session') +}) diff --git a/server/ssr-render.tsx b/server/ssr-render.tsx new file mode 100644 index 0000000..551a08a --- /dev/null +++ b/server/ssr-render.tsx @@ -0,0 +1,59 @@ +/** @jsxImportSource remix/ui */ +/** @jsxRuntime automatic */ +import { type RemixNode } from 'remix/ui' +import { renderToStream } from 'remix/ui/server' +import { type AppLoaderData } from '#shared/loader-data.ts' +import { type AppEnv } from '#types/env-schema.ts' +import { readAuthSessionResult, setAuthSessionSecret } from './auth-session.ts' +import { SsrDocument } from './ssr-document.tsx' + +export type RenderAppPageInput = { + request: Request + appEnv: AppEnv + title?: string + loaderData?: AppLoaderData + notFound?: boolean + status?: number +} + +function getRequestUrl(request: Request) { + const url = new URL(request.url) + return `${url.pathname}${url.search}` +} + +export async function renderAppPage(input: RenderAppPageInput) { + const { request, appEnv, title, loaderData, notFound, status } = input + + setAuthSessionSecret(appEnv.COOKIE_SECRET) + const { session, setCookie } = await readAuthSessionResult(request) + const stream = renderToStream( + ( + + ) as RemixNode, + { + frameSrc: request.url, + onError(error) { + console.error('SSR render error:', error) + }, + }, + ) + + const headers = new Headers({ + 'Cache-Control': 'no-store', + 'Content-Type': 'text/html; charset=utf-8', + }) + if (setCookie) { + headers.append('Set-Cookie', setCookie) + } + + return new Response(stream, { + status: status ?? 200, + headers, + }) +} diff --git a/shared/loader-data.ts b/shared/loader-data.ts new file mode 100644 index 0000000..deb27b3 --- /dev/null +++ b/shared/loader-data.ts @@ -0,0 +1,36 @@ +import { type ChatThreadSummary } from './chat.ts' + +export type AccountLoaderData = { + ok: true + email: string +} + +export type OAuthAuthorizeLoaderData = + | { + ok: true + client: { + id: string + name: string + } + scopes: Array + } + | { + ok: false + error: string + } + +export type ChatLoaderData = { + ok: true + threads: Array + hasMore: boolean + nextCursor: string | null + totalCount: number + selectedThread: ChatThreadSummary | null + search: string +} + +export type AppLoaderData = { + account?: AccountLoaderData + chat?: ChatLoaderData + oauthAuthorize?: OAuthAuthorizeLoaderData +} diff --git a/tsconfig.json b/tsconfig.json index 15562b5..a115008 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,4 +1,8 @@ { + "compilerOptions": { + "jsx": "react-jsx", + "jsxImportSource": "remix/ui" + }, "files": [], "references": [ { "path": "./types/tsconfig-tools.json" }, diff --git a/types/tsconfig-client.json b/types/tsconfig-client.json index 74e5351..cd6d499 100644 --- a/types/tsconfig-client.json +++ b/types/tsconfig-client.json @@ -9,5 +9,10 @@ "jsx": "react-jsx", "jsxImportSource": "remix/ui" }, - "include": ["../client/**/*.ts", "../client/**/*.tsx", "../shared/**/*.ts"] + "include": [ + "../client/**/*.ts", + "../client/**/*.tsx", + "../shared/**/*.ts", + "../server/routes.ts" + ] } diff --git a/types/tsconfig-worker.json b/types/tsconfig-worker.json index aafff83..f11ac5e 100644 --- a/types/tsconfig-worker.json +++ b/types/tsconfig-worker.json @@ -5,7 +5,7 @@ "allowImportingTsExtensions": true, "tsBuildInfoFile": "../node_modules/.tmp/tsconfig.worker.tsbuildinfo", "target": "ES2023", - "lib": ["ES2023", "WebWorker", "WebWorker.Iterable"], + "lib": ["DOM", "DOM.Iterable", "ES2023", "WebWorker", "WebWorker.Iterable"], "baseUrl": ".", // Force MCP SDK to resolve to the hoisted install to avoid duplicate // private type conflicts when multiple copies are present. @@ -18,6 +18,8 @@ ] }, "module": "ESNext", + "jsx": "react-jsx", + "jsxImportSource": "remix/ui", "types": [], "moduleResolution": "bundler", "verbatimModuleSyntax": true, @@ -33,9 +35,12 @@ "./env-schema.ts", "./worker-configuration.d.ts", "../shared/**/*.ts", + "../client/**/*.ts", + "../client/**/*.tsx", "../mock-servers/**/*.ts", "../worker/**/*.ts", "../server/**/*.ts", + "../server/**/*.tsx", "../mcp/**/*.ts" ], "exclude": [ diff --git a/worker/index.ts b/worker/index.ts index 3d9d7c2..8e2b26c 100644 --- a/worker/index.ts +++ b/worker/index.ts @@ -47,7 +47,7 @@ const appHandler = withCors({ } if (url.pathname === oauthPaths.callback) { - return handleOAuthCallback(request) + return handleOAuthCallback(request, env) } if (url.pathname === '/.well-known/appspecific/com.chrome.devtools.json') { diff --git a/worker/oauth-handlers.test.ts b/worker/oauth-handlers.test.ts index 974ea37..c067cb2 100644 --- a/worker/oauth-handlers.test.ts +++ b/worker/oauth-handlers.test.ts @@ -107,7 +107,7 @@ function createFormRequest( }) } -test('authorize page returns SPA shell', async () => { +test('authorize page returns server-rendered page', async () => { const response = await handleAuthorizeRequest( new Request('https://example.com/oauth/authorize'), createEnv(createHelpers()), @@ -116,7 +116,8 @@ test('authorize page returns SPA shell', async () => { expect(response.status).toBe(200) const body = await response.text() expect(body).toContain('client-entry.js') - expect(body).toContain('app-shell') + expect(body).toContain('Authorize access') + expect(body).toContain('