Drei Stränge, die sich über dieselben Dateien ziehen (HomePage, index.css, README, application.properties) und deshalb nicht getrennt committet werden können, ohne einen nicht übersetzbaren Zwischenstand zu hinterlassen: Adressdaten: Der Bereich zeigt den gesamten Bestand des globalen Telefon- buchs - zuerst aus dem Adress-Cache der App (Protokoll 8), sonst über das Kommando "addresses" und ersatzweise aus rund 110 Einzelabfragen hinter einem Wartedialog. Der Mock gibt denselben Bestand heraus und lässt sich über /api/mock/address-cache leeren, um die Rückfallebene zu prüfen. Mock hinter dem Profil "mock": Seine Bohnen (/ws und /api/mock/**) hängen jetzt an @Profile, sind ohne das Profil also nicht vorhanden. Damit kann der Mock nicht versehentlich in einer Produktivumgebung mitlaufen; das Container-Image setzt das Profil nicht. Je ein Test hält beide Richtungen fest. Webhook: POST /api/webhook nimmt beliebiges JSON eines fremden Systems an und reicht es über Server-Sent Events an die offenen Browser weiter, wo es der neue Bereich "Webhook" unverändert anzeigt. Der WebSocket kam dafür nicht in Frage - er gehört der SwyxTray-App. Das Backend hält die letzten 50 Nachrichten vor und liefert sie beim Wiederverbinden anhand der Last-Event-ID nach; ein Heartbeat und X-Accel-Buffering: no halten die Verbindung durch Reverse Proxys hindurch offen. Ein Token (app.webhook.token) ist vorgesehen, aber nicht voreingestellt. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
93 lines
2.7 KiB
TypeScript
93 lines
2.7 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import {
|
||
clearHistory,
|
||
fetchHistory,
|
||
isWebhookEvent,
|
||
mergeEvent,
|
||
mergeEvents,
|
||
type WebhookEvent,
|
||
} from '../webhook'
|
||
|
||
export type WebhookStatus = 'connecting' | 'open' | 'closed'
|
||
|
||
export interface WebhookState {
|
||
events: WebhookEvent[]
|
||
status: WebhookStatus
|
||
error: string | null
|
||
clear: () => Promise<void>
|
||
}
|
||
|
||
/**
|
||
* Hört auf die Nachrichten des Webhooks.
|
||
*
|
||
* Der Strom läuft über eine `EventSource` auf `/api/webhook/events` und wird
|
||
* **unabhängig vom geöffneten Bereich** gehalten: So ist schon alles da, wenn
|
||
* der Bereich „Webhook" aufgeschlagen wird. Bricht die Verbindung ab, verbindet
|
||
* der Browser von selbst neu und schickt dabei die `Last-Event-ID` mit – das
|
||
* Backend liefert dann nach, was in der Lücke ankam.
|
||
*
|
||
* Zusätzlich wird beim Start der Verlauf geholt, damit auch Nachrichten von vor
|
||
* dem Laden der Seite erscheinen.
|
||
*/
|
||
export function useWebhook(): WebhookState {
|
||
const [events, setEvents] = useState<WebhookEvent[]>([])
|
||
const [status, setStatus] = useState<WebhookStatus>('connecting')
|
||
const [error, setError] = useState<string | null>(null)
|
||
|
||
const sourceRef = useRef<EventSource | null>(null)
|
||
|
||
useEffect(() => {
|
||
const controller = new AbortController()
|
||
|
||
fetchHistory(controller.signal)
|
||
.then((history) => {
|
||
if (controller.signal.aborted) return
|
||
setEvents((current) => mergeEvents(current, history))
|
||
})
|
||
.catch((e: unknown) => {
|
||
if (controller.signal.aborted) return
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
})
|
||
|
||
const source = new EventSource('/api/webhook/events')
|
||
sourceRef.current = source
|
||
|
||
source.onopen = () => {
|
||
setStatus('open')
|
||
setError(null)
|
||
}
|
||
|
||
source.addEventListener('webhook', (event) => {
|
||
try {
|
||
const parsed: unknown = JSON.parse((event as MessageEvent<string>).data)
|
||
if (isWebhookEvent(parsed)) setEvents((current) => mergeEvent(current, parsed))
|
||
} catch {
|
||
// Unlesbare Nachricht: lieber übergehen als die Anzeige abstürzen lassen.
|
||
}
|
||
})
|
||
|
||
source.onerror = () => {
|
||
// Der Browser verbindet von selbst neu; nur der endgültige Abbruch ist final.
|
||
setStatus(source.readyState === EventSource.CLOSED ? 'closed' : 'connecting')
|
||
}
|
||
|
||
return () => {
|
||
controller.abort()
|
||
source.close()
|
||
sourceRef.current = null
|
||
}
|
||
}, [])
|
||
|
||
const clear = useCallback(async () => {
|
||
try {
|
||
await clearHistory()
|
||
setEvents([])
|
||
setError(null)
|
||
} catch (e: unknown) {
|
||
setError(e instanceof Error ? e.message : String(e))
|
||
}
|
||
}, [])
|
||
|
||
return { events, status, error, clear }
|
||
}
|