fix(M8): make SSE-stream survive Solo Paneel mutations
Symptoom op feat/ST-801-realtime-triggers initial implementation: elke task-update sloot de open SSE-stream af en triggerde een herverbinding met backoff. In de tussentijd gemiste events. Oorzaak: Server Actions in App Router doen een impliciete route-tree refresh die client components remount; daarmee killt React de useEffect die de EventSource beheert. Fix in twee delen: 1. Hef de realtime-hook op naar de (app)-layout via een nieuwe `SoloRealtimeBridge`-component. Layouts overleven Server- Action-refreshes beter dan pages, en de bridge leest het product-id uit de URL via usePathname. Connection-status (status, showConnectingIndicator) gaat naar de solo-store zodat SoloBoard 'm uit een gedeelde plek kan lezen. 2. Vervang updateTaskStatusAction en updateTaskPlanAction in de Solo-componenten door fetch naar de bestaande Route Handler `PATCH /api/tasks/[id]`. Route Handlers triggeren geen page-refresh, dus de SSE-stream blijft staan. lib/api-auth.ts accepteert nu naast Bearer-tokens ook iron-session cookies zodat browser-fetches zonder token werken. Bijkomend: actions/tasks.ts laat /solo bewust niet meer revalideren (wordt nu via realtime gedekt). Sprint/planning blijft wel revalidaten — geen realtime daar. Toegevoegd: - components/solo/realtime-bridge.tsx — mount in (app) layout - scripts/realtime-mutate.ts — handige test-helper voor externe mutaties (alsof MCP/REST schrijft) tijdens acceptance Debug-logs in app/api/realtime/solo/route.ts staan nog aan voor ST-806 acceptance; worden later gestript. Bekend issue: Chrome op localhost (HTTP/1.1) cycle't EventSource om de paar seconden vanwege de 6-connectie-limiet en retry- heuristiek. Safari werkt stabiel. Productie op Vercel (HTTP/2 multiplexing) zou beide browsers stabiel moeten houden — Vercel preview test is volgende stap. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f12e50d8cb
commit
847fc84faf
10 changed files with 254 additions and 74 deletions
|
|
@ -1,23 +1,40 @@
|
|||
import { createHash } from 'crypto'
|
||||
import { cookies } from 'next/headers'
|
||||
import { getIronSession } from 'iron-session'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { sessionOptions, type SessionData } from '@/lib/session'
|
||||
|
||||
// Probeert eerst Bearer-token (REST/MCP), valt terug op iron-session
|
||||
// cookie (browser fetches vanuit ingelogde sessie). Cookie-pad is bewust
|
||||
// voor Solo Paneel-mutations die anders via Server Action zouden gaan —
|
||||
// maar Server Actions triggeren een page-refresh die SSE-streams sluit.
|
||||
export async function authenticateApiRequest(request: Request) {
|
||||
const authHeader = request.headers.get('Authorization')
|
||||
if (!authHeader?.startsWith('Bearer ')) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
|
||||
if (authHeader?.startsWith('Bearer ')) {
|
||||
const token = authHeader.slice(7)
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { token_hash: tokenHash },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
}
|
||||
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
|
||||
}
|
||||
|
||||
const token = authHeader.slice(7)
|
||||
const tokenHash = createHash('sha256').update(token).digest('hex')
|
||||
|
||||
const apiToken = await prisma.apiToken.findUnique({
|
||||
where: { token_hash: tokenHash },
|
||||
include: { user: true },
|
||||
})
|
||||
|
||||
if (!apiToken || apiToken.revoked_at) {
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
// Geen Bearer — probeer iron-session cookie
|
||||
try {
|
||||
const session = await getIronSession<SessionData>(await cookies(), sessionOptions)
|
||||
if (session.userId) {
|
||||
return { userId: session.userId, isDemo: session.isDemo ?? false }
|
||||
}
|
||||
} catch {
|
||||
// cookies() outside of request-scope kan throwen — laat door naar 401
|
||||
}
|
||||
|
||||
return { userId: apiToken.user_id, isDemo: apiToken.user.is_demo }
|
||||
return { error: 'Unauthorized', status: 401 as const }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,42 +1,47 @@
|
|||
// ST-803: client-side hook voor de Solo Paneel realtime stream.
|
||||
// ST-803 + ST-805 (refactor in ST-806-acceptance): client-side hook die de
|
||||
// Solo Paneel realtime stream beheert.
|
||||
//
|
||||
// - Opent EventSource('/api/realtime/solo?product_id=...')
|
||||
// - Reconnect met exponential backoff (1s → 30s, reset bij ready)
|
||||
// - Pauseert bij document.visibilityState === 'hidden', resumes bij 'visible'
|
||||
// - Cleanup op unmount
|
||||
// - Dispatcht events naar de solo-store via handleRealtimeEvent
|
||||
//
|
||||
// State exposed:
|
||||
// status: 'connecting' | 'open' | 'disconnected'
|
||||
// showConnectingIndicator: true zodra status !== 'open' langer dan 2s duurt
|
||||
// (UI gebruikt dit zodat micro-disconnects geen flikker veroorzaken)
|
||||
// - Mount in de (app)-layout via SoloRealtimeBridge zodat hij Server Action-
|
||||
// refreshes overleeft (anders kapt Next.js' soft-navigation de SSE).
|
||||
// - Opent EventSource('/api/realtime/solo?product_id=...') wanneer
|
||||
// productId niet null is; sluit de stream als productId null wordt.
|
||||
// - Reconnect met exponential backoff (1s → 30s, reset bij ready).
|
||||
// - Pauseert bij document.visibilityState === 'hidden', resumes bij visible.
|
||||
// - Cleanup op unmount.
|
||||
// - Connection-status (status, showConnectingIndicator) wordt naar de
|
||||
// solo-store geschreven; UI-componenten lezen daar uit.
|
||||
// - Dispatcht events naar de solo-store via handleRealtimeEvent. Task-
|
||||
// updates worden in document.startViewTransition + flushSync gewikkeld
|
||||
// zodat het kanban-kaartje soepel naar zijn nieuwe kolom animeert
|
||||
// (animatie A — vereist view-transition-name op de cards).
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef } from 'react'
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { flushSync } from 'react-dom'
|
||||
import { useSoloStore } from '@/stores/solo-store'
|
||||
import type { RealtimeEvent } from '@/stores/solo-store'
|
||||
|
||||
export type RealtimeStatus = 'connecting' | 'open' | 'disconnected'
|
||||
import type { RealtimeEvent, RealtimeStatus } from '@/stores/solo-store'
|
||||
|
||||
const BACKOFF_START_MS = 1_000
|
||||
const BACKOFF_MAX_MS = 30_000
|
||||
const CONNECTING_INDICATOR_DELAY_MS = 2_000
|
||||
|
||||
export function useSoloRealtime(productId: string) {
|
||||
const [status, setStatus] = useState<RealtimeStatus>('connecting')
|
||||
const [showConnectingIndicator, setShowConnectingIndicator] = useState(false)
|
||||
|
||||
// Refs voor lifecycle die ge-survival moeten zijn over re-renders
|
||||
export function useSoloRealtime(productId: string | null) {
|
||||
const sourceRef = useRef<EventSource | null>(null)
|
||||
const backoffRef = useRef<number>(BACKOFF_START_MS)
|
||||
const reconnectTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const indicatorTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const setStatus = useSoloStore.getState().setRealtimeStatus
|
||||
const handleEvent = useSoloStore.getState().handleRealtimeEvent
|
||||
|
||||
if (!productId) {
|
||||
// Geen actief product (gebruiker zit niet op /solo) — stream uit
|
||||
setStatus('disconnected', false)
|
||||
return
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
if (sourceRef.current) {
|
||||
sourceRef.current.close()
|
||||
|
|
@ -54,17 +59,19 @@ export function useSoloRealtime(productId: string) {
|
|||
indicatorTimerRef.current = null
|
||||
}
|
||||
if (next === 'open') {
|
||||
setShowConnectingIndicator(false)
|
||||
setStatus('open', false)
|
||||
} else {
|
||||
// Status meteen bijwerken, indicator pas na 2s — voorkomt flikker
|
||||
// bij microscopische disconnects.
|
||||
setStatus(next, false)
|
||||
indicatorTimerRef.current = setTimeout(() => {
|
||||
setShowConnectingIndicator(true)
|
||||
setStatus(useSoloStore.getState().realtimeStatus, true)
|
||||
}, CONNECTING_INDICATOR_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
const connect = () => {
|
||||
close()
|
||||
setStatus('connecting')
|
||||
scheduleIndicator('connecting')
|
||||
|
||||
const source = new EventSource(
|
||||
|
|
@ -74,7 +81,6 @@ export function useSoloRealtime(productId: string) {
|
|||
|
||||
source.addEventListener('ready', () => {
|
||||
backoffRef.current = BACKOFF_START_MS
|
||||
setStatus('open')
|
||||
scheduleIndicator('open')
|
||||
})
|
||||
|
||||
|
|
@ -82,13 +88,11 @@ export function useSoloRealtime(productId: string) {
|
|||
if (!e.data) return
|
||||
try {
|
||||
const payload = JSON.parse(e.data) as RealtimeEvent
|
||||
// ST-805 animatie A: kanban-move animeren via View Transitions API.
|
||||
// Voor task UPDATE-events wrap'en we de store-update in een view
|
||||
// transition. De browser snapshot't de DOM voor en na, en animeert
|
||||
// het verschil — vereist `view-transition-name` op de cards.
|
||||
// Andere events (task INSERT/DELETE, story-events) krijgen geen
|
||||
// animatie; die zijn niet zichtbaar als positie-shift in de
|
||||
// kanban-kolommen.
|
||||
// Animatie A: kanban-move animeren via View Transitions API. Voor
|
||||
// task UPDATE-events wrap'en we de store-update in een view
|
||||
// transition. flushSync forceert React om synchroon te renderen
|
||||
// tijdens de transition-callback zodat de nieuwe DOM-state wordt
|
||||
// gesnapshot voor de animatie.
|
||||
const animate =
|
||||
payload.entity === 'task' &&
|
||||
payload.op === 'U' &&
|
||||
|
|
@ -96,10 +100,6 @@ export function useSoloRealtime(productId: string) {
|
|||
typeof (document as Document & { startViewTransition?: unknown }).startViewTransition ===
|
||||
'function'
|
||||
if (animate) {
|
||||
// flushSync forceert React om de re-render synchroon te doen
|
||||
// tijdens de view-transition callback, zodat de nieuwe DOM-state
|
||||
// wordt gesnapshot voor de animatie. Zonder flushSync rendert
|
||||
// React asynchroon en captured de browser nog de oude state.
|
||||
;(
|
||||
document as Document & {
|
||||
startViewTransition: (cb: () => void) => unknown
|
||||
|
|
@ -118,17 +118,11 @@ export function useSoloRealtime(productId: string) {
|
|||
}
|
||||
|
||||
source.onerror = () => {
|
||||
// EventSource probeert standaard zelf te reconnecten, maar we willen
|
||||
// controle over backoff + skip-on-hidden. Dus close + plan zelf.
|
||||
if (sourceRef.current !== source) return
|
||||
close()
|
||||
setStatus('disconnected')
|
||||
scheduleIndicator('disconnected')
|
||||
|
||||
if (document.visibilityState === 'hidden') {
|
||||
// Niet retryen tot tab weer zichtbaar wordt
|
||||
return
|
||||
}
|
||||
if (document.visibilityState === 'hidden') return
|
||||
const delay = backoffRef.current
|
||||
backoffRef.current = Math.min(backoffRef.current * 2, BACKOFF_MAX_MS)
|
||||
reconnectTimerRef.current = setTimeout(connect, delay)
|
||||
|
|
@ -138,7 +132,6 @@ export function useSoloRealtime(productId: string) {
|
|||
const onVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
close()
|
||||
setStatus('disconnected')
|
||||
scheduleIndicator('disconnected')
|
||||
} else if (sourceRef.current === null) {
|
||||
backoffRef.current = BACKOFF_START_MS
|
||||
|
|
@ -157,6 +150,4 @@ export function useSoloRealtime(productId: string) {
|
|||
close()
|
||||
}
|
||||
}, [productId])
|
||||
|
||||
return { status, showConnectingIndicator }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue