chore(debug): add /debug-realtime page + bare SSE endpoint
Tijdelijke debug-tooling voor M8-acceptance op Vercel preview. - app/api/debug/realtime-stream/route.ts — geen auth, geen filtering; dropt elke pg_notify-event op scrum4me_changes rauw door als SSE - app/debug-realtime/page.tsx — open zonder login op de root, toont binnenkomende events in een simpele <table> Doel: isoleren of de SSE + Postgres LISTEN-pipe op Vercel überhaupt events laat zien, los van iron-session, productfilter of solo-store. Als ook deze niets binnen krijgt: probleem zit in pg connection of Vercel function lifecycle. Als deze wel events toont: probleem zit hoger in de stack (filter, store, hook). VERWIJDEREN voordat de PR uit draft gaat. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
868a53c2ed
commit
e964a76318
3 changed files with 241 additions and 0 deletions
114
app/api/debug/realtime-stream/route.ts
Normal file
114
app/api/debug/realtime-stream/route.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
// TIJDELIJKE debug-endpoint voor M8-acceptance.
|
||||
// Geen auth, geen filtering — alle pg_notify-events op `scrum4me_changes`
|
||||
// stromen rauw door naar de browser. Bedoeld om te isoleren of de
|
||||
// SSE + LISTEN-pipe op Vercel werkt, los van iron-session, productfilter
|
||||
// of solo-store.
|
||||
//
|
||||
// VERWIJDEREN VOOR M8 OUT-OF-DRAFT.
|
||||
|
||||
import { NextRequest } from 'next/server'
|
||||
import { Client } from 'pg'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
export const dynamic = 'force-dynamic'
|
||||
export const maxDuration = 300
|
||||
|
||||
const CHANNEL = 'scrum4me_changes'
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const directUrl = process.env.DIRECT_URL ?? process.env.DATABASE_URL
|
||||
if (!directUrl) {
|
||||
return Response.json({ error: 'DIRECT_URL/DATABASE_URL niet gezet' }, { status: 500 })
|
||||
}
|
||||
|
||||
const isPooled = directUrl.includes('pooler.')
|
||||
const hostHint = directUrl.match(/@([^/]+)/)?.[1] ?? 'unknown-host'
|
||||
console.log(`[debug-realtime] connecting (${isPooled ? 'POOLED' : 'direct'}) host=${hostHint}`)
|
||||
|
||||
const encoder = new TextEncoder()
|
||||
const pgClient = new Client({ connectionString: directUrl })
|
||||
let closed = false
|
||||
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
const enqueue = (chunk: string) => {
|
||||
if (closed) return
|
||||
try {
|
||||
controller.enqueue(encoder.encode(chunk))
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
}
|
||||
|
||||
const cleanup = async (reason: string) => {
|
||||
if (closed) return
|
||||
closed = true
|
||||
if (heartbeatTimer) clearInterval(heartbeatTimer)
|
||||
try {
|
||||
await pgClient.end()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
try {
|
||||
controller.close()
|
||||
} catch {
|
||||
// already closed
|
||||
}
|
||||
console.log(`[debug-realtime] closed: ${reason}`)
|
||||
}
|
||||
|
||||
try {
|
||||
await pgClient.connect()
|
||||
await pgClient.query(`LISTEN ${CHANNEL}`)
|
||||
console.log('[debug-realtime] LISTEN ready')
|
||||
} catch (err) {
|
||||
console.error('[debug-realtime] pg connect/listen failed:', err)
|
||||
enqueue(`event: error\ndata: ${JSON.stringify({ message: String(err) })}\n\n`)
|
||||
await cleanup('pg connect failed')
|
||||
return
|
||||
}
|
||||
|
||||
pgClient.on('notification', (msg) => {
|
||||
console.log(`[debug-realtime] RAW notification length=${msg.payload?.length ?? 0}`)
|
||||
if (!msg.payload) return
|
||||
enqueue(`data: ${msg.payload}\n\n`)
|
||||
})
|
||||
|
||||
pgClient.on('error', async (err) => {
|
||||
console.error('[debug-realtime] pg client error:', err)
|
||||
await cleanup('pg error')
|
||||
})
|
||||
|
||||
pgClient.on('end', () => {
|
||||
console.log('[debug-realtime] pg client end')
|
||||
})
|
||||
|
||||
enqueue(
|
||||
`event: ready\ndata: ${JSON.stringify({
|
||||
host: hostHint,
|
||||
pooled: isPooled,
|
||||
channel: CHANNEL,
|
||||
time: new Date().toISOString(),
|
||||
})}\n\n`,
|
||||
)
|
||||
|
||||
heartbeatTimer = setInterval(() => {
|
||||
enqueue(`: heartbeat ${new Date().toISOString()}\n\n`)
|
||||
}, 25_000)
|
||||
|
||||
request.signal.addEventListener('abort', () => {
|
||||
cleanup('client aborted')
|
||||
})
|
||||
},
|
||||
})
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||
'Cache-Control': 'no-cache, no-transform',
|
||||
Connection: 'keep-alive',
|
||||
'X-Accel-Buffering': 'no',
|
||||
},
|
||||
})
|
||||
}
|
||||
104
app/debug-realtime/client.tsx
Normal file
104
app/debug-realtime/client.tsx
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
'use client'
|
||||
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
|
||||
interface Row {
|
||||
receivedAt: string
|
||||
type: 'ready' | 'message' | 'error'
|
||||
raw: string
|
||||
}
|
||||
|
||||
const MAX_ROWS = 200
|
||||
|
||||
export function DebugRealtimeClient() {
|
||||
const [rows, setRows] = useState<Row[]>([])
|
||||
const [status, setStatus] = useState<'connecting' | 'open' | 'closed' | 'error'>('connecting')
|
||||
const sourceRef = useRef<EventSource | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource('/api/debug/realtime-stream')
|
||||
sourceRef.current = source
|
||||
|
||||
const append = (row: Row) => {
|
||||
setRows((prev) => [row, ...prev].slice(0, MAX_ROWS))
|
||||
}
|
||||
|
||||
source.addEventListener('ready', (e) => {
|
||||
setStatus('open')
|
||||
const data = (e as MessageEvent).data ?? ''
|
||||
append({ receivedAt: new Date().toISOString(), type: 'ready', raw: data })
|
||||
})
|
||||
|
||||
source.addEventListener('error', (e) => {
|
||||
setStatus('error')
|
||||
const data = (e as MessageEvent).data ?? '(no data)'
|
||||
append({ receivedAt: new Date().toISOString(), type: 'error', raw: data })
|
||||
})
|
||||
|
||||
source.onmessage = (e) => {
|
||||
append({ receivedAt: new Date().toISOString(), type: 'message', raw: e.data ?? '' })
|
||||
}
|
||||
|
||||
source.onerror = () => {
|
||||
setStatus('error')
|
||||
}
|
||||
|
||||
return () => {
|
||||
source.close()
|
||||
sourceRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
function statusColor() {
|
||||
switch (status) {
|
||||
case 'open':
|
||||
return 'green'
|
||||
case 'error':
|
||||
return 'red'
|
||||
case 'closed':
|
||||
return 'gray'
|
||||
default:
|
||||
return 'orange'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
Status:{' '}
|
||||
<span style={{ color: statusColor(), fontWeight: 'bold' }}>{status}</span> · totaal{' '}
|
||||
{rows.length} entries
|
||||
</div>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#f0f0f0', textAlign: 'left' }}>
|
||||
<th style={{ padding: 6, border: '1px solid #ddd', width: 220 }}>received_at</th>
|
||||
<th style={{ padding: 6, border: '1px solid #ddd', width: 100 }}>type</th>
|
||||
<th style={{ padding: 6, border: '1px solid #ddd' }}>payload</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} style={{ padding: 12, textAlign: 'center', color: '#888' }}>
|
||||
Wachten op events… trigger een mutatie via UI of script.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, idx) => (
|
||||
<tr key={`${row.receivedAt}-${idx}`}>
|
||||
<td style={{ padding: 6, border: '1px solid #ddd', whiteSpace: 'nowrap' }}>
|
||||
{row.receivedAt}
|
||||
</td>
|
||||
<td style={{ padding: 6, border: '1px solid #ddd' }}>{row.type}</td>
|
||||
<td style={{ padding: 6, border: '1px solid #ddd', wordBreak: 'break-all' }}>
|
||||
<code>{row.raw}</code>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
23
app/debug-realtime/page.tsx
Normal file
23
app/debug-realtime/page.tsx
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
// TIJDELIJKE debug-pagina voor M8-acceptance.
|
||||
// Geen auth, geen styling — toont alle inkomende pg_notify-events op
|
||||
// `scrum4me_changes` in een tabel zodat we kunnen zien of de SSE + LISTEN-
|
||||
// pipe überhaupt events doorstroomt op Vercel.
|
||||
//
|
||||
// VERWIJDEREN VOOR M8 OUT-OF-DRAFT.
|
||||
|
||||
import { DebugRealtimeClient } from './client'
|
||||
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
export default function DebugRealtimePage() {
|
||||
return (
|
||||
<div style={{ fontFamily: 'monospace', padding: 16 }}>
|
||||
<h1 style={{ fontSize: 18, fontWeight: 'bold' }}>Realtime debug — scrum4me_changes</h1>
|
||||
<p style={{ fontSize: 13, color: '#666' }}>
|
||||
Live SSE-stream rechtstreeks van Postgres LISTEN op channel{' '}
|
||||
<code>scrum4me_changes</code>. Geen auth, geen filtering. Verwijderen na M8 acceptance.
|
||||
</p>
|
||||
<DebugRealtimeClient />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue