feat(fides-auth-next): add useHeartbeat hook and handleHeartbeat handler#1473
Conversation
There was a problem hiding this comment.
Pull request overview
Adds the missing “heartbeat” layer to @eventuras/fides-auth-next, enabling activity-gated session refresh via a client hook and a Next.js route handler (to be wired into apps/web in the follow-up PR).
Changes:
- Added
useHeartbeatclient hook that periodically POSTs a refresh endpoint only when the user has been recently active and the tab is visible. - Added
handleHeartbeatNext.js route-handler helper that refreshes the current session and returns 401 when refresh is no longer possible. - Updated library build/exports and added a changeset for a minor release.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| libs/fides-auth-next/vite.config.ts | Adds heartbeat-handler as a build entrypoint. |
| libs/fides-auth-next/src/store/use-heartbeat.ts | New client hook implementing activity-driven keepalive logic. |
| libs/fides-auth-next/src/store/index.ts | Re-exports useHeartbeat and HeartbeatConfig from the store subpath. |
| libs/fides-auth-next/src/index.ts | Re-exports the heartbeat handler from the package root. |
| libs/fides-auth-next/src/heartbeat-handler.ts | New server-side helper to rate-limit and refresh the current session on POST. |
| libs/fides-auth-next/package.json | Adds ./heartbeat-handler export map entry. |
| .changeset/fides-auth-next-heartbeat.md | Declares a minor version bump for the new hook + handler. |
| const response = await fetch(endpoint, { | ||
| method: 'POST', | ||
| credentials: 'same-origin', | ||
| headers: { 'content-type': 'application/json' }, | ||
| }); | ||
|
|
||
| if (response.status === 401) { | ||
| logger.info('Heartbeat returned 401 — session expired'); | ||
| callbacksRef.current.onSessionExpired?.(); | ||
| stopped = true; | ||
| return; | ||
| } | ||
|
|
||
| if (!response.ok) { | ||
| throw new Error(`Heartbeat failed with status ${response.status}`); | ||
| } | ||
|
|
||
| logger.debug('Heartbeat refresh succeeded'); | ||
| callbacksRef.current.onRefreshed?.(); | ||
| } catch (error) { | ||
| const err = error instanceof Error ? error : new Error(String(error)); | ||
| logger.warn({ error: err.message }, 'Heartbeat tick failed'); | ||
| callbacksRef.current.onError?.(err); |
There was a problem hiding this comment.
Good catch — added an AbortController that's aborted in the teardown, plus a stopped re-check after the await fetch(...) so callbacks can't fire after unmount or after a 401. Extracted the cleanup into a teardown() helper that the 401 branch now calls eagerly, so the visibility listener and tracker don't linger after the session is known dead. The catch block also short-circuits on stopped so an aborted fetch doesn't surface as an onError.
| // Tab regained focus. If we've been away longer than one tick, beat now. | ||
| const sinceLast = Date.now() - lastTickAt; | ||
| if (sinceLast >= intervalMs) { | ||
| logger.debug({ sinceLast }, 'Tab regained focus after long away, beating now'); | ||
| void sendBeat(); | ||
| } |
There was a problem hiding this comment.
Right — onVisibilityChange now clears the pending timeoutId before triggering the immediate beat, and chains scheduleNext after the beat resolves so the next tick is measured from now rather than firing back-to-back.
| config: HeartbeatHandlerConfig, | ||
| ): Promise<Response> { | ||
| if (request.method !== 'POST') { | ||
| return new Response(null, { status: 405, headers: { allow: 'POST' } }); |
There was a problem hiding this comment.
Fixed — using Allow (conventional title-case) on the 405.
| return Response.json({ | ||
| accessTokenExpiresAt: updated.tokens?.accessTokenExpiresAt ?? null, | ||
| }); |
There was a problem hiding this comment.
Good defense-in-depth — added Cache-Control: private, no-store on the 200 success response. (401s aren't cacheable by default per RFC 7234, so leaving those as-is.)
In fides-auth-next: activity-driven session keepalive on top of @eventuras/fides-auth's createActivityTracker. Refreshes access tokens while the user is actually interacting, so long form-editing sessions don't hit the idle refresh-token cut-off. - useHeartbeat: React hook that ticks every 5 min, gated on activity within the last 2 min, pauses in hidden tabs, beats immediately on tab refocus - handleHeartbeat: Next route-handler factory that runs server-side refresh and returns 401 when the refresh token is gone Part 2/3 of the heartbeat work. PR #3 wires this into apps/web. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2e8666f to
d9121c3
Compare
|
Wires the heartbeat from @eventuras/fides-auth-next into apps/web: - New `/api/auth/heartbeat` POST route that delegates to the `handleHeartbeat` factory with the app's `oauthConfig` - `useHeartbeat` hook invoked from the root `Providers` so every authenticated user benefits — dispatches `sessionExpired` to the auth store on 401 Final 3/3 of the heartbeat work. Together with #1470 and #1473, this should stop the "logged out while typing" reports for active users. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the heartbeat from @eventuras/fides-auth-next into apps/web: - New `/api/auth/heartbeat` POST route that delegates to the `handleHeartbeat` factory with the app's `oauthConfig` - `useHeartbeat` hook invoked from the root `Providers` so every authenticated user benefits — dispatches `sessionExpired` to the auth store on 401 Final 3/3 of the heartbeat work. Together with #1470 and #1473, this should stop the "logged out while typing" reports for active users. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>



Summary
Activity-driven session keepalive on top of the
createActivityTrackerprimitive landed in #1470.useHeartbeat— React hook that ticks every 5 min, gated on user activity within the last 2 min. Pauses on hidden tabs, beats immediately on tab refocus after a long away, and emitsonSessionExpiredwhen the refresh endpoint returns 401.handleHeartbeat— Next.js route-handler factory that runsrefreshCurrentSessionand returns 401 when the refresh token is gone. Rate-limited via the existingglobalPOSTRateLimit.@eventuras/fides-auth-next/store(addsuseHeartbeat) and@eventuras/fides-auth-next/heartbeat-handler.Part 2/3 of the session heartbeat work:
fides-auth: activity tracker primitive (merged)fides-auth-next: hook + handlerapps/web: route + provider wiringNo app behavior change in this PR — these are unused exports until wired up in PR #3.
Motivation
Auth0 idle refresh-token lifetime kicks in even for active users who don't navigate — e.g. long form editing. This is the bridge between "user is interacting" (PR #1) and "tokens get refreshed" (PR #3).
Design notes
libs/fides-auth-nexthas no vitest setup yet; planned as a follow-up (logged separately)Test plan
pnpm --filter @eventuras/fides-auth-next buildemitsdist/heartbeat-handler.{js,d.ts}anddist/store/use-heartbeat.{js,d.ts}.changeset/fides-auth-next-heartbeat.md)🤖 Generated with Claude Code