From 76c16fc0a491c493cf45fc7cf374307a6f31ab93 Mon Sep 17 00:00:00 2001
From: "HomePC\\Kevin"
Date: Sun, 13 Sep 2026 15:41:16 +0200
Subject: [PATCH] feat(ui): synchronize management views with native Bun
websockets
---
README.md | 8 +
controller/src/runtime/access_logs/cache.rs | 118 +++++++-
controller/src/runtime/access_logs/tests.rs | 4 +-
.../runtime/access_logs/tests/snapshots.rs | 2 +-
docker/production/Dockerfile | 1 +
docker/web/Dockerfile | 1 +
docker/web/serve.mjs | 14 +-
web/src/db/index.ts | 11 +-
.../Hooks/useAccessPolicyManagementLogic.ts | 15 +-
.../Components/AuditLogsPageView.tsx | 4 +-
.../AuditLogs/Components/AuditLogsTable.tsx | 80 ++----
.../AuditLogs/Hooks/useAuditLogsLogic.ts | 87 +++---
.../Admin/AuditLogs/Types/audit-logs.types.ts | 7 +-
web/src/features/Admin/AuditLogs/queryKeys.ts | 1 +
web/src/features/Admin/AuditLogs/server.ts | 14 +-
.../Hooks/useCertificateManagementLogic.ts | 12 +-
.../Hooks/useCertificatesTableColumns.ts | 5 +
.../Hooks/useCertificatesTableLogic.ts | 12 +
.../Components/ProxyAccessLogsPageView.tsx | 3 -
.../Components/ProxyAccessLogsTable.tsx | 42 +--
.../Hooks/useProxyAccessLogsLogic.ts | 67 +++--
.../Types/proxy-access-logs.types.ts | 3 -
.../Hooks/useProxyHostManagementLogic.ts | 20 +-
.../Hooks/useRedirectHostManagementLogic.ts | 15 +-
.../Components/CompactFoundationStatus.tsx | 2 +-
.../Components/FoundationStatus.tsx | 8 +-
.../createFoundationStatusViewModel.ts | 17 +-
.../Hooks/useFoundationStatusLogic.ts | 24 +-
.../Types/foundation-status.types.ts | 4 +
web/src/features/FoundationStatus/index.tsx | 2 +-
web/src/language/Locales/de.json | 15 +-
web/src/language/Locales/en.json | 15 +-
web/src/language/Locales/es.json | 15 +-
web/src/language/Locales/fr.json | 15 +-
.../Components/AuthenticatedRouteLayout.tsx | 2 +
web/src/routeTree.gen.ts | 21 ++
web/src/routes/api/live-snapshot.ts | 12 +
web/src/server/Audit/audit-reader.service.ts | 12 +-
web/src/shared/Live/useApplicationLiveSync.ts | 98 +++++++
web/src/shared/Live/useLiveInvalidation.ts | 50 ++++
web/src/shared/Live/useLiveQuery.ts | 60 ++++
.../Table/Components/TableColumnFilters.tsx | 2 +-
web/src/shared/Types/audit-events.types.ts | 5 +
web/src/start.ts | 17 +-
web/src/tests/application-live-sync.test.tsx | 186 +++++++++++++
.../tests/audit-events-permissions.test.ts | 37 ++-
web/src/tests/audit-logs-ui.test.tsx | 57 ++--
web/src/tests/certificates-ui.test.tsx | 43 +++
web/src/tests/foundation-live-cache.test.tsx | 135 +++++++++
web/src/tests/live-invalidation.test.tsx | 113 ++++++++
web/src/tests/live-query-ui.test.tsx | 148 ++++++++++
web/src/tests/live-snapshot.test.ts | 111 ++++++++
web/src/tests/live-transport.test.ts | 231 ++++++++++++++++
web/src/tests/proxy-access-logs-ui.test.tsx | 28 +-
web/src/tests/realtime-events-client.test.ts | 244 ++++++++++++++++
.../websockets/Client/realtimeEventsClient.ts | 260 ++++++++++++++++++
web/src/websockets/Helpers/liveReader.ts | 134 +++++++++
web/src/websockets/Helpers/messages.ts | 28 ++
.../websockets/Helpers/publishFunctions.ts | 69 +++++
.../websockets/Helpers/realtimeConstants.ts | 35 +++
web/src/websockets/Helpers/snapshot.ts | 161 +++++++++++
web/src/websockets/Server/realtimeHandler.ts | 85 ++++++
.../websockets/Server/realtimeLifecycle.ts | 41 +++
.../Server/realtimeRedis.service.ts | 61 ++++
.../Server/realtimeSnapshots.service.ts | 111 ++++++++
.../Server/realtimeSubscriptions.ts | 192 +++++++++++++
.../websockets/Server/realtimeWebSocket.ts | 177 ++++++++++++
web/src/websockets/Types/bun.ts | 49 ++++
web/src/websockets/Types/events.ts | 25 ++
web/vite-live.ts | 104 +++++++
web/vite.config.ts | 3 +
71 files changed, 3521 insertions(+), 289 deletions(-)
create mode 100644 web/src/routes/api/live-snapshot.ts
create mode 100644 web/src/shared/Live/useApplicationLiveSync.ts
create mode 100644 web/src/shared/Live/useLiveInvalidation.ts
create mode 100644 web/src/shared/Live/useLiveQuery.ts
create mode 100644 web/src/tests/application-live-sync.test.tsx
create mode 100644 web/src/tests/foundation-live-cache.test.tsx
create mode 100644 web/src/tests/live-invalidation.test.tsx
create mode 100644 web/src/tests/live-query-ui.test.tsx
create mode 100644 web/src/tests/live-snapshot.test.ts
create mode 100644 web/src/tests/live-transport.test.ts
create mode 100644 web/src/tests/realtime-events-client.test.ts
create mode 100644 web/src/websockets/Client/realtimeEventsClient.ts
create mode 100644 web/src/websockets/Helpers/liveReader.ts
create mode 100644 web/src/websockets/Helpers/messages.ts
create mode 100644 web/src/websockets/Helpers/publishFunctions.ts
create mode 100644 web/src/websockets/Helpers/realtimeConstants.ts
create mode 100644 web/src/websockets/Helpers/snapshot.ts
create mode 100644 web/src/websockets/Server/realtimeHandler.ts
create mode 100644 web/src/websockets/Server/realtimeLifecycle.ts
create mode 100644 web/src/websockets/Server/realtimeRedis.service.ts
create mode 100644 web/src/websockets/Server/realtimeSnapshots.service.ts
create mode 100644 web/src/websockets/Server/realtimeSubscriptions.ts
create mode 100644 web/src/websockets/Server/realtimeWebSocket.ts
create mode 100644 web/src/websockets/Types/bun.ts
create mode 100644 web/src/websockets/Types/events.ts
create mode 100644 web/vite-live.ts
diff --git a/README.md b/README.md
index 85e0e9c..167532d 100644
--- a/README.md
+++ b/README.md
@@ -137,6 +137,14 @@ and TCP/UDP port mappings, alongside the backup: these settings are not stored i
Development setup and checks are in [`CONTRIBUTING.md`](./CONTRIBUTING.md).
+### Live administration
+
+The management UI shares one native Bun WebSocket connection per visible browser tab. Changes to hosts, users, profile pictures, permissions, and settings invalidate the affected visible views. Application events are also distributed through the existing Redis connection setup. Expired or revoked sessions are revalidated through the same authorization services as HTTP requests.
+
+Pages subscribe only to the data they display. Access logs stop streaming when their page is left, a historical page is selected, or the tab becomes hidden. Returning opens a fresh subscription. Controller-backed log and runtime changes are sampled on the server only while subscribed; the browser does not poll them. The Overview includes the actual WebSocket connection status.
+
+Realtime code lives in `web/src/websockets`, separated into `Client`, `Server`, `Helpers`, and `Types`. Production upgrades share the web server port. Development proxies the same `/api/live` URL to a native Bun listener on a temporary loopback port; no additional public port or realtime credentials are needed.
+
### Certificate renewal and retries
ACME certificates become due after two thirds of their actual certificate lifetime. The
diff --git a/controller/src/runtime/access_logs/cache.rs b/controller/src/runtime/access_logs/cache.rs
index 59ce7a7..71a53d7 100644
--- a/controller/src/runtime/access_logs/cache.rs
+++ b/controller/src/runtime/access_logs/cache.rs
@@ -83,6 +83,23 @@ impl SnapshotCache {
.map_err(|_| ReadError::Failed)?;
let mut state = self.state.lock().map_err(|_| ReadError::Failed)?;
remove_expired(&mut state, now);
+ let capture_snapshot_query = SnapshotQuery::from_query(capture_query);
+ if let Some(snapshot_id) = state
+ .snapshots
+ .iter()
+ .find(|(_, snapshot)| {
+ snapshot.query == capture_snapshot_query
+ && snapshot.entries == capture.entries
+ && snapshot.truncated == capture.truncated
+ && snapshot.available_hosts == capture.available_hosts
+ && snapshot.available_statuses == capture.available_statuses
+ })
+ .map(|(snapshot_id, _)| snapshot_id.clone())
+ {
+ touch_snapshot(&mut state, &snapshot_id);
+ let snapshot = state.snapshots.get(&snapshot_id).ok_or(ReadError::Failed)?;
+ return Ok(page_response(&snapshot_id, snapshot, page_query, reset));
+ }
let mut snapshot_id = new_snapshot_id().ok_or(ReadError::Failed)?;
while state.snapshots.contains_key(&snapshot_id) {
snapshot_id = new_snapshot_id().ok_or(ReadError::Failed)?;
@@ -111,7 +128,7 @@ impl SnapshotCache {
state.bytes = state.bytes.saturating_add(bytes);
state.lru.push_back(snapshot_id.clone());
let snapshot = CachedSnapshot {
- query: SnapshotQuery::from_query(capture_query),
+ query: capture_snapshot_query,
total: capture.entries.len(),
entries: capture.entries,
truncated: capture.truncated,
@@ -315,6 +332,101 @@ mod tests {
}
}
+ fn entry(path: &str) -> AccessLogEntry {
+ AccessLogEntry {
+ timestamp: "2026-09-13T00:00:00Z".into(),
+ host: "example.com".into(),
+ method: "GET".into(),
+ path: path.into(),
+ status: 200,
+ duration_ms: 1,
+ client_ip: "192.0.2.1".into(),
+ upstream: None,
+ bytes: 0,
+ protocol: "HTTP/2".into(),
+ }
+ }
+
+ fn capture(path: &str) -> CapturedLogs {
+ CapturedLogs {
+ entries: vec![entry(path)],
+ truncated: false,
+ available_hosts: vec!["example.com".into()],
+ available_statuses: vec![200],
+ }
+ }
+
+ #[test]
+ fn identical_captures_reuse_unexpired_snapshot_without_evicting_history() {
+ let cache = SnapshotCache::new();
+ let query = query();
+ let now = Instant::now();
+ let historical = cache
+ .insert(&query, &query, capture("/historical"), false, now)
+ .unwrap();
+ let repeated = cache
+ .insert(&query, &query, capture("/current"), false, now)
+ .unwrap();
+ for _ in 0..MAX_SNAPSHOTS {
+ let response = cache
+ .insert(&query, &query, capture("/current"), false, now)
+ .unwrap();
+ assert_eq!(response.snapshot, repeated.snapshot);
+ }
+ assert_eq!(cache.len(), 2);
+ let mut historical_query = query.clone();
+ historical_query.snapshot = Some(historical.snapshot.clone());
+ assert_eq!(
+ cache
+ .lookup(&historical.snapshot, &historical_query, now)
+ .unwrap()
+ .unwrap()
+ .entries[0]
+ .path,
+ "/historical"
+ );
+ }
+
+ #[test]
+ fn changed_capture_gets_a_new_immutable_snapshot() {
+ let cache = SnapshotCache::new();
+ let query = query();
+ let now = Instant::now();
+ let first = cache
+ .insert(&query, &query, capture("/first"), false, now)
+ .unwrap();
+ let changed = cache
+ .insert(&query, &query, capture("/changed"), false, now)
+ .unwrap();
+ assert_ne!(first.snapshot, changed.snapshot);
+ let mut continuation = query.clone();
+ continuation.snapshot = Some(first.snapshot.clone());
+ assert_eq!(
+ cache
+ .lookup(&first.snapshot, &continuation, now)
+ .unwrap()
+ .unwrap()
+ .entries[0]
+ .path,
+ "/first"
+ );
+ }
+
+ #[test]
+ fn expired_identical_capture_is_replaced() {
+ let cache = SnapshotCache::new();
+ let query = query();
+ let now = Instant::now();
+ let first = cache
+ .insert(&query, &query, capture("/same"), false, now)
+ .unwrap();
+ let replacement = cache
+ .insert(&query, &query, capture("/same"), false, now + SNAPSHOT_TTL)
+ .unwrap();
+ assert_ne!(first.snapshot, replacement.snapshot);
+ assert_eq!(cache.len(), 1);
+ }
+
#[test]
fn vector_spare_capacity_is_counted_and_released_before_caching() {
let query = query();
@@ -388,7 +500,7 @@ mod tests {
fn aggregate_memory_bound_evicts_before_the_count_limit() {
let cache = SnapshotCache::new();
let query = query();
- for _ in 0..8 {
+ for index in 0..8 {
let entries = Vec::with_capacity(3 * 1024 * 1024 / size_of::());
cache
.insert(
@@ -397,7 +509,7 @@ mod tests {
CapturedLogs {
entries,
truncated: false,
- available_hosts: Vec::new(),
+ available_hosts: vec![format!("host-{index}")],
available_statuses: Vec::new(),
},
false,
diff --git a/controller/src/runtime/access_logs/tests.rs b/controller/src/runtime/access_logs/tests.rs
index b61497d..692aad6 100644
--- a/controller/src/runtime/access_logs/tests.rs
+++ b/controller/src/runtime/access_logs/tests.rs
@@ -393,7 +393,7 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() {
);
let mut ids = Vec::new();
- for _ in 0..MAX_SNAPSHOTS {
+ for index in 0..MAX_SNAPSHOTS {
ids.push(
cache
.insert(
@@ -402,7 +402,7 @@ fn snapshot_cache_expiry_is_fixed_and_evicts_lru_entries() {
CapturedLogs {
entries: Vec::new(),
truncated: false,
- available_hosts: Vec::new(),
+ available_hosts: vec![format!("host-{index}")],
available_statuses: Vec::new(),
},
false,
diff --git a/controller/src/runtime/access_logs/tests/snapshots.rs b/controller/src/runtime/access_logs/tests/snapshots.rs
index 9e837ff..38ae0dc 100644
--- a/controller/src/runtime/access_logs/tests/snapshots.rs
+++ b/controller/src/runtime/access_logs/tests/snapshots.rs
@@ -124,7 +124,7 @@ async fn equal_timestamps_keep_field_order_after_archive_rename_and_line_reorder
.map(|entry| entry.path.as_str())
.collect::>();
assert!(!second.snapshot_reset);
- assert_ne!(second.snapshot, first.snapshot);
+ assert_eq!(second.snapshot, first.snapshot);
assert_eq!(second_paths, first_paths);
}
diff --git a/docker/production/Dockerfile b/docker/production/Dockerfile
index bf93bdd..be0ef60 100644
--- a/docker/production/Dockerfile
+++ b/docker/production/Dockerfile
@@ -35,6 +35,7 @@ COPY --from=web-build /build/migrate.js ./migrate.js
COPY --from=web-build /app/docker/web/bootstrap-secrets.mjs ./docker/web/bootstrap-secrets.mjs
COPY --from=web-build /app/docker/web/request-context.ts ./docker/web/request-context.ts
COPY --from=web-build /app/docker/web/static-assets.ts ./docker/web/static-assets.ts
+COPY --from=web-build /app/web/src/websockets ./web/src/websockets
COPY --from=web-build /app/docker/web/serve.mjs ./docker/web/serve.mjs
COPY --from=web-build /app/docker/web/healthcheck.mjs ./docker/web/healthcheck.mjs
diff --git a/docker/web/Dockerfile b/docker/web/Dockerfile
index e410258..64b5b21 100644
--- a/docker/web/Dockerfile
+++ b/docker/web/Dockerfile
@@ -37,6 +37,7 @@ COPY --from=build /build/migrate.js ./migrate.js
COPY --from=build /app/docker/web/bootstrap-secrets.mjs ./docker/web/bootstrap-secrets.mjs
COPY --from=build /app/docker/web/request-context.ts ./docker/web/request-context.ts
COPY --from=build /app/docker/web/static-assets.ts ./docker/web/static-assets.ts
+COPY --from=build /app/web/src/websockets ./web/src/websockets
COPY --from=build /app/docker/web/serve.mjs ./docker/web/serve.mjs
COPY --from=build /app/docker/web/healthcheck.mjs ./docker/web/healthcheck.mjs
diff --git a/docker/web/serve.mjs b/docker/web/serve.mjs
index 247299c..67dd930 100644
--- a/docker/web/serve.mjs
+++ b/docker/web/serve.mjs
@@ -1,5 +1,6 @@
import { createRuntimeFetch } from './request-context.ts'
import { createStaticAssetFetch } from './static-assets.ts'
+import { createLiveWebSocketRuntime } from '../../web/src/websockets/Server/realtimeWebSocket.ts'
import { fileURLToPath } from 'node:url'
const { default: application } = await import('../../web/dist/server/server.js')
@@ -13,12 +14,22 @@ if (!Number.isInteger(port) || port < 1 || port > 65_535 || !application?.fetch)
}
const runtimeFetch = createRuntimeFetch(application)
+const live = createLiveWebSocketRuntime({
+ allowedOrigin: process.env.RENTNERPROXY_PUBLIC_ORIGIN ?? 'http://localhost:5173',
+ readSnapshot: (request) => application.fetch(request),
+})
+const staticAssetFetch = createStaticAssetFetch(clientRoot, runtimeFetch)
const server = Bun.serve({
hostname,
port,
maxRequestBodySize: 12 * 1024 * 1024,
- fetch: createStaticAssetFetch(clientRoot, runtimeFetch),
+ websocket: live.websocket,
+ fetch: async (request, bunServer) => {
+ const result = await live.handle(request, bunServer)
+ if (result.handled) return result.response
+ return staticAssetFetch(request, bunServer)
+ },
})
let shuttingDown = false
@@ -31,6 +42,7 @@ async function shutdown() {
try {
const pending = []
process.emit('rentnerproxy:shutdown', pending)
+ pending.push(live.shutdown())
await Promise.all([server.stop(false), ...pending])
process.exitCode = 0
} finally {
diff --git a/web/src/db/index.ts b/web/src/db/index.ts
index 6e0e6ba..e077265 100644
--- a/web/src/db/index.ts
+++ b/web/src/db/index.ts
@@ -4,5 +4,14 @@ import { validateDatabaseEnvironment } from '../server/env.server'
import * as schema from './schema'
const { DATABASE_URL } = validateDatabaseEnvironment()
-const client = new SQL(DATABASE_URL)
+const developmentGlobal = globalThis as typeof globalThis & {
+ rentnerproxyDatabaseClient?: { url: string; client: SQL }
+}
+const cached =
+ process.env.NODE_ENV === 'production' ? undefined : developmentGlobal.rentnerproxyDatabaseClient
+if (cached && cached.url !== DATABASE_URL) void cached.client.close().catch(() => undefined)
+const client = cached?.url === DATABASE_URL ? cached.client : new SQL(DATABASE_URL)
+if (process.env.NODE_ENV !== 'production') {
+ developmentGlobal.rentnerproxyDatabaseClient = { url: DATABASE_URL, client }
+}
export const db = drizzle(client, { schema })
diff --git a/web/src/features/Admin/AccessPolicyManagement/Hooks/useAccessPolicyManagementLogic.ts b/web/src/features/Admin/AccessPolicyManagement/Hooks/useAccessPolicyManagementLogic.ts
index a58fac7..5889a43 100644
--- a/web/src/features/Admin/AccessPolicyManagement/Hooks/useAccessPolicyManagementLogic.ts
+++ b/web/src/features/Admin/AccessPolicyManagement/Hooks/useAccessPolicyManagementLogic.ts
@@ -2,6 +2,7 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useMemo, useState } from 'react'
import { PERMISSIONS } from '../../../../config/permissions.config'
+import useLiveInvalidation from '../../../../shared/Live/useLiveInvalidation'
import useToast from '../../../../shared/Toast/Hooks/useToast'
import type { AccessPolicySummary } from '../../../../shared/Types/access-policies.types'
import { accessPolicyManagementQueryKeys } from '../queryKeys'
@@ -27,6 +28,7 @@ export default function useAccessPolicyManagementLogic({
const toast = useToast()
const queryClient = useQueryClient()
const permissionSet = useMemo(() => new Set(permissions), [permissions])
+ const canView = permissionSet.has(PERMISSIONS.ACCESS_POLICIES_VIEW)
const [showCreate, setShowCreate] = useState(false)
const [selectedPolicy, setSelectedPolicy] = useState(null)
const [deleteTarget, setDeleteTarget] = useState(null)
@@ -35,12 +37,21 @@ export default function useAccessPolicyManagementLogic({
const policiesQuery = useQuery({
queryKey: accessPolicyManagementQueryKeys.all,
queryFn: () => getAccessPoliciesHandler(),
+ enabled: canView,
})
const runtimeStatusQuery = useQuery({
queryKey: accessPolicyManagementQueryKeys.runtimeStatus,
queryFn: () => getAccessPolicyRuntimeStatusHandler(),
- refetchInterval: 15_000,
- refetchIntervalInBackground: false,
+ enabled: canView,
+ })
+ useLiveInvalidation({
+ topic: 'access-policies',
+ query: {},
+ enabled: canView,
+ queryKeys: [
+ accessPolicyManagementQueryKeys.all,
+ accessPolicyManagementQueryKeys.runtimeStatus,
+ ],
})
const invalidate = useCallback(async () => {
await Promise.all([
diff --git a/web/src/features/Admin/AuditLogs/Components/AuditLogsPageView.tsx b/web/src/features/Admin/AuditLogs/Components/AuditLogsPageView.tsx
index 82efa7e..ee9a877 100644
--- a/web/src/features/Admin/AuditLogs/Components/AuditLogsPageView.tsx
+++ b/web/src/features/Admin/AuditLogs/Components/AuditLogsPageView.tsx
@@ -40,6 +40,7 @@ export default function AuditLogsPageView({ logic: { state, handler } }: AuditLo
/>
) : (
-
- {t('admin.auditLogs.actions.refresh')}
-
- }
filterToggle={
{(resetButton) => (
-
-
+
{services.map((service, index) => (
diff --git a/web/src/features/FoundationStatus/Helpers/createFoundationStatusViewModel.ts b/web/src/features/FoundationStatus/Helpers/createFoundationStatusViewModel.ts
index 885810f..229b9e6 100644
--- a/web/src/features/FoundationStatus/Helpers/createFoundationStatusViewModel.ts
+++ b/web/src/features/FoundationStatus/Helpers/createFoundationStatusViewModel.ts
@@ -1,17 +1,20 @@
import type { Translate } from '../../../language/useTranslationStore'
import type { FoundationHealth } from '../../../shared/Types/health.types'
-import type { FoundationStatusViewModel } from '../Types/foundation-status.types'
+import type { FoundationStatusViewModel, LiveStatus } from '../Types/foundation-status.types'
export default function createFoundationStatusViewModel(
health: FoundationHealth,
t: Translate,
+ liveStatus: LiveStatus,
): FoundationStatusViewModel {
const controllerConnected = health.controller.state === 'connected'
const databaseConnected = health.database.state === 'connected'
const redisConnected = health.redis.state === 'connected'
+ const liveConnected = liveStatus === 'connected'
return {
controllerConnected,
+ liveStatus,
services: [
{
label: t('foundation.services.web.label'),
@@ -37,6 +40,18 @@ export default function createFoundationStatusViewModel(
value: t(redisConnected ? 'foundation.connected' : 'foundation.unavailable'),
tone: redisConnected ? 'positive' : 'warning',
},
+ {
+ label: t('foundation.services.websocket.label'),
+ detail: t('foundation.services.websocket.detail'),
+ value: t(
+ liveConnected
+ ? 'foundation.connected'
+ : liveStatus === 'connecting'
+ ? 'foundation.connecting'
+ : 'foundation.unavailable',
+ ),
+ tone: liveConnected ? 'positive' : 'warning',
+ },
],
}
}
diff --git a/web/src/features/FoundationStatus/Hooks/useFoundationStatusLogic.ts b/web/src/features/FoundationStatus/Hooks/useFoundationStatusLogic.ts
index f35da33..dc372e6 100644
--- a/web/src/features/FoundationStatus/Hooks/useFoundationStatusLogic.ts
+++ b/web/src/features/FoundationStatus/Hooks/useFoundationStatusLogic.ts
@@ -1,13 +1,32 @@
-import { useQuery } from '@tanstack/react-query'
+import { useCallback } from 'react'
+import { useQuery, useQueryClient } from '@tanstack/react-query'
+import useLiveQuery from '../../../shared/Live/useLiveQuery'
+import type { FoundationHealth } from '../../../shared/Types/health.types'
import { foundationStatusQueryKeys } from '../queryKeys'
import { getFoundationHealthHandler } from '../server'
export default function useFoundationStatusLogic() {
+ const queryClient = useQueryClient()
const healthQuery = useQuery({
queryKey: foundationStatusQueryKeys.all,
queryFn: () => getFoundationHealthHandler(),
- refetchInterval: 30_000,
+ })
+ const onLiveData = useCallback(
+ async (data: FoundationHealth) => {
+ await queryClient.cancelQueries({
+ queryKey: foundationStatusQueryKeys.all,
+ exact: true,
+ })
+ queryClient.setQueryData(foundationStatusQueryKeys.all, data)
+ },
+ [queryClient],
+ )
+ const liveStatus = useLiveQuery
({
+ topic: 'foundation',
+ query: {},
+ enabled: true,
+ onData: onLiveData,
})
return {
@@ -15,6 +34,7 @@ export default function useFoundationStatusLogic() {
data: healthQuery.data,
isError: healthQuery.isError,
isPending: healthQuery.isPending,
+ liveStatus,
},
handler: {
retry: () => {
diff --git a/web/src/features/FoundationStatus/Types/foundation-status.types.ts b/web/src/features/FoundationStatus/Types/foundation-status.types.ts
index ed98d29..3742254 100644
--- a/web/src/features/FoundationStatus/Types/foundation-status.types.ts
+++ b/web/src/features/FoundationStatus/Types/foundation-status.types.ts
@@ -3,8 +3,11 @@ import type { FoundationHealth } from '../../../shared/Types/health.types'
export interface FoundationStatusProps {
readonly health: FoundationHealth
readonly compact?: boolean
+ readonly liveStatus: LiveStatus
}
+export type LiveStatus = 'connecting' | 'connected' | 'disconnected' | 'inactive'
+
export interface ConnectionTraceProps {
readonly connected: boolean
}
@@ -20,6 +23,7 @@ export interface ServiceStatusProps {
export interface FoundationStatusViewModel {
readonly controllerConnected: boolean
+ readonly liveStatus: LiveStatus
readonly services: readonly ServiceStatusProps[]
}
diff --git a/web/src/features/FoundationStatus/index.tsx b/web/src/features/FoundationStatus/index.tsx
index b058d46..1596e54 100644
--- a/web/src/features/FoundationStatus/index.tsx
+++ b/web/src/features/FoundationStatus/index.tsx
@@ -37,7 +37,7 @@ export default function FoundationStatusPage() {
}
/>
) : (
-
+
)}
>
)
diff --git a/web/src/language/Locales/de.json b/web/src/language/Locales/de.json
index c378250..4a84bba 100644
--- a/web/src/language/Locales/de.json
+++ b/web/src/language/Locales/de.json
@@ -202,6 +202,7 @@
},
"running": "Läuft",
"connected": "Verbunden",
+ "connecting": "Verbindung wird hergestellt",
"unavailable": "Nicht verfügbar",
"local": "Systembasis · Lokal",
"status": "Systemstatus",
@@ -210,8 +211,8 @@
"serviceStatus": "Status der Systemdienste",
"footer": "Systemstatus · Verbindungsstatus serverseitig geprüft",
"connectionMap": "Verbindungsübersicht",
- "summary": "Vier Dienste. Eine Verwaltung.",
- "refreshDescription": "Jedes Ergebnis wird serverseitig geprüft und alle 30 Sekunden aktualisiert.",
+ "summary": "Fünf Dienste. Eine Verwaltung.",
+ "refreshDescription": "Der Dienststatus wird serverseitig geprüft und live aktualisiert, solange diese Seite geöffnet ist.",
"web": "Web",
"services": {
"web": {
@@ -229,6 +230,10 @@
"redis": {
"label": "Redis",
"detail": "Serverseitige Redis-Statusprüfung"
+ },
+ "websocket": {
+ "label": "WebSocket-Server",
+ "detail": "Nativer Bun-WebSocket-Server"
}
}
},
@@ -1568,7 +1573,6 @@
"allStatuses": "Alle Statuscodes"
},
"actions": {
- "apply": "Filter anwenden",
"refresh": "Aktualisieren",
"showDetails": "Anfragedetails anzeigen",
"hideDetails": "Anfragedetails ausblenden"
@@ -1628,8 +1632,8 @@
"filteredEmptyDescription": "Keine Audit-Ereignisse passen zu diesen Filtern."
},
"filters": {
- "actor": "Benutzer-ID des Akteurs",
- "actorPlaceholder": "UUID des Akteurs",
+ "actor": "Akteur",
+ "actorPlaceholder": "Alle Benutzer",
"action": "Aktion",
"allActions": "Alle Aktionen",
"resource": "Ressource",
@@ -1638,7 +1642,6 @@
"to": "Bis (UTC)"
},
"actions": {
- "apply": "Filter anwenden",
"refresh": "Aktualisieren",
"showDetails": "Ereignisdetails anzeigen",
"hideDetails": "Ereignisdetails ausblenden"
diff --git a/web/src/language/Locales/en.json b/web/src/language/Locales/en.json
index ba8cd47..3437e71 100644
--- a/web/src/language/Locales/en.json
+++ b/web/src/language/Locales/en.json
@@ -202,6 +202,7 @@
},
"running": "Running",
"connected": "Connected",
+ "connecting": "Connecting",
"unavailable": "Unavailable",
"local": "Foundation · Local",
"status": "Foundation status",
@@ -210,8 +211,8 @@
"serviceStatus": "Foundation service status",
"footer": "Foundation status · Connection state is server-verified",
"connectionMap": "Connection map",
- "summary": "Four services. One control path.",
- "refreshDescription": "Each result is checked through the server boundary and refreshed every 30 seconds.",
+ "summary": "Five services. One control path.",
+ "refreshDescription": "Service status is verified through the server boundary, with live updates while this page is open.",
"web": "Web",
"services": {
"web": {
@@ -229,6 +230,10 @@
"redis": {
"label": "Redis",
"detail": "Server-side Redis health check"
+ },
+ "websocket": {
+ "label": "WebSocket Server",
+ "detail": "Native Bun WebSocket server"
}
}
},
@@ -1568,7 +1573,6 @@
"allStatuses": "All statuses"
},
"actions": {
- "apply": "Apply filters",
"refresh": "Refresh",
"showDetails": "Show request details",
"hideDetails": "Hide request details"
@@ -1628,8 +1632,8 @@
"filteredEmptyDescription": "No audit events match these filters."
},
"filters": {
- "actor": "Actor user ID",
- "actorPlaceholder": "UUID of the actor",
+ "actor": "Actor",
+ "actorPlaceholder": "All users",
"action": "Action",
"allActions": "All actions",
"resource": "Resource",
@@ -1638,7 +1642,6 @@
"to": "To (UTC)"
},
"actions": {
- "apply": "Apply filters",
"refresh": "Refresh",
"showDetails": "Show event details",
"hideDetails": "Hide event details"
diff --git a/web/src/language/Locales/es.json b/web/src/language/Locales/es.json
index 1b9cec4..5cbcc90 100644
--- a/web/src/language/Locales/es.json
+++ b/web/src/language/Locales/es.json
@@ -202,6 +202,7 @@
},
"running": "En ejecución",
"connected": "Conectado",
+ "connecting": "Conectando",
"unavailable": "No disponible",
"local": "Servicios base · Local",
"status": "Estado de los servicios base",
@@ -210,8 +211,8 @@
"serviceStatus": "Estado de los servicios base",
"footer": "Estado de los servicios base · Conexiones verificadas por el servidor",
"connectionMap": "Mapa de conexiones",
- "summary": "Cuatro servicios. Un punto de control.",
- "refreshDescription": "Cada resultado se verifica desde el servidor y se actualiza cada 30 segundos.",
+ "summary": "Cinco servicios. Un punto de control.",
+ "refreshDescription": "El estado de los servicios se verifica desde el servidor y se actualiza en directo mientras esta página está abierta.",
"web": "Web",
"services": {
"web": {
@@ -229,6 +230,10 @@
"redis": {
"label": "Redis",
"detail": "Comprobación de Redis desde el servidor"
+ },
+ "websocket": {
+ "label": "Servidor WebSocket",
+ "detail": "Servidor WebSocket nativo de Bun"
}
}
},
@@ -1568,7 +1573,6 @@
"allStatuses": "Todos los estados"
},
"actions": {
- "apply": "Aplicar filtros",
"refresh": "Actualizar",
"showDetails": "Mostrar detalles de la solicitud",
"hideDetails": "Ocultar detalles de la solicitud"
@@ -1628,8 +1632,8 @@
"filteredEmptyDescription": "Ningún evento de auditorÃa coincide con estos filtros."
},
"filters": {
- "actor": "ID de usuario del actor",
- "actorPlaceholder": "UUID del actor",
+ "actor": "Actor",
+ "actorPlaceholder": "Todos los usuarios",
"action": "Acción",
"allActions": "Todas las acciones",
"resource": "Recurso",
@@ -1638,7 +1642,6 @@
"to": "Hasta (UTC)"
},
"actions": {
- "apply": "Aplicar filtros",
"refresh": "Actualizar",
"showDetails": "Mostrar detalles del evento",
"hideDetails": "Ocultar detalles del evento"
diff --git a/web/src/language/Locales/fr.json b/web/src/language/Locales/fr.json
index 2331fa1..f11c988 100644
--- a/web/src/language/Locales/fr.json
+++ b/web/src/language/Locales/fr.json
@@ -202,6 +202,7 @@
},
"running": "En cours d’exécution",
"connected": "Connecté",
+ "connecting": "Connexion en cours",
"unavailable": "Indisponible",
"local": "Services de base · Local",
"status": "État des services de base",
@@ -210,8 +211,8 @@
"serviceStatus": "État des services de base",
"footer": "État des services de base · Connexions vérifiées côté serveur",
"connectionMap": "Carte des connexions",
- "summary": "Quatre services. Un point de contrôle.",
- "refreshDescription": "Chaque résultat est vérifié côté serveur et actualisé toutes les 30 secondes.",
+ "summary": "Cinq services. Un point de contrôle.",
+ "refreshDescription": "L’état des services est vérifié côté serveur et mis à jour en direct tant que cette page est ouverte.",
"web": "Web",
"services": {
"web": {
@@ -229,6 +230,10 @@
"redis": {
"label": "Redis",
"detail": "Vérification de Redis côté serveur"
+ },
+ "websocket": {
+ "label": "Serveur WebSocket",
+ "detail": "Serveur WebSocket Bun natif"
}
}
},
@@ -1568,7 +1573,6 @@
"allStatuses": "Tous les statuts"
},
"actions": {
- "apply": "Appliquer les filtres",
"refresh": "Actualiser",
"showDetails": "Afficher les détails de la requête",
"hideDetails": "Masquer les détails de la requête"
@@ -1628,8 +1632,8 @@
"filteredEmptyDescription": "Aucun événement d’audit ne correspond à ces filtres."
},
"filters": {
- "actor": "ID utilisateur de l’acteur",
- "actorPlaceholder": "UUID de l’acteur",
+ "actor": "Acteur",
+ "actorPlaceholder": "Tous les utilisateurs",
"action": "Action",
"allActions": "Toutes les actions",
"resource": "Ressource",
@@ -1638,7 +1642,6 @@
"to": "Au (UTC)"
},
"actions": {
- "apply": "Appliquer les filtres",
"refresh": "Actualiser",
"showDetails": "Afficher les détails de l’événement",
"hideDetails": "Masquer les détails de l’événement"
diff --git a/web/src/layout/Components/AuthenticatedRouteLayout.tsx b/web/src/layout/Components/AuthenticatedRouteLayout.tsx
index 57bfd91..48b280e 100644
--- a/web/src/layout/Components/AuthenticatedRouteLayout.tsx
+++ b/web/src/layout/Components/AuthenticatedRouteLayout.tsx
@@ -4,9 +4,11 @@ import AuthenticatedShell from './ApplicationShell'
import ThemeModeSwitch from './Theme'
import useThemeModeLogic from './Theme/Hooks/useThemeModeLogic'
import useLogoutLogic from '../../features/Auth/Session/Hooks/useLogoutLogic'
+import useApplicationLiveSync from '../../shared/Live/useApplicationLiveSync'
import type { AuthenticatedRouteLayoutProps } from '../Types/authenticated-route-layout.types'
export default function AuthenticatedRouteLayout({ user }: AuthenticatedRouteLayoutProps) {
+ useApplicationLiveSync()
const { handler, state } = useLogoutLogic()
const theme = useThemeModeLogic(user.themeMode)
diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts
index 2767bf9..7280c94 100644
--- a/web/src/routeTree.gen.ts
+++ b/web/src/routeTree.gen.ts
@@ -26,6 +26,7 @@ import { Route as PublicForgotPasswordRouteImport } from './routes/_public/forgo
import { Route as PublicLoginRouteImport } from './routes/_public/login'
import { Route as PublicResetPasswordRouteImport } from './routes/_public/reset-password'
import { Route as PublicSetupRouteImport } from './routes/_public/setup'
+import { Route as ApiLiveSnapshotRouteImport } from './routes/api/live-snapshot'
import { Route as HealthLiveRouteImport } from './routes/health/live'
import { Route as HealthReadyRouteImport } from './routes/health/ready'
import { Route as PublicLoginIndexRouteImport } from './routes/_public/login.index'
@@ -119,6 +120,11 @@ const PublicSetupRoute = PublicSetupRouteImport.update({
path: '/setup',
getParentRoute: () => PublicRouteRoute,
} as any)
+const ApiLiveSnapshotRoute = ApiLiveSnapshotRouteImport.update({
+ id: '/api/live-snapshot',
+ path: '/api/live-snapshot',
+ getParentRoute: () => rootRouteImport,
+} as any)
const HealthLiveRoute = HealthLiveRouteImport.update({
id: '/health/live',
path: '/health/live',
@@ -161,6 +167,7 @@ export interface FileRoutesByFullPath {
'/login': typeof PublicLoginRouteWithChildren
'/reset-password': typeof PublicResetPasswordRoute
'/setup': typeof PublicSetupRoute
+ '/api/live-snapshot': typeof ApiLiveSnapshotRoute
'/health/live': typeof HealthLiveRoute
'/health/ready': typeof HealthReadyRoute
'/login/two-factor': typeof PublicLoginTwoFactorRoute
@@ -182,6 +189,7 @@ export interface FileRoutesByTo {
'/forgot-password': typeof PublicForgotPasswordRoute
'/reset-password': typeof PublicResetPasswordRoute
'/setup': typeof PublicSetupRoute
+ '/api/live-snapshot': typeof ApiLiveSnapshotRoute
'/health/live': typeof HealthLiveRoute
'/health/ready': typeof HealthReadyRoute
'/login/two-factor': typeof PublicLoginTwoFactorRoute
@@ -206,6 +214,7 @@ export interface FileRoutesById {
'/_public/login': typeof PublicLoginRouteWithChildren
'/_public/reset-password': typeof PublicResetPasswordRoute
'/_public/setup': typeof PublicSetupRoute
+ '/api/live-snapshot': typeof ApiLiveSnapshotRoute
'/health/live': typeof HealthLiveRoute
'/health/ready': typeof HealthReadyRoute
'/_authenticated/': typeof AuthenticatedIndexRoute
@@ -231,6 +240,7 @@ export interface FileRouteTypes {
| '/login'
| '/reset-password'
| '/setup'
+ | '/api/live-snapshot'
| '/health/live'
| '/health/ready'
| '/login/two-factor'
@@ -252,6 +262,7 @@ export interface FileRouteTypes {
| '/forgot-password'
| '/reset-password'
| '/setup'
+ | '/api/live-snapshot'
| '/health/live'
| '/health/ready'
| '/login/two-factor'
@@ -275,6 +286,7 @@ export interface FileRouteTypes {
| '/_public/login'
| '/_public/reset-password'
| '/_public/setup'
+ | '/api/live-snapshot'
| '/health/live'
| '/health/ready'
| '/_authenticated/'
@@ -286,6 +298,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
AuthenticatedRouteRoute: typeof AuthenticatedRouteRouteWithChildren
PublicRouteRoute: typeof PublicRouteRouteWithChildren
+ ApiLiveSnapshotRoute: typeof ApiLiveSnapshotRoute
HealthLiveRoute: typeof HealthLiveRoute
HealthReadyRoute: typeof HealthReadyRoute
MediaAvatarsUserIdRoute: typeof MediaAvatarsUserIdRoute
@@ -412,6 +425,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof PublicSetupRouteImport
parentRoute: typeof PublicRouteRoute
}
+ '/api/live-snapshot': {
+ id: '/api/live-snapshot'
+ path: '/api/live-snapshot'
+ fullPath: '/api/live-snapshot'
+ preLoaderRoute: typeof ApiLiveSnapshotRouteImport
+ parentRoute: typeof rootRouteImport
+ }
'/health/live': {
id: '/health/live'
path: '/health/live'
@@ -516,6 +536,7 @@ const PublicRouteRouteWithChildren = PublicRouteRoute._addFileChildren(
const rootRouteChildren: RootRouteChildren = {
AuthenticatedRouteRoute: AuthenticatedRouteRouteWithChildren,
PublicRouteRoute: PublicRouteRouteWithChildren,
+ ApiLiveSnapshotRoute: ApiLiveSnapshotRoute,
HealthLiveRoute: HealthLiveRoute,
HealthReadyRoute: HealthReadyRoute,
MediaAvatarsUserIdRoute: MediaAvatarsUserIdRoute,
diff --git a/web/src/routes/api/live-snapshot.ts b/web/src/routes/api/live-snapshot.ts
new file mode 100644
index 0000000..71389d4
--- /dev/null
+++ b/web/src/routes/api/live-snapshot.ts
@@ -0,0 +1,12 @@
+import { createFileRoute } from '@tanstack/react-router'
+import { createServerOnlyFn } from '@tanstack/react-start'
+
+import { getLiveSnapshotResponse } from '../../websockets/Server/realtimeSnapshots.service'
+
+const readSnapshot = createServerOnlyFn(getLiveSnapshotResponse)
+
+export const Route = createFileRoute('/api/live-snapshot')({
+ server: {
+ handlers: { GET: ({ request }) => readSnapshot(request) },
+ },
+})
diff --git a/web/src/server/Audit/audit-reader.service.ts b/web/src/server/Audit/audit-reader.service.ts
index 267cd86..a6c7b67 100644
--- a/web/src/server/Audit/audit-reader.service.ts
+++ b/web/src/server/Audit/audit-reader.service.ts
@@ -1,11 +1,12 @@
import '@tanstack/react-start/server-only'
-import { and, desc, eq, gte, lte, lt, or, sql } from 'drizzle-orm'
+import { and, asc, desc, eq, gte, lte, lt, or, sql } from 'drizzle-orm'
import { PERMISSIONS } from '../../config/permissions.config'
import { auditEvents, users } from '../../db/schema'
import type {
AuditEventDto,
+ AuditActorOption,
AuditEventsQuery,
AuditEventsResult,
AuditMetadata,
@@ -113,3 +114,12 @@ export async function listAuditEventsService(input: AuditEventsQuery): Promise {
+ await requirePermissionService(PERMISSIONS.AUDIT_LOGS_VIEW)
+ const rows = await getAuthDatabase()
+ .select({ id: users.id, displayName: users.displayName })
+ .from(users)
+ .orderBy(asc(users.displayName), asc(users.id))
+ return rows
+}
diff --git a/web/src/shared/Live/useApplicationLiveSync.ts b/web/src/shared/Live/useApplicationLiveSync.ts
new file mode 100644
index 0000000..beb298d
--- /dev/null
+++ b/web/src/shared/Live/useApplicationLiveSync.ts
@@ -0,0 +1,98 @@
+import { useCallback, useRef } from 'react'
+import { useQueryClient, type QueryClient } from '@tanstack/react-query'
+import { useRouter } from '@tanstack/react-router'
+
+import { auditLogsQueryKeys } from '../../features/Admin/AuditLogs/queryKeys'
+import { proxyAccessLogsQueryKeys } from '../../features/Admin/ProxyAccessLogs/queryKeys'
+import { foundationStatusQueryKeys } from '../../features/FoundationStatus/queryKeys'
+import useLiveQuery, { type LiveStatus } from './useLiveQuery'
+
+export interface ApplicationLiveSnapshot {
+ readonly revision: string
+ readonly userVersion: string
+}
+
+const snapshotsByClient = new WeakMap()
+
+const ownWebSocketQueryPrefixes = [
+ [...proxyAccessLogsQueryKeys.all, 'list'],
+ [...auditLogsQueryKeys.all, 'list'],
+ foundationStatusQueryKeys.all,
+] as ReadonlyArray>
+
+function isApplicationLiveSnapshot(value: unknown): value is ApplicationLiveSnapshot {
+ if (typeof value !== 'object' || value === null) return false
+ const snapshot = value as Partial
+ return typeof snapshot.revision === 'string' && typeof snapshot.userVersion === 'string'
+}
+
+function hasPrefix(queryKey: ReadonlyArray, prefix: ReadonlyArray): boolean {
+ return prefix.every((part, index) => queryKey[index] === part)
+}
+
+function isOwnedByWebSocket(queryKey: ReadonlyArray): boolean {
+ return ownWebSocketQueryPrefixes.some((prefix) => hasPrefix(queryKey, prefix))
+}
+
+async function refreshApplicationQueries(queryClient: QueryClient): Promise {
+ await queryClient.invalidateQueries({
+ predicate: (query) => !isOwnedByWebSocket(query.queryKey),
+ refetchType: 'active',
+ })
+}
+
+export default function useApplicationLiveSync(): LiveStatus {
+ const queryClient = useQueryClient()
+ const router = useRouter()
+ const syncGeneration = useRef(0)
+
+ const handleSnapshot = useCallback(
+ (snapshot: ApplicationLiveSnapshot) => {
+ if (!isApplicationLiveSnapshot(snapshot)) return
+
+ const previous = snapshotsByClient.get(queryClient)
+ if (
+ previous?.revision === snapshot.revision &&
+ previous.userVersion === snapshot.userVersion
+ ) {
+ return
+ }
+ snapshotsByClient.set(queryClient, snapshot)
+ const generation = ++syncGeneration.current
+
+ void (async () => {
+ if (previous) await router.invalidate()
+ if (generation !== syncGeneration.current) return
+ await refreshApplicationQueries(queryClient)
+ })().catch(() => undefined)
+ },
+ [queryClient, router, syncGeneration],
+ )
+
+ const handleUnauthorized = useCallback(() => {
+ syncGeneration.current += 1
+ void (async () => {
+ await queryClient.cancelQueries()
+ queryClient.clear()
+ await router.invalidate()
+ })().catch(() => undefined)
+ }, [queryClient, router, syncGeneration])
+
+ const handleResume = useCallback(() => {
+ const generation = ++syncGeneration.current
+ void (async () => {
+ await router.invalidate()
+ if (generation !== syncGeneration.current) return
+ await refreshApplicationQueries(queryClient)
+ })().catch(() => undefined)
+ }, [queryClient, router, syncGeneration])
+
+ return useLiveQuery({
+ topic: 'app-events',
+ query: {},
+ enabled: true,
+ onData: handleSnapshot,
+ onUnauthorized: handleUnauthorized,
+ onResume: handleResume,
+ })
+}
diff --git a/web/src/shared/Live/useLiveInvalidation.ts b/web/src/shared/Live/useLiveInvalidation.ts
new file mode 100644
index 0000000..918cf77
--- /dev/null
+++ b/web/src/shared/Live/useLiveInvalidation.ts
@@ -0,0 +1,50 @@
+import { useCallback, useRef } from 'react'
+import { useQueryClient, type QueryKey } from '@tanstack/react-query'
+
+import useLiveQuery from './useLiveQuery'
+
+export type LiveInvalidationTopic =
+ | 'proxy-hosts'
+ | 'certificates'
+ | 'redirect-hosts'
+ | 'access-policies'
+
+export interface LiveRevisionSnapshot {
+ readonly revision: string
+}
+
+interface UseLiveInvalidationOptions {
+ readonly topic: LiveInvalidationTopic
+ readonly query: object
+ readonly enabled: boolean
+ readonly queryKeys: readonly QueryKey[]
+}
+
+export default function useLiveInvalidation({
+ topic,
+ query,
+ enabled,
+ queryKeys,
+}: UseLiveInvalidationOptions): ReturnType {
+ const queryClient = useQueryClient()
+ const revisionRef = useRef(undefined)
+ const onData = useCallback(
+ (snapshot: LiveRevisionSnapshot) => {
+ if (!snapshot || typeof snapshot.revision !== 'string') return
+ const previousRevision = revisionRef.current
+ revisionRef.current = snapshot.revision
+ if (previousRevision === snapshot.revision) return
+ void Promise.all(
+ queryKeys.map((queryKey) => queryClient.invalidateQueries({ queryKey })),
+ )
+ },
+ [queryClient, queryKeys],
+ )
+
+ return useLiveQuery({
+ topic,
+ query,
+ enabled,
+ onData,
+ })
+}
diff --git a/web/src/shared/Live/useLiveQuery.ts b/web/src/shared/Live/useLiveQuery.ts
new file mode 100644
index 0000000..c3450eb
--- /dev/null
+++ b/web/src/shared/Live/useLiveQuery.ts
@@ -0,0 +1,60 @@
+import { useEffect, useRef, useState } from 'react'
+
+import {
+ subscribeToRealtimeEvents,
+ type LiveStatus,
+} from '../../websockets/Client/realtimeEventsClient'
+import type { LiveTopic } from '../../websockets/Types/events'
+
+export type { LiveStatus, LiveTopic }
+
+export default function useLiveQuery({
+ topic,
+ query,
+ enabled,
+ onData,
+ onUnauthorized,
+ onResume,
+}: {
+ topic: LiveTopic
+ query: object
+ enabled: boolean
+ onData: (data: T) => void
+ onUnauthorized?: () => void
+ onResume?: () => void
+}): LiveStatus {
+ const [status, setStatus] = useState('inactive')
+ const dataCallback = useRef(onData)
+ const unauthorizedCallback = useRef(onUnauthorized)
+ const resumeCallback = useRef(onResume)
+ useEffect(() => {
+ dataCallback.current = onData
+ }, [onData])
+ useEffect(() => {
+ unauthorizedCallback.current = onUnauthorized
+ }, [onUnauthorized])
+ useEffect(() => {
+ resumeCallback.current = onResume
+ }, [onResume])
+ const serializedQuery = JSON.stringify(query)
+
+ useEffect(() => {
+ if (
+ !enabled ||
+ typeof window === 'undefined' ||
+ !/^https?:$/.test(window.location.protocol)
+ ) {
+ return
+ }
+ return subscribeToRealtimeEvents({
+ topic,
+ query: JSON.parse(serializedQuery) as object,
+ onData: (data) => dataCallback.current(data),
+ onStatus: setStatus,
+ onUnauthorized: () => unauthorizedCallback.current?.(),
+ onResume: () => resumeCallback.current?.(),
+ })
+ }, [enabled, topic, serializedQuery])
+
+ return enabled ? status : 'inactive'
+}
diff --git a/web/src/shared/Table/Components/TableColumnFilters.tsx b/web/src/shared/Table/Components/TableColumnFilters.tsx
index a3a6aff..bb76a2e 100644
--- a/web/src/shared/Table/Components/TableColumnFilters.tsx
+++ b/web/src/shared/Table/Components/TableColumnFilters.tsx
@@ -43,7 +43,7 @@ export default function TableColumnFilters({
)
- if (!resetButton || index !== headers.length - 1) return field
+ if (index !== headers.length - 1) return field
const desktopSpan = ['xl:col-span-3', 'xl:col-span-2', 'xl:col-span-1'][
index % 3
diff --git a/web/src/shared/Types/audit-events.types.ts b/web/src/shared/Types/audit-events.types.ts
index 8e5d221..523aebe 100644
--- a/web/src/shared/Types/audit-events.types.ts
+++ b/web/src/shared/Types/audit-events.types.ts
@@ -142,6 +142,11 @@ export interface AuditEventDto extends AuditEventInput {
readonly metadata: AuditMetadata
}
+export interface AuditActorOption {
+ readonly id: string
+ readonly displayName: string
+}
+
export interface AuditEventsQuery {
readonly actorUserId?: string | undefined
readonly action?: AuditAction | undefined
diff --git a/web/src/start.ts b/web/src/start.ts
index fad8d29..52dd933 100644
--- a/web/src/start.ts
+++ b/web/src/start.ts
@@ -4,9 +4,11 @@ import {
createServerOnlyFn,
createStart,
} from '@tanstack/react-start'
-import { getRequestProtocol, setResponseHeaders } from '@tanstack/react-start/server'
+import { getRequest, getRequestProtocol, setResponseHeaders } from '@tanstack/react-start/server'
import { getTrustProxyHeaders, validateProductionEnvironment } from './server/env.server'
+import { publishApplicationChange } from './websockets/Helpers/publishFunctions'
+import { startRealtimeRedis } from './websockets/Server/realtimeRedis.service'
import {
applyAdminUiSecurityHeaders,
createCspNonce,
@@ -19,6 +21,9 @@ const validateProductionEnvironmentAtStartup = createServerOnlyFn(() => {
if (typeof window === 'undefined') validateProductionEnvironmentAtStartup()
+const startRealtimeEvents = createServerOnlyFn(startRealtimeRedis)
+if (typeof window === 'undefined') startRealtimeEvents()
+
const startProxyRuntimeLifecycle = createServerOnlyFn(async () => {
let stop: (() => Promise
) | null = null
const initializing = import('./server/ProxyRuntime/proxy-runtime.service').then(
@@ -90,6 +95,14 @@ const csrfMiddleware = createCsrfMiddleware({
filter: (context) => context.handlerType === 'serverFn',
})
+const liveChangesMiddleware = createMiddleware().server(async ({ next, handlerType }) => {
+ const result = await next()
+ if (handlerType === 'serverFn' && getRequest().method === 'POST' && result.response.ok) {
+ publishApplicationChange()
+ }
+ return result
+})
+
export const startInstance = createStart(() => ({
- requestMiddleware: [securityHeadersMiddleware, csrfMiddleware],
+ requestMiddleware: [securityHeadersMiddleware, csrfMiddleware, liveChangesMiddleware],
}))
diff --git a/web/src/tests/application-live-sync.test.tsx b/web/src/tests/application-live-sync.test.tsx
new file mode 100644
index 0000000..e238738
--- /dev/null
+++ b/web/src/tests/application-live-sync.test.tsx
@@ -0,0 +1,186 @@
+import { afterEach, beforeEach, expect, spyOn, test } from 'bun:test'
+import { GlobalRegistrator } from '@happy-dom/global-registrator'
+import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
+import {
+ createMemoryHistory,
+ createRootRoute,
+ createRouter,
+ RouterContextProvider,
+} from '@tanstack/react-router'
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+
+import { auditLogsQueryKeys } from '../features/Admin/AuditLogs/queryKeys'
+import { proxyAccessLogsQueryKeys } from '../features/Admin/ProxyAccessLogs/queryKeys'
+import { foundationStatusQueryKeys } from '../features/FoundationStatus/queryKeys'
+import useApplicationLiveSync from '../shared/Live/useApplicationLiveSync'
+
+if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register()
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+
+class FakeSocket extends EventTarget {
+ static instances: FakeSocket[] = []
+ static OPEN = 1
+ static CLOSED = 3
+ readyState = FakeSocket.OPEN
+ closed = false
+
+ constructor(readonly url: URL) {
+ super()
+ FakeSocket.instances.push(this)
+ }
+
+ close() {
+ this.closed = true
+ this.readyState = FakeSocket.CLOSED
+ this.dispatchEvent(new Event('close'))
+ }
+
+ deliver(data: unknown, query: object = {}) {
+ this.dispatchEvent(
+ new MessageEvent('message', {
+ data: JSON.stringify({
+ type: 'snapshot',
+ topic: 'app-events',
+ query,
+ payload: data,
+ }),
+ }),
+ )
+ }
+
+ deliverUnauthorized() {
+ this.dispatchEvent(
+ new MessageEvent('message', { data: JSON.stringify({ type: 'unauthorized' }) }),
+ )
+ }
+}
+
+const originalSocket = globalThis.WebSocket
+let root: Root
+let queryClient: QueryClient
+const router = createRouter({
+ routeTree: createRootRoute(),
+ history: createMemoryHistory({ initialEntries: ['/'] }),
+})
+let invalidateSpy: ReturnType | undefined
+let container: HTMLDivElement
+let activeFetches: number
+
+function Probe() {
+ useApplicationLiveSync()
+ useQuery({
+ queryKey: ['application-live', 'active'],
+ queryFn: async () => {
+ activeFetches += 1
+ return activeFetches
+ },
+ })
+ return null
+}
+
+async function flush() {
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+}
+
+beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: new URL('http://localhost:5173/'),
+ })
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ globalThis.WebSocket = FakeSocket as unknown as typeof WebSocket
+ FakeSocket.instances = []
+ activeFetches = 0
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0 } },
+ })
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ queryClient.clear()
+ invalidateSpy?.mockRestore()
+ invalidateSpy = undefined
+ container.remove()
+ globalThis.WebSocket = originalSocket
+ Reflect.deleteProperty(document, 'visibilityState')
+})
+
+test('refreshes active queries and marks inactive queries stale when the application changes', async () => {
+ const invalidate = spyOn(router, 'invalidate').mockResolvedValue(undefined)
+ invalidateSpy = invalidate
+ queryClient.setQueryData(['application-live', 'inactive'], 'stale')
+ queryClient.setQueryData(proxyAccessLogsQueryKeys.list({ offset: 0 }), 'websocket-owned')
+ queryClient.setQueryData(auditLogsQueryKeys.list({}), 'websocket-owned')
+ queryClient.setQueryData(auditLogsQueryKeys.actors(), 'http-owned')
+ queryClient.setQueryData(foundationStatusQueryKeys.all, 'websocket-owned')
+
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ )
+ })
+ const socket = FakeSocket.instances[0]!
+ await act(async () => socket.deliver({ revision: 'r1', userVersion: 'u1' }))
+ await flush()
+ expect(activeFetches).toBe(2)
+ expect(queryClient.getQueryState(['application-live', 'inactive'])?.isInvalidated).toBe(true)
+ expect(
+ queryClient.getQueryCache().findAll({ queryKey: proxyAccessLogsQueryKeys.all })[0]?.state
+ .isInvalidated,
+ ).toBe(false)
+ expect(
+ queryClient.getQueryCache().findAll({ queryKey: auditLogsQueryKeys.all })[0]?.state
+ .isInvalidated,
+ ).toBe(false)
+ expect(queryClient.getQueryState(auditLogsQueryKeys.actors())?.isInvalidated).toBe(true)
+ expect(queryClient.getQueryState(foundationStatusQueryKeys.all)?.isInvalidated).toBe(false)
+ expect(invalidate).not.toHaveBeenCalled()
+
+ await act(async () => socket.deliver({ revision: 'r1', userVersion: 'u1' }))
+ await flush()
+ expect(activeFetches).toBe(2)
+ expect(invalidate).not.toHaveBeenCalled()
+
+ await act(async () => socket.deliver({ revision: 'r2', userVersion: 'u1' }))
+ await flush()
+ expect(invalidate).toHaveBeenCalledTimes(1)
+ expect(activeFetches).toBe(3)
+
+ await act(async () => socket.deliver({ revision: 'r2', userVersion: 'u2' }))
+ await flush()
+ expect(invalidate).toHaveBeenCalledTimes(2)
+})
+
+test('clears queries and revalidates the router when the live connection is unauthorized', async () => {
+ const invalidate = spyOn(router, 'invalidate').mockResolvedValue(undefined)
+ invalidateSpy = invalidate
+ queryClient.setQueryData(['application-live', 'cached'], 'value')
+
+ await act(async () => {
+ root.render(
+
+
+
+
+ ,
+ )
+ })
+ const socket = FakeSocket.instances[0]!
+ await act(async () => socket.deliverUnauthorized())
+ await flush()
+
+ expect(queryClient.getQueryCache().getAll()).toHaveLength(0)
+ expect(invalidate).toHaveBeenCalledTimes(1)
+})
diff --git a/web/src/tests/audit-events-permissions.test.ts b/web/src/tests/audit-events-permissions.test.ts
index 10353ec..770c303 100644
--- a/web/src/tests/audit-events-permissions.test.ts
+++ b/web/src/tests/audit-events-permissions.test.ts
@@ -4,6 +4,7 @@ import { fileURLToPath } from 'node:url'
const serviceScript = `
import { mock } from 'bun:test'
let queries = 0
+ let actorQueries = 0
let denied = true
mock.module('./server/Auth/Access/authorization.service.ts', () => ({
requirePermissionService: async () => {
@@ -11,8 +12,14 @@ const serviceScript = `
return { id: '0198d98a-0000-7000-8000-000000000001' }
},
}))
- mock.module('./db/index.ts', () => ({
- db: { transaction: async (work) => {
+ mock.module('./db/index.ts', () => {
+ const actorQuery = {
+ from() { return actorQuery },
+ orderBy() { return Promise.resolve([
+ { id: '0198d98a-0000-7000-8000-000000000001', displayName: 'Alice Admin' },
+ ]) },
+ }
+ return { db: { transaction: async (work) => {
queries += 1
const query = {
from() { return query },
@@ -26,16 +33,24 @@ const serviceScript = `
delete: () => ({ where: async () => [] }),
select: () => query,
})
- } },
- }))
- const { listAuditEventsService } = await import('./server/Audit/audit-reader.service.ts')
+ }, select: () => {
+ actorQueries += 1
+ return actorQuery
+ } } }
+ })
+ const { listAuditActorOptionsService, listAuditEventsService } = await import('./server/Audit/audit-reader.service.ts')
const deniedResult = await listAuditEventsService({}).then(
() => 'resolved', (error) => error instanceof Error ? error.message : String(error),
)
const deniedQueries = queries
+ const deniedActorResult = await listAuditActorOptionsService().then(
+ () => 'resolved', (error) => error instanceof Error ? error.message : String(error),
+ )
+ const deniedActorQueries = actorQueries
denied = false
const authorizedResult = await listAuditEventsService({})
- console.log(JSON.stringify({ deniedResult, deniedQueries, authorizedResult, queries }))
+ const actorOptions = await listAuditActorOptionsService()
+ console.log(JSON.stringify({ deniedResult, deniedQueries, deniedActorResult, deniedActorQueries, actorOptions, authorizedResult, queries, actorQueries }))
`
describe('audit viewer authorization', () => {
@@ -54,12 +69,22 @@ describe('audit viewer authorization', () => {
const output = JSON.parse(stdout) as {
readonly deniedResult: string
readonly deniedQueries: number
+ readonly deniedActorResult: string
+ readonly deniedActorQueries: number
+ readonly actorOptions: readonly { readonly id: string; readonly displayName: string }[]
readonly authorizedResult: { readonly events: readonly unknown[] }
readonly queries: number
+ readonly actorQueries: number
}
expect(output.deniedResult).toBe('permission denied')
expect(output.deniedQueries).toBe(0)
+ expect(output.deniedActorResult).toBe('permission denied')
+ expect(output.deniedActorQueries).toBe(0)
expect(output.queries).toBe(2)
+ expect(output.actorQueries).toBe(1)
+ expect(output.actorOptions).toEqual([
+ { id: '0198d98a-0000-7000-8000-000000000001', displayName: 'Alice Admin' },
+ ])
expect(output.authorizedResult.events).toEqual([])
})
})
diff --git a/web/src/tests/audit-logs-ui.test.tsx b/web/src/tests/audit-logs-ui.test.tsx
index d123fcb..0821d8e 100644
--- a/web/src/tests/audit-logs-ui.test.tsx
+++ b/web/src/tests/audit-logs-ui.test.tsx
@@ -20,6 +20,7 @@ const { createRoot } = await import('react-dom/client')
const { TooltipProvider } = await import('../shared/Tooltip')
const actorId = '018f2f52-7c1b-7cc0-9f3c-6a9952c54021'
+const secondActorId = '018f2f52-7c1b-7cc0-9f3c-6a9952c54025'
const targetId = '018f2f52-7c1b-7cc0-9f3c-6a9952c54022'
const nextCursor = 'eyJ0aW1lc3RhbXAiOiIyMDI2LTA5LTEyVDEwOjAwOjAwLjAwMFoiLCJpZCI6IjAxOGYifQ'
@@ -63,8 +64,13 @@ const getAuditEventsHandlerMock = mock(
}
},
)
+const getAuditActorOptionsHandlerMock = mock(async () => [
+ { id: actorId, displayName: 'Alice Admin' },
+ { id: secondActorId, displayName: 'Bob Viewer' },
+])
mock.module('../features/Admin/AuditLogs/server', () => ({
+ getAuditActorOptionsHandler: getAuditActorOptionsHandlerMock,
getAuditEventsHandler: getAuditEventsHandlerMock,
}))
@@ -105,16 +111,6 @@ async function click(element: Element): Promise {
})
}
-async function setInputValue(input: HTMLInputElement, value: string): Promise {
- const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set
- await act(async () => {
- setter?.call(input, value)
- input.dispatchEvent(new Event('input', { bubbles: true }))
- input.dispatchEvent(new Event('change', { bubbles: true }))
- await Promise.resolve()
- })
-}
-
async function chooseSelectOption(
container: HTMLElement,
ariaLabel: string,
@@ -218,6 +214,7 @@ function expectFilterTogglePlacement(toggle: HTMLButtonElement, panel: HTMLEleme
beforeEach(() => {
getAuditEventsHandlerMock.mockClear()
+ getAuditActorOptionsHandlerMock.mockClear()
})
afterEach(() => {
@@ -252,7 +249,7 @@ describe('audit log UI', () => {
})
})
- test('applies filters only on submit and navigates keyset pages', async () => {
+ test('applies valid filters automatically and navigates keyset pages', async () => {
const container = await renderPage([PERMISSIONS.AUDIT_LOGS_VIEW])
await waitFor(() => getAuditEventsHandlerMock.mock.calls.length === 1)
await waitFor(() => container.textContent?.includes('Alice Admin') === true)
@@ -262,15 +259,14 @@ describe('audit log UI', () => {
})
expect(container.textContent).toContain('Update')
expect(container.textContent).toContain('Success')
+ expect(
+ [...container.querySelectorAll('button')].some(
+ (button) => button.textContent?.trim() === 'Refresh',
+ ),
+ ).toBeFalse()
- const actorInput = container.querySelector(
- 'input[placeholder="UUID of the actor"]',
- )
- expect(actorInput).not.toBeNull()
- await setInputValue(actorInput!, actorId)
- expect(getAuditEventsHandlerMock).toHaveBeenCalledTimes(1)
-
- await click(getButton(container, 'Apply filters'))
+ await click(getButton(container, 'Filters'))
+ await chooseSelectOption(container, 'Actor', 'Alice Admin')
await waitFor(() => getAuditEventsHandlerMock.mock.calls.length === 2)
expect(getAuditEventsHandlerMock.mock.calls[1]?.[0]).toEqual({
data: { actorUserId: actorId, limit: 100 },
@@ -300,7 +296,7 @@ describe('audit log UI', () => {
})
})
- test('keeps draft filters when collapsed and resets them from the shared control', async () => {
+ test('keeps filters when collapsed and resets them from the shared control', async () => {
const container = await renderPage([PERMISSIONS.AUDIT_LOGS_VIEW])
await waitFor(() => container.textContent?.includes('Alice Admin') === true)
@@ -308,20 +304,20 @@ describe('audit log UI', () => {
const filterPanel = getFilterPanel(container, filtersButton)
expectFilterTogglePlacement(filtersButton, filterPanel)
expect(filtersButton.getAttribute('aria-expanded')).toBe('false')
- const actorInput = container.querySelector(
- 'input[placeholder="UUID of the actor"]',
- )
- expect(actorInput).not.toBeNull()
-
await click(filtersButton)
expect(filtersButton.getAttribute('aria-expanded')).toBe('true')
- await setInputValue(actorInput!, actorId)
+ await chooseSelectOption(container, 'Actor', 'Alice Admin')
await click(filtersButton)
expect(filtersButton.getAttribute('aria-expanded')).toBe('false')
- expect(actorInput?.value).toBe(actorId)
await click(getButton(container, 'Reset filters'))
- expect(actorInput?.value).toBe('')
+ await waitFor(() => getAuditEventsHandlerMock.mock.calls.length === 3)
+ expect(getAuditEventsHandlerMock.mock.calls[2]?.[0]).toEqual({
+ data: { limit: 100 },
+ })
+ expect(container.querySelector('button[aria-label="Actor"]')?.textContent).toContain(
+ 'All users',
+ )
expect(getButton(container, 'Filters').getAttribute('aria-label')).toBe('Filters')
})
@@ -333,10 +329,9 @@ describe('audit log UI', () => {
await click(getButton(container, 'Filters'))
await chooseSelectOption(container, 'Action', 'Update')
await chooseSelectOption(container, 'Resource', 'Proxy host')
- await click(getButton(container, 'Apply filters'))
- await waitFor(() => getAuditEventsHandlerMock.mock.calls.length === 2)
+ await waitFor(() => getAuditEventsHandlerMock.mock.calls.length === 3)
- expect(getAuditEventsHandlerMock.mock.calls[1]?.[0]).toEqual({
+ expect(getAuditEventsHandlerMock.mock.calls[2]?.[0]).toEqual({
data: { action: 'update', limit: 100, resource: 'proxy-host' },
})
})
diff --git a/web/src/tests/certificates-ui.test.tsx b/web/src/tests/certificates-ui.test.tsx
index 13a72c5..c48f04e 100644
--- a/web/src/tests/certificates-ui.test.tsx
+++ b/web/src/tests/certificates-ui.test.tsx
@@ -296,6 +296,49 @@ afterEach(async () => {
})
describe('certificate management UI', () => {
+ test('filters exact certificate names with search and expiry dates with the calendar', async () => {
+ const now = new Date()
+ const date = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-15`
+ getCertificatesHandlerMock.mockResolvedValueOnce([
+ { ...certificate, expiresAt: new Date(`${date}T23:59:59Z`) },
+ {
+ ...certificate,
+ id: 'second-certificate',
+ name: 'Public edge backup',
+ expiresAt: null,
+ },
+ {
+ ...certificate,
+ id: 'later-certificate',
+ name: 'Later expiry',
+ expiresAt: new Date(new Date(`${date}T00:00:00Z`).getTime() + 86_400_000),
+ },
+ ])
+ await renderPage([PERMISSIONS.CERTIFICATES_VIEW])
+ const table = document.querySelector('table')!
+ const rows = () => table.querySelectorAll('tbody tr')
+ await waitFor(() => document.body.textContent?.includes('Public edge backup') === true)
+ await click(button('Filters'))
+ await click(button('Filter Name…'))
+ const search = document.querySelector('input[aria-label="Filter Name…"]')!
+ expect(document.activeElement).toBe(search)
+ await setValue(search, 'Public edge')
+ const option = [...document.querySelectorAll('[role="option"]')].find(
+ (item) => item.textContent?.trim() === 'Public edge',
+ )!
+ await click(option)
+ await waitFor(() => rows().length === 1)
+ expect(rows()[0]?.textContent).not.toContain('backup')
+ await click(button('Reset filters'))
+ await waitFor(() => rows().length === 3)
+ await click(button('Filter by date range'))
+ await click(document.querySelector(`[data-calendar-date="${date}"]`)!)
+ await click(document.querySelector(`[data-calendar-date="${date}"]`)!)
+ await waitFor(() => rows().length === 1)
+ expect(rows()[0]?.textContent).toContain('Public edge')
+ expect(rows()[0]?.textContent).not.toContain('backup')
+ })
+
test('shows certificate metadata to viewers without mutation actions', async () => {
await renderPage([PERMISSIONS.CERTIFICATES_VIEW])
await waitFor(() => document.body.textContent?.includes('Public edge') === true)
diff --git a/web/src/tests/foundation-live-cache.test.tsx b/web/src/tests/foundation-live-cache.test.tsx
new file mode 100644
index 0000000..5e7a667
--- /dev/null
+++ b/web/src/tests/foundation-live-cache.test.tsx
@@ -0,0 +1,135 @@
+import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
+import { GlobalRegistrator } from '@happy-dom/global-registrator'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+
+import type { FoundationHealth } from '../shared/Types/health.types'
+import { foundationStatusQueryKeys } from '../features/FoundationStatus/queryKeys'
+import withTestLanguage from './Helpers/withTestLanguage'
+
+if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register()
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+
+const freshHealth: FoundationHealth = {
+ controller: { state: 'connected' },
+ database: { state: 'connected' },
+ redis: { state: 'connected' },
+}
+const staleHealth: FoundationHealth = {
+ controller: { state: 'unavailable' },
+ database: { state: 'unavailable' },
+ redis: { state: 'unavailable' },
+}
+
+let resolveHealth: ((health: FoundationHealth) => void) | undefined
+const getFoundationHealthHandlerMock = mock(
+ () => new Promise((resolve) => (resolveHealth = resolve)),
+)
+
+mock.module('../features/FoundationStatus/server', () => ({
+ getFoundationHealthHandler: getFoundationHealthHandlerMock,
+}))
+
+const { default: FoundationStatusPage } = await import('../features/FoundationStatus')
+
+class FakeSocket {
+ static readonly OPEN = 1
+ static instances: FakeSocket[] = []
+ readonly readyState = 0
+ private readonly listeners = new Set<(event: { data: string }) => void>()
+
+ constructor(readonly url: URL) {
+ FakeSocket.instances.push(this)
+ }
+
+ close() {
+ this.listeners.clear()
+ }
+
+ addEventListener(type: string, listener: (event: { data: string }) => void) {
+ if (type === 'message') this.listeners.add(listener)
+ }
+
+ deliver(data: unknown) {
+ const event = {
+ data: JSON.stringify({
+ type: 'snapshot',
+ topic: 'foundation',
+ query: {},
+ payload: data,
+ }),
+ }
+ this.listeners.forEach((listener) => listener(event))
+ }
+}
+
+const originalSocket = globalThis.WebSocket
+let root: Root | null = null
+let queryClient: QueryClient | null = null
+
+async function flush() {
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+}
+
+beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: new URL('http://localhost:5173/'),
+ })
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ globalThis.WebSocket = FakeSocket as unknown as typeof WebSocket
+ FakeSocket.instances = []
+ resolveHealth = undefined
+ getFoundationHealthHandlerMock.mockClear()
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ })
+ const container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root?.unmount())
+ queryClient?.clear()
+ root = null
+ queryClient = null
+ document.body.replaceChildren()
+ globalThis.WebSocket = originalSocket
+ Reflect.deleteProperty(document, 'visibilityState')
+})
+
+test('keeps a live foundation snapshot when the initial HTTP response arrives late', async () => {
+ await act(async () => {
+ root?.render(
+ withTestLanguage(
+
+
+ ,
+ ),
+ )
+ })
+ await flush()
+ expect(getFoundationHealthHandlerMock).toHaveBeenCalledTimes(1)
+ const socket = FakeSocket.instances[0]
+ expect(socket).toBeDefined()
+ const client = queryClient!
+
+ await act(async () => socket?.deliver(freshHealth))
+ await flush()
+ expect(client.getQueryData(foundationStatusQueryKeys.all)).toEqual(
+ freshHealth,
+ )
+ await act(async () => resolveHealth?.(staleHealth))
+ await flush()
+ expect(client.getQueryData(foundationStatusQueryKeys.all)).toEqual(
+ freshHealth,
+ )
+})
diff --git a/web/src/tests/live-invalidation.test.tsx b/web/src/tests/live-invalidation.test.tsx
new file mode 100644
index 0000000..4f8aa76
--- /dev/null
+++ b/web/src/tests/live-invalidation.test.tsx
@@ -0,0 +1,113 @@
+import { afterEach, beforeEach, expect, test } from 'bun:test'
+import { GlobalRegistrator } from '@happy-dom/global-registrator'
+import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query'
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+
+import useLiveInvalidation from '../shared/Live/useLiveInvalidation'
+
+if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register()
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+
+class FakeSocket extends EventTarget {
+ static instances: FakeSocket[] = []
+ static OPEN = 1
+ static CLOSED = 3
+ readyState = FakeSocket.OPEN
+
+ constructor(readonly url: URL) {
+ super()
+ FakeSocket.instances.push(this)
+ }
+
+ close() {
+ this.readyState = FakeSocket.CLOSED
+ this.dispatchEvent(new Event('close'))
+ }
+
+ deliver(data: unknown, query: object = {}) {
+ this.dispatchEvent(
+ new MessageEvent('message', {
+ data: JSON.stringify({
+ type: 'snapshot',
+ topic: 'proxy-hosts',
+ query,
+ payload: data,
+ }),
+ }),
+ )
+ }
+}
+
+const originalSocket = globalThis.WebSocket
+let root: Root
+let queryClient: QueryClient
+let fetches: number
+
+function Probe() {
+ useLiveInvalidation({
+ topic: 'proxy-hosts',
+ query: {},
+ enabled: true,
+ queryKeys: [['live-invalidation', 'target']],
+ })
+ useQuery({
+ queryKey: ['live-invalidation', 'target'],
+ queryFn: async () => ++fetches,
+ })
+ return null
+}
+
+async function flush() {
+ await act(async () => {
+ await Promise.resolve()
+ await Promise.resolve()
+ })
+}
+
+beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: new URL('http://localhost:5173/'),
+ })
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ globalThis.WebSocket = FakeSocket as unknown as typeof WebSocket
+ FakeSocket.instances = []
+ fetches = 0
+ queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, staleTime: 0 } },
+ })
+ root = createRoot(document.createElement('div'))
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ queryClient.clear()
+ globalThis.WebSocket = originalSocket
+ Reflect.deleteProperty(document, 'visibilityState')
+})
+
+test('invalidates polled queries once for each new live revision', async () => {
+ await act(async () => {
+ root.render(
+
+
+ ,
+ )
+ })
+ const socket = FakeSocket.instances[0]!
+ await flush()
+ expect(fetches).toBe(1)
+
+ await act(async () => socket.deliver({ revision: 'r1' }))
+ await flush()
+ expect(fetches).toBe(2)
+
+ await act(async () => socket.deliver({ revision: 'r1' }))
+ await flush()
+ expect(fetches).toBe(2)
+
+ await act(async () => socket.deliver({ revision: 'r2' }))
+ await flush()
+ expect(fetches).toBe(3)
+})
diff --git a/web/src/tests/live-query-ui.test.tsx b/web/src/tests/live-query-ui.test.tsx
new file mode 100644
index 0000000..2a0dcee
--- /dev/null
+++ b/web/src/tests/live-query-ui.test.tsx
@@ -0,0 +1,148 @@
+import { afterEach, beforeEach, expect, test } from 'bun:test'
+import { GlobalRegistrator } from '@happy-dom/global-registrator'
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+
+import useLiveQuery from '../shared/Live/useLiveQuery'
+
+if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register()
+Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true })
+
+class FakeSocket extends EventTarget {
+ static instances: FakeSocket[] = []
+ static OPEN = 1
+ static CLOSED = 3
+ readyState = FakeSocket.OPEN
+ sent: string[] = []
+ closed = false
+ constructor(readonly url: URL) {
+ super()
+ FakeSocket.instances.push(this)
+ }
+ send(value: string) {
+ this.sent.push(value)
+ }
+ open() {
+ this.dispatchEvent(new Event('open'))
+ }
+ close() {
+ this.closed = true
+ this.readyState = FakeSocket.CLOSED
+ this.dispatchEvent(new Event('close'))
+ }
+ deliver(data: unknown, query: object = {}) {
+ this.dispatchEvent(
+ new MessageEvent('message', {
+ data: JSON.stringify({
+ type: 'snapshot',
+ topic: 'access-logs',
+ query,
+ payload: data,
+ }),
+ }),
+ )
+ }
+}
+
+const originalSocket = globalThis.WebSocket
+let root: Root
+let container: HTMLDivElement
+let received: unknown[]
+function Probe({ enabled = true, search = '' }: { enabled?: boolean; search?: string }) {
+ const status = useLiveQuery({
+ topic: 'access-logs',
+ query: { search },
+ enabled,
+ onData: (data) => {
+ received.push(data)
+ },
+ })
+ return {status}
+}
+
+beforeEach(() => {
+ window.location.href = 'http://localhost:5173/proxy-access-logs'
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ globalThis.WebSocket = FakeSocket as unknown as typeof WebSocket
+ FakeSocket.instances = []
+ received = []
+ container = document.createElement('div')
+ document.body.append(container)
+ root = createRoot(container)
+})
+
+afterEach(async () => {
+ await act(async () => root.unmount())
+ container.remove()
+ globalThis.WebSocket = originalSocket
+ Reflect.deleteProperty(document, 'visibilityState')
+})
+
+test('connects only when enabled and receives snapshots without HTTP polling', async () => {
+ await act(async () => root.render())
+ expect(FakeSocket.instances).toHaveLength(0)
+ await act(async () => root.render())
+ const socket = FakeSocket.instances[0]!
+ socket.open()
+ expect(socket.url.origin).toBe('ws://localhost:5173')
+ expect(socket.url.pathname).toBe('/api/live')
+ await act(async () => socket.deliver({ entries: ['new'] }, { search: '' }))
+ expect(received).toEqual([{ entries: ['new'] }])
+ expect(container.textContent).toBe('connected')
+ await act(async () => root.render())
+ expect(socket.closed).toBe(true)
+ socket.deliver({ entries: ['late'] })
+ expect(received).toHaveLength(1)
+})
+
+test('closes hidden pages, opens a fresh subscription on return and cleans up navigation', async () => {
+ await act(async () => root.render())
+ const first = FakeSocket.instances[0]!
+ first.open()
+ await act(async () => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' })
+ document.dispatchEvent(new Event('visibilitychange'))
+ })
+ expect(first.closed).toBe(true)
+ expect(container.textContent).toBe('inactive')
+ first.deliver('late', { search: '' })
+ expect(received).toHaveLength(0)
+ await act(async () => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ document.dispatchEvent(new Event('visibilitychange'))
+ })
+ expect(FakeSocket.instances).toHaveLength(2)
+ await act(async () => root.render(another page))
+ expect(FakeSocket.instances[1]!.closed).toBe(true)
+ await act(async () => document.dispatchEvent(new Event('visibilitychange')))
+ expect(FakeSocket.instances).toHaveLength(2)
+})
+
+test('replaces filters and rejects stale messages from the old subscription', async () => {
+ await act(async () => root.render())
+ const first = FakeSocket.instances[0]!
+ first.open()
+ await act(async () => root.render())
+ expect(first.closed).toBe(true)
+ const second = FakeSocket.instances[1]!
+ second.open()
+ expect(JSON.parse(second.sent[0]!)).toEqual({
+ type: 'subscribe',
+ topic: 'access-logs',
+ query: { search: 'ts-laser' },
+ })
+ await act(async () => {
+ first.deliver('old', { search: 'first' })
+ second.deliver('current', { search: 'ts-laser' })
+ })
+ expect(received).toEqual(['current'])
+})
+
+test('unmount cancels a pending reconnect', async () => {
+ await act(async () => root.render())
+ await act(async () => FakeSocket.instances[0]!.close())
+ expect(container.textContent).toBe('disconnected')
+ await act(async () => root.render(left))
+ await new Promise((resolve) => setTimeout(resolve, 1100))
+ expect(FakeSocket.instances).toHaveLength(1)
+})
diff --git a/web/src/tests/live-snapshot.test.ts b/web/src/tests/live-snapshot.test.ts
new file mode 100644
index 0000000..6534356
--- /dev/null
+++ b/web/src/tests/live-snapshot.test.ts
@@ -0,0 +1,111 @@
+import { expect, test } from 'bun:test'
+import { fileURLToPath } from 'node:url'
+
+test('live snapshots enforce canonical origin, query limits and current permissions', async () => {
+ const script = `
+ import { mock } from 'bun:test'
+ import { AuthDomainError } from './server/Auth/Core/errors.server.ts'
+ let authorized = true
+ let reads = 0
+ let permissionChecks = 0
+ mock.module('./server/env.server.ts', () => ({ getPublicOrigin: () => 'https://proxy.example' }))
+ mock.module('./websockets/Helpers/publishFunctions.ts', () => ({ getApplicationRevision: () => 'revision' }))
+ mock.module('./server/Auth/Access/authorization.service.ts', () => ({
+ requirePermissionService: async () => {
+ permissionChecks++
+ if (!authorized) throw new AuthDomainError('permission_denied', 'Denied')
+ return { id: 'current-user', permissions: ['app.access'] }
+ },
+ }))
+ mock.module('./server/Foundation/health.service.ts', () => ({ checkFoundationHealth: async () => { reads++; return { database: { state: 'connected' } } } }))
+ mock.module('./server/Admin/ProxyAccessLogs/proxy-access-logs.service.ts', () => ({ getProxyAccessLogsService: async (query) => { reads++; return { query, entries: [] } } }))
+ mock.module('./server/Audit/audit-reader.service.ts', () => ({ listAuditEventsService: async () => { reads++; return { events: [] } } }))
+ mock.module('./server/Admin/ProxyHostManagement/proxy-hosts.service.ts', () => ({ getProxyHostsService: async () => [] }))
+ mock.module('./server/Admin/CertificateManagement/certificates.service.ts', () => ({ getCertificatesService: async () => [] }))
+ mock.module('./server/Admin/RedirectHostManagement/redirect-hosts.service.ts', () => ({ getRedirectHostsService: async () => [] }))
+ mock.module('./server/Admin/AccessPolicyManagement/access-policies.service.ts', () => ({ getAccessPoliciesService: async () => [] }))
+ mock.module('./server/ProxyRuntime/proxy-runtime.service.ts', () => ({ getProxyRuntimeStatusService: async () => ({ state: 'synced' }) }))
+ const { getLiveSnapshotResponse } = await import('./websockets/Server/realtimeSnapshots.service.ts')
+ async function read(topic, query = '{}', origin = 'https://proxy.example') {
+ const url = new URL('https://proxy.example/api/live-snapshot')
+ url.searchParams.set('topic', topic)
+ url.searchParams.set('query', query)
+ return getLiveSnapshotResponse(new Request(url, { headers: origin ? { origin } : {} }))
+ }
+ const statuses = []
+ statuses.push((await read('foundation', '{}', '')).status)
+ statuses.push((await read('foundation', '{}', 'https://attacker.example')).status)
+ statuses.push((await read('unknown')).status)
+ statuses.push((await read('access-logs', '{')).status)
+ statuses.push((await read('access-logs', JSON.stringify({ offset: 15 }))).status)
+ statuses.push((await read('access-logs', JSON.stringify({ status: 999 }))).status)
+ statuses.push((await read('audit-logs', JSON.stringify({ actorUserId: 'invalid' }))).status)
+ statuses.push((await read('foundation', ' '.repeat(4097))).status)
+ const rejectedReads = reads
+ const response = await read('foundation')
+ const cacheControl = response.headers.get('cache-control')
+ statuses.push(response.status)
+ const application = await (await read('app-events')).json()
+ authorized = false
+ statuses.push((await read('foundation')).status)
+ statuses.push((await read('app-events')).status)
+ console.log(JSON.stringify({ statuses, rejectedReads, reads, permissionChecks, cacheControl, application }))
+ `
+ const child = Bun.spawn([process.execPath, '-e', script], {
+ cwd: fileURLToPath(new URL('../', import.meta.url)),
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ const [stdout, stderr, code] = await Promise.all([
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ child.exited,
+ ])
+ expect(code, stderr).toBe(0)
+ const result = JSON.parse(stdout)
+ expect(result.statuses).toEqual([403, 403, 400, 400, 400, 400, 400, 400, 200, 403, 403])
+ expect(result.rejectedReads).toBe(0)
+ expect(result.reads).toBe(1)
+ expect(result.permissionChecks).toBe(4)
+ expect(result.cacheControl).toBe('private, no-store')
+ expect(Object.keys(result.application).toSorted()).toEqual(['revision', 'userVersion'])
+ expect(result.application.userVersion).toMatch(/^[a-f0-9]{64}$/)
+})
+
+test('application events publish immediately, deduplicate Redis echoes and clean up subscribers', async () => {
+ const script = `
+ const { getApplicationRevision, publishApplicationChange, subscribeToApplicationChanges, receiveApplicationChange, setApplicationPublisher } = await import('./websockets/Helpers/publishFunctions.ts')
+ let deliveries = 0
+ let published
+ const unsubscribe = subscribeToApplicationChanges(() => deliveries++)
+ const initial = getApplicationRevision()
+ setApplicationPublisher(async event => { published = event })
+ publishApplicationChange()
+ const changed = getApplicationRevision()
+ receiveApplicationChange(published)
+ const afterEcho = deliveries
+ receiveApplicationChange({ type: 'application.updated', payload: { version: 'remote-write' } })
+ const remote = getApplicationRevision()
+ unsubscribe()
+ setApplicationPublisher(async () => { throw new Error('offline') })
+ publishApplicationChange()
+ await new Promise(resolve => setTimeout(resolve, 0))
+ console.log(JSON.stringify({ initial, changed, afterEcho, deliveries, remote }))
+ `
+ const child = Bun.spawn([process.execPath, '-e', script], {
+ cwd: fileURLToPath(new URL('../', import.meta.url)),
+ stdout: 'pipe',
+ stderr: 'pipe',
+ })
+ const [stdout, stderr, code] = await Promise.all([
+ new Response(child.stdout).text(),
+ new Response(child.stderr).text(),
+ child.exited,
+ ])
+ expect(code, stderr).toBe(0)
+ const result = JSON.parse(stdout)
+ expect(result.changed).not.toBe(result.initial)
+ expect(result.afterEcho).toBe(1)
+ expect(result.deliveries).toBe(2)
+ expect(result.remote).toBe('remote-write')
+})
diff --git a/web/src/tests/live-transport.test.ts b/web/src/tests/live-transport.test.ts
new file mode 100644
index 0000000..f012e7d
--- /dev/null
+++ b/web/src/tests/live-transport.test.ts
@@ -0,0 +1,231 @@
+import { describe, expect, test } from 'bun:test'
+
+import { createLiveWebSocketRuntime } from '../websockets/Server/realtimeWebSocket'
+import { publishApplicationChange } from '../websockets/Helpers/publishFunctions'
+import type { LiveSocketData as RuntimeSocketData } from '../websockets/Types/bun'
+
+type SnapshotReader = (request: Request) => Response | Promise
+
+function startRuntime(readSnapshot: SnapshotReader) {
+ const options = {
+ allowedOrigin: 'http://allowed.test',
+ readSnapshot: (request: Request) => readSnapshot(request),
+ }
+ const runtime = createLiveWebSocketRuntime({
+ ...options,
+ })
+ const server = Bun.serve({
+ hostname: '127.0.0.1',
+ port: 0,
+ websocket: runtime.websocket,
+ fetch: async (request, bunServer) => {
+ const result = await runtime.handle(request, bunServer)
+ if (result.handled) return result.response
+ return new Response(null, { status: 404 })
+ },
+ })
+ return { runtime, server }
+}
+
+function openSocket(url: URL): WebSocket {
+ const Constructor = globalThis.WebSocket as unknown as new (
+ url: string,
+ options?: { headers?: Record },
+ ) => WebSocket
+ return new Constructor(url.toString(), { headers: { Origin: 'http://allowed.test' } })
+}
+
+function nextMessage(socket: WebSocket, timeoutMs = 1_000): Promise {
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ socket.removeEventListener('message', onMessage)
+ reject(new Error('Timed out waiting for a WebSocket message.'))
+ }, timeoutMs)
+ const onMessage = (event: MessageEvent) => {
+ clearTimeout(timer)
+ socket.removeEventListener('message', onMessage)
+ resolve(JSON.parse(String(event.data)) as unknown)
+ }
+ socket.addEventListener('message', onMessage)
+ })
+}
+
+function socketOpened(socket: WebSocket): Promise {
+ return new Promise((resolve, reject) => {
+ socket.addEventListener('open', () => resolve(), { once: true })
+ socket.addEventListener('error', () => reject(new Error('WebSocket failed to open.')), {
+ once: true,
+ })
+ })
+}
+
+function socketClosed(socket: WebSocket): Promise {
+ return new Promise((resolve) => {
+ socket.addEventListener('close', (event) => resolve(event), { once: true })
+ })
+}
+
+describe('live WebSocket transport', () => {
+ test('rejects cross-origin upgrades before reading a snapshot', async () => {
+ let reads = 0
+ const runtime = createLiveWebSocketRuntime({
+ allowedOrigin: 'http://allowed.test',
+ readSnapshot: () => {
+ reads += 1
+ return Response.json({ value: 1 })
+ },
+ })
+ let upgrades = 0
+ const request = new Request('http://localhost/api/live', {
+ headers: { upgrade: 'websocket', origin: 'http://evil.test' },
+ })
+ const result = await runtime.handle(request, {
+ upgrade: () => {
+ upgrades += 1
+ return false
+ },
+ } as unknown as Bun.Server)
+
+ expect(result.response?.status).toBe(403)
+ expect(reads).toBe(0)
+ expect(upgrades).toBe(0)
+ await runtime.shutdown()
+ })
+
+ test('rejects an unauthorized initial snapshot', async () => {
+ const runtime = createLiveWebSocketRuntime({
+ allowedOrigin: 'http://allowed.test',
+ readSnapshot: () => new Response(null, { status: 401 }),
+ })
+ const result = await runtime.handle(
+ new Request('http://localhost/api/live', {
+ headers: { upgrade: 'websocket', origin: 'http://allowed.test' },
+ }),
+ { upgrade: () => true } as unknown as Bun.Server,
+ )
+
+ expect(result.response?.status).toBe(401)
+ expect(runtime.connectionCount).toBe(0)
+ await runtime.shutdown()
+ })
+
+ test('releases a handshake reservation when the snapshot reader hangs', async () => {
+ const runtime = createLiveWebSocketRuntime({
+ allowedOrigin: 'http://allowed.test',
+ snapshotTimeoutMs: 5,
+ readSnapshot: () => new Promise(() => undefined),
+ })
+ const result = await runtime.handle(
+ new Request('http://localhost/api/live', {
+ headers: { upgrade: 'websocket', origin: 'http://allowed.test' },
+ }),
+ { upgrade: () => true } as unknown as Bun.Server,
+ )
+
+ expect(result.response?.status).toBe(503)
+ expect(runtime.connectionCount).toBe(0)
+ await runtime.shutdown()
+ })
+
+ test('sends changed snapshots, ignores volatile fields, and stops sampling after close', async () => {
+ const snapshots = [
+ { revision: 'auth' },
+ { revision: '1', snapshot: 'token', snapshotExpiresAt: 'first' },
+ { revision: '1', snapshot: 'token', snapshotExpiresAt: 'second' },
+ { revision: '2', snapshot: 'token-2', snapshotExpiresAt: 'third' },
+ ]
+ let reads = 0
+ const { runtime, server } = startRuntime(() => {
+ const snapshot = snapshots[Math.min(reads, snapshots.length - 1)]!
+ reads += 1
+ return Response.json(snapshot)
+ })
+ const socket = openSocket(new URL('/api/live', server.url))
+
+ try {
+ await socketOpened(socket)
+ socket.send(JSON.stringify({ type: 'subscribe', topic: 'app-events', query: {} }))
+ await expect(nextMessage(socket)).resolves.toEqual({
+ type: 'snapshot',
+ topic: 'app-events',
+ query: {},
+ payload: { revision: '1', snapshot: 'token', snapshotExpiresAt: 'first' },
+ })
+ publishApplicationChange()
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ publishApplicationChange()
+ await expect(nextMessage(socket)).resolves.toEqual({
+ type: 'snapshot',
+ topic: 'app-events',
+ query: {},
+ payload: { revision: '2', snapshot: 'token-2', snapshotExpiresAt: 'third' },
+ })
+ socket.close()
+ await socketClosed(socket)
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ const readsAtClose = reads
+ publishApplicationChange()
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ expect(reads).toBe(readsAtClose)
+ expect(runtime.connectionCount).toBe(0)
+ } finally {
+ if (socket.readyState !== WebSocket.CLOSED) socket.close()
+ await runtime.shutdown()
+ await server.stop(true)
+ }
+ })
+
+ test('sends unauthorized and closes when authorization is revoked', async () => {
+ let reads = 0
+ const runtime = createLiveWebSocketRuntime({
+ allowedOrigin: 'http://allowed.test',
+ readSnapshot: () => {
+ reads += 1
+ return reads < 3
+ ? Response.json({ revision: String(reads) })
+ : new Response(null, { status: 403 })
+ },
+ })
+ let upgradedData: RuntimeSocketData | undefined
+ const result = await runtime.handle(
+ new Request('http://localhost/api/live', {
+ headers: { upgrade: 'websocket', origin: 'http://allowed.test' },
+ }),
+ {
+ upgrade: (_request: Request, options: { data?: RuntimeSocketData }) => {
+ upgradedData = options?.data
+ return true
+ },
+ } as unknown as Bun.Server,
+ )
+ expect(result.handled).toBe(true)
+ const messages: string[] = []
+ let closeCode: number | undefined
+ const socket = {
+ data: upgradedData!,
+ sendText(message: string) {
+ messages.push(message)
+ return message.length
+ },
+ close(code: number) {
+ closeCode = code
+ },
+ } as unknown as Bun.ServerWebSocket
+
+ try {
+ runtime.websocket.open?.(socket)
+ runtime.websocket.message?.(
+ socket,
+ JSON.stringify({ type: 'subscribe', topic: 'app-events', query: {} }),
+ )
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ publishApplicationChange()
+ await new Promise((resolve) => setTimeout(resolve, 5))
+ expect(messages.at(-1)).toBe(JSON.stringify({ type: 'unauthorized' }))
+ expect(closeCode).toBe(4001)
+ expect(runtime.connectionCount).toBe(0)
+ } finally {
+ await runtime.shutdown()
+ }
+ })
+})
diff --git a/web/src/tests/proxy-access-logs-ui.test.tsx b/web/src/tests/proxy-access-logs-ui.test.tsx
index cede5be..53a9aed 100644
--- a/web/src/tests/proxy-access-logs-ui.test.tsx
+++ b/web/src/tests/proxy-access-logs-ui.test.tsx
@@ -240,7 +240,7 @@ afterEach(async () => {
})
describe('proxy access-log UI', () => {
- test('applies filters only on submit, paginates, and refreshes the first page', async () => {
+ test('automatically applies host filters and paginates', async () => {
const container = await renderPage([PERMISSIONS.PROXY_ACCESS_LOGS_VIEW])
await waitFor(() => getProxyAccessLogsHandlerMock.mock.calls.length === 1)
await waitFor(() => container.textContent?.includes('/dashboard') === true)
@@ -248,11 +248,10 @@ describe('proxy access-log UI', () => {
data: { limit: 15, offset: 0 },
})
expect(container.textContent).toContain('/dashboard')
+ expect(container.textContent).not.toContain('Apply filters')
await chooseHost(container, 'app.example', 'app.example.com')
- expect(getProxyAccessLogsHandlerMock).toHaveBeenCalledTimes(1)
- await click(getButton(container, 'Apply filters'))
await waitFor(() => getProxyAccessLogsHandlerMock.mock.calls.length === 2)
expect(getProxyAccessLogsHandlerMock.mock.calls[1]?.[0]).toEqual({
data: { host: 'app.example.com', limit: 15, offset: 0 },
@@ -279,12 +278,11 @@ describe('proxy access-log UI', () => {
})
expect(container.textContent).toContain('/older')
- await click(getButton(container, 'Refresh'))
- await waitFor(() => getProxyAccessLogsHandlerMock.mock.calls.length === 4)
- expect(getProxyAccessLogsHandlerMock.mock.calls[3]?.[0]).toEqual({
- data: { host: 'app.example.com', limit: 15, offset: 0 },
- })
- expect(container.textContent).toContain('/dashboard')
+ expect(
+ [...container.querySelectorAll('button')].some(
+ (button) => button.textContent?.trim() === 'Refresh',
+ ),
+ ).toBeFalse()
})
test('changes page size, uses numbered pages, and preserves the snapshot', async () => {
@@ -354,7 +352,7 @@ describe('proxy access-log UI', () => {
await waitFor(() => container.textContent?.includes('Page 2 of 7') === true)
})
- test('keeps draft filters when collapsed and resets them from the shared control', async () => {
+ test('keeps automatic filters when collapsed and resets them from the shared control', async () => {
const container = await renderPage([PERMISSIONS.PROXY_ACCESS_LOGS_VIEW])
await waitFor(() => container.textContent?.includes('/dashboard') === true)
@@ -376,15 +374,17 @@ describe('proxy access-log UI', () => {
expect(getButton(container, 'Filters').getAttribute('aria-label')).toBe('Filters')
})
- test('selects a status from the available status options', async () => {
+ test('automatically applies status changes from a later page using a fresh snapshot', async () => {
const container = await renderPage([PERMISSIONS.PROXY_ACCESS_LOGS_VIEW])
await waitFor(() => container.textContent?.includes('/dashboard') === true)
- await chooseSelectOption('Status', '404')
- await click(getButton(container, 'Apply filters'))
+ await click(getButton(container, 'Go to next page'))
await waitFor(() => getProxyAccessLogsHandlerMock.mock.calls.length === 2)
+ await waitFor(() => container.textContent?.includes('/older') === true)
+ await chooseSelectOption('Status', '404')
+ await waitFor(() => getProxyAccessLogsHandlerMock.mock.calls.length === 3)
- expect(getProxyAccessLogsHandlerMock.mock.calls[1]?.[0]).toEqual({
+ expect(getProxyAccessLogsHandlerMock.mock.calls[2]?.[0]).toEqual({
data: { limit: 15, offset: 0, status: 404 },
})
})
diff --git a/web/src/tests/realtime-events-client.test.ts b/web/src/tests/realtime-events-client.test.ts
new file mode 100644
index 0000000..91cc415
--- /dev/null
+++ b/web/src/tests/realtime-events-client.test.ts
@@ -0,0 +1,244 @@
+import { afterEach, beforeEach, expect, test } from 'bun:test'
+import { GlobalRegistrator } from '@happy-dom/global-registrator'
+
+import { subscribeToRealtimeEvents } from '../websockets/Client/realtimeEventsClient'
+
+if (!GlobalRegistrator.isRegistered) GlobalRegistrator.register()
+
+class FakeSocket extends EventTarget {
+ static instances: FakeSocket[] = []
+ static OPEN = 1
+ static CLOSED = 3
+ readyState = 0
+ sent: string[] = []
+ closed = false
+
+ constructor(readonly url: URL) {
+ super()
+ FakeSocket.instances.push(this)
+ }
+
+ send(value: string) {
+ this.sent.push(value)
+ }
+
+ open() {
+ this.readyState = FakeSocket.OPEN
+ this.dispatchEvent(new Event('open'))
+ }
+
+ close() {
+ this.closed = true
+ this.readyState = FakeSocket.CLOSED
+ this.dispatchEvent(new Event('close'))
+ }
+
+ deliver(value: unknown, query: object = {}, topic = 'app-events') {
+ this.dispatchEvent(
+ new MessageEvent('message', {
+ data: JSON.stringify({
+ type: 'snapshot',
+ topic,
+ query,
+ payload: value,
+ }),
+ }),
+ )
+ }
+
+ deliverUnauthorized() {
+ this.dispatchEvent(
+ new MessageEvent('message', { data: JSON.stringify({ type: 'unauthorized' }) }),
+ )
+ }
+}
+
+const originalSocket = globalThis.WebSocket
+let cleanups: Array<() => void> = []
+
+beforeEach(() => {
+ Object.defineProperty(window, 'location', {
+ configurable: true,
+ value: new URL('http://localhost:5173/'),
+ })
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ globalThis.WebSocket = FakeSocket as unknown as typeof WebSocket
+ FakeSocket.instances = []
+ cleanups = []
+})
+
+afterEach(() => {
+ for (const cleanup of cleanups) cleanup()
+ cleanups = []
+ globalThis.WebSocket = originalSocket
+ Reflect.deleteProperty(document, 'visibilityState')
+})
+
+test('shares a topic query and unsubscribes after the last listener leaves', async () => {
+ const received: unknown[] = []
+ cleanups.push(
+ subscribeToRealtimeEvents({
+ topic: 'app-events',
+ query: { user: 'current' },
+ onData: (data) => {
+ received.push(['first', data])
+ },
+ }),
+ )
+ const secondCleanup = subscribeToRealtimeEvents({
+ topic: 'app-events',
+ query: { user: 'current' },
+ onData: (data) => {
+ received.push(['second', data])
+ },
+ })
+ cleanups.push(secondCleanup)
+
+ const socket = FakeSocket.instances[0]!
+ socket.open()
+ expect(socket.sent).toEqual([
+ JSON.stringify({ type: 'subscribe', topic: 'app-events', query: { user: 'current' } }),
+ ])
+ socket.deliver({ revision: 'r1', userVersion: 'u1' }, { user: 'current' })
+ await Promise.resolve()
+ expect(received).toEqual([
+ ['first', { revision: 'r1', userVersion: 'u1' }],
+ ['second', { revision: 'r1', userVersion: 'u1' }],
+ ])
+
+ secondCleanup()
+ expect(socket.sent).toHaveLength(1)
+ cleanups[0]!()
+ expect(socket.sent).toEqual([
+ JSON.stringify({ type: 'subscribe', topic: 'app-events', query: { user: 'current' } }),
+ JSON.stringify({ type: 'unsubscribe', topic: 'app-events' }),
+ ])
+ expect(socket.closed).toBe(true)
+})
+
+test('ignores messages from a closed socket and forwards unauthorized frames', async () => {
+ const received: unknown[] = []
+ let unauthorized = 0
+ cleanups.push(
+ subscribeToRealtimeEvents({
+ topic: 'app-events',
+ query: {},
+ onData: (data) => {
+ received.push(data)
+ },
+ onUnauthorized: () => {
+ unauthorized += 1
+ },
+ }),
+ )
+ const first = FakeSocket.instances[0]!
+ first.open()
+ cleanups[0]!()
+
+ cleanups = [
+ subscribeToRealtimeEvents({
+ topic: 'app-events',
+ query: {},
+ onData: (data) => {
+ received.push(data)
+ },
+ onUnauthorized: () => {
+ unauthorized += 1
+ },
+ }),
+ ]
+ const second = FakeSocket.instances[1]!
+ second.open()
+ first.deliver('old')
+ second.deliver('current')
+ await Promise.resolve()
+ expect(received).toEqual(['current'])
+
+ second.deliverUnauthorized()
+ expect(unauthorized).toBe(1)
+ expect(second.closed).toBe(true)
+})
+
+test('resubscribes and calls resume handlers after a hidden page returns', () => {
+ let resumed = 0
+ cleanups.push(
+ subscribeToRealtimeEvents({
+ topic: 'app-events',
+ query: {},
+ onData: () => {},
+ onResume: () => {
+ resumed += 1
+ },
+ }),
+ )
+ const first = FakeSocket.instances[0]!
+ first.open()
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' })
+ document.dispatchEvent(new Event('visibilitychange'))
+ expect(first.closed).toBe(true)
+
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ document.dispatchEvent(new Event('visibilitychange'))
+ expect(resumed).toBe(1)
+ expect(FakeSocket.instances).toHaveLength(2)
+ const second = FakeSocket.instances[1]!
+ second.open()
+ expect(JSON.parse(second.sent[0]!)).toEqual({
+ type: 'subscribe',
+ topic: 'app-events',
+ query: {},
+ })
+
+ document.dispatchEvent(new Event('visibilitychange'))
+ expect(resumed).toBe(1)
+})
+
+test('routes buffered snapshots by query while a socket is shared', async () => {
+ const received: unknown[] = []
+ const holder = subscribeToRealtimeEvents({
+ topic: 'app-events',
+ query: {},
+ onData: () => {},
+ })
+ cleanups.push(holder)
+ const oldCleanup = subscribeToRealtimeEvents({
+ topic: 'access-logs',
+ query: { search: 'first' },
+ onData: (data) => {
+ received.push(['old', data])
+ },
+ })
+ const socket = FakeSocket.instances[0]!
+ socket.open()
+ oldCleanup()
+ const currentCleanup = subscribeToRealtimeEvents({
+ topic: 'access-logs',
+ query: { search: 'current' },
+ onData: (data) => {
+ received.push(['current', data])
+ },
+ })
+ cleanups.push(currentCleanup)
+ expect(FakeSocket.instances).toHaveLength(1)
+
+ socket.deliver('old', { search: 'first' }, 'access-logs')
+ socket.deliver('current', { search: 'current' }, 'access-logs')
+ await Promise.resolve()
+ expect(received).toEqual([['current', 'current']])
+})
+
+test('does not open a socket until a hidden page becomes visible', () => {
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'hidden' })
+ cleanups.push(
+ subscribeToRealtimeEvents({
+ topic: 'app-events',
+ query: {},
+ onData: () => {},
+ }),
+ )
+ expect(FakeSocket.instances).toHaveLength(0)
+
+ Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
+ document.dispatchEvent(new Event('visibilitychange'))
+ expect(FakeSocket.instances).toHaveLength(1)
+})
diff --git a/web/src/websockets/Client/realtimeEventsClient.ts b/web/src/websockets/Client/realtimeEventsClient.ts
new file mode 100644
index 0000000..c41775e
--- /dev/null
+++ b/web/src/websockets/Client/realtimeEventsClient.ts
@@ -0,0 +1,260 @@
+import { realtimeEventSchema, type LiveTopic, type SnapshotEvent } from '../Types/events'
+
+export type LiveStatus = 'connecting' | 'connected' | 'disconnected' | 'inactive'
+
+export interface RealtimeSubscriptionOptions {
+ readonly topic: LiveTopic
+ readonly query: object
+ readonly onData: (data: T) => void | Promise
+ readonly onStatus?: (status: LiveStatus) => void
+ readonly onUnauthorized?: () => void
+ readonly onResume?: () => void
+}
+
+interface Listener {
+ readonly id: number
+ readonly onData: (data: unknown) => void | Promise
+ readonly onStatus?: (status: LiveStatus) => void
+ readonly onUnauthorized?: () => void
+ readonly onResume?: () => void
+ subscriptionKey: string
+}
+
+interface Subscription {
+ readonly key: string
+ readonly topic: LiveTopic
+ readonly queryText: string
+ readonly query: object
+ readonly listeners: Set
+}
+
+const MIN_RECONNECT_DELAY_MS = 1000
+const MAX_RECONNECT_DELAY_MS = 30_000
+const listeners = new Map()
+const subscriptions = new Map()
+
+let websocket: WebSocket | null = null
+let reconnectTimeout: ReturnType | null = null
+let reconnectDelay = MIN_RECONNECT_DELAY_MS
+let connectionStatus: LiveStatus = 'inactive'
+let nextListenerId = 1
+let shouldReconnect = false
+let isConnecting = false
+let isPageSuspended = false
+let lifecycleListenersRegistered = false
+
+function getRealtimeUrl(): URL {
+ const url = new URL('/api/live', window.location.href)
+ url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:'
+ url.search = ''
+ return url
+}
+
+function notifyStatus(status: LiveStatus): void {
+ connectionStatus = status
+ for (const listener of listeners.values()) listener.onStatus?.(status)
+}
+
+function notifyData(event: SnapshotEvent): void {
+ const queryText = JSON.stringify(event.query)
+ for (const subscription of subscriptions.values()) {
+ if (subscription.topic !== event.topic || subscription.queryText !== queryText) continue
+ for (const listener of subscription.listeners) {
+ void Promise.resolve(listener.onData(event.payload)).catch(() => undefined)
+ }
+ }
+}
+
+function clearReconnectTimeout(): void {
+ if (reconnectTimeout === null) return
+ clearTimeout(reconnectTimeout)
+ reconnectTimeout = null
+}
+
+function sendMessage(message: object): boolean {
+ if (!websocket || websocket.readyState !== WebSocket.OPEN) return false
+ try {
+ websocket.send(JSON.stringify(message))
+ return true
+ } catch {
+ websocket.close()
+ return false
+ }
+}
+
+function sendSubscription(subscription: Subscription): void {
+ sendMessage({ type: 'subscribe', topic: subscription.topic, query: subscription.query })
+}
+
+function sendAllSubscriptions(): void {
+ for (const subscription of subscriptions.values()) sendSubscription(subscription)
+}
+
+function hasTopicSubscription(topic: LiveTopic): boolean {
+ for (const subscription of subscriptions.values()) {
+ if (subscription.topic === topic) return true
+ }
+ return false
+}
+
+function closeCurrentWebSocket(): void {
+ clearReconnectTimeout()
+ const current = websocket
+ websocket = null
+ if (!current) return
+ try {
+ current.close()
+ } catch {
+ return
+ }
+}
+
+function scheduleReconnect(): void {
+ if (!shouldReconnect || isPageSuspended || listeners.size === 0 || reconnectTimeout !== null)
+ return
+ notifyStatus('disconnected')
+ const delay = reconnectDelay
+ reconnectDelay = Math.min(reconnectDelay * 2, MAX_RECONNECT_DELAY_MS)
+ reconnectTimeout = setTimeout(() => {
+ reconnectTimeout = null
+ void connect()
+ }, delay)
+}
+
+function handleUnauthorized(): void {
+ shouldReconnect = false
+ clearReconnectTimeout()
+ notifyStatus('disconnected')
+ for (const listener of listeners.values()) listener.onUnauthorized?.()
+ closeCurrentWebSocket()
+}
+
+async function connect(): Promise {
+ if (isConnecting || websocket || !shouldReconnect || isPageSuspended || listeners.size === 0)
+ return
+ isConnecting = true
+ notifyStatus('connecting')
+ try {
+ const current = new WebSocket(getRealtimeUrl())
+ websocket = current
+ current.addEventListener('open', () => {
+ if (websocket !== current) return
+ reconnectDelay = MIN_RECONNECT_DELAY_MS
+ notifyStatus('connected')
+ sendAllSubscriptions()
+ })
+ current.addEventListener('message', (event) => {
+ if (websocket !== current) return
+ let value: unknown
+ try {
+ value = JSON.parse(String(event.data))
+ } catch {
+ return
+ }
+ const parsed = realtimeEventSchema.safeParse(value)
+ if (!parsed.success) return
+ if (parsed.data.type === 'unauthorized') {
+ handleUnauthorized()
+ return
+ }
+ notifyStatus('connected')
+ notifyData(parsed.data)
+ })
+ current.addEventListener('error', () => {
+ if (websocket === current) current.close()
+ })
+ current.addEventListener('close', () => {
+ if (websocket !== current) return
+ websocket = null
+ if (shouldReconnect && !isPageSuspended) scheduleReconnect()
+ else notifyStatus(isPageSuspended ? 'inactive' : 'disconnected')
+ })
+ } catch {
+ scheduleReconnect()
+ } finally {
+ isConnecting = false
+ }
+}
+
+function setSuspended(suspended: boolean): void {
+ isPageSuspended = suspended
+ if (suspended) {
+ closeCurrentWebSocket()
+ notifyStatus('inactive')
+ }
+}
+
+function setupLifecycleListeners(): void {
+ if (lifecycleListenersRegistered || typeof window === 'undefined') return
+ lifecycleListenersRegistered = true
+ const resume = () => {
+ if (document.visibilityState === 'hidden') {
+ setSuspended(true)
+ return
+ }
+ const wasSuspended = isPageSuspended
+ isPageSuspended = false
+ if (wasSuspended) for (const listener of listeners.values()) listener.onResume?.()
+ reconnectDelay = MIN_RECONNECT_DELAY_MS
+ if (listeners.size > 0) void connect()
+ }
+ document.addEventListener('visibilitychange', resume)
+ window.addEventListener('pagehide', () => setSuspended(true))
+ window.addEventListener('pageshow', resume)
+}
+
+export function subscribeToRealtimeEvents({
+ topic,
+ query,
+ onData,
+ onStatus,
+ onUnauthorized,
+ onResume,
+}: RealtimeSubscriptionOptions): () => void {
+ if (
+ listeners.size === 0 &&
+ !isPageSuspended &&
+ typeof document !== 'undefined' &&
+ document.visibilityState === 'hidden'
+ ) {
+ isPageSuspended = true
+ }
+ setupLifecycleListeners()
+ const queryText = JSON.stringify(query)
+ const key = `${topic}\u0000${queryText}`
+ let subscription = subscriptions.get(key)
+ if (!subscription) {
+ subscription = { key, topic, queryText, query, listeners: new Set() }
+ subscriptions.set(key, subscription)
+ }
+ const listener: Listener = {
+ id: nextListenerId++,
+ onData: onData as (data: unknown) => void | Promise,
+ subscriptionKey: key,
+ ...(onStatus ? { onStatus } : {}),
+ ...(onUnauthorized ? { onUnauthorized } : {}),
+ ...(onResume ? { onResume } : {}),
+ }
+ listeners.set(listener.id, listener)
+ subscription.listeners.add(listener)
+ onStatus?.(isPageSuspended ? 'inactive' : connectionStatus)
+ shouldReconnect = true
+ if (websocket?.readyState === WebSocket.OPEN && subscription.listeners.size === 1) {
+ sendSubscription(subscription)
+ }
+ if (!isPageSuspended) void connect()
+ return () => {
+ if (!listeners.delete(listener.id)) return
+ const current = subscriptions.get(listener.subscriptionKey)
+ if (!current) return
+ current.listeners.delete(listener)
+ if (current.listeners.size > 0) return
+ subscriptions.delete(current.key)
+ if (!hasTopicSubscription(current.topic))
+ sendMessage({ type: 'unsubscribe', topic: current.topic })
+ if (listeners.size > 0) return
+ shouldReconnect = false
+ closeCurrentWebSocket()
+ notifyStatus('inactive')
+ }
+}
diff --git a/web/src/websockets/Helpers/liveReader.ts b/web/src/websockets/Helpers/liveReader.ts
new file mode 100644
index 0000000..5ab597b
--- /dev/null
+++ b/web/src/websockets/Helpers/liveReader.ts
@@ -0,0 +1,134 @@
+import { LIVE_MAX_PAYLOAD_BYTES, type LiveTopic } from './realtimeConstants'
+import { readBoundedJson, snapshotUrl } from './snapshot'
+import type { LiveConnectionData, LiveTopicSubscription } from '../Server/realtimeSubscriptions'
+import type { LiveSnapshotReader } from '../Types/bun'
+
+export interface LiveReadResult {
+ readonly kind: 'success' | 'failure'
+ readonly data?: unknown
+ readonly status?: number
+}
+
+function handshakeSubscription(connection: LiveConnectionData): LiveTopicSubscription {
+ return {
+ connection,
+ topic: 'app-events',
+ query: {},
+ queryText: '{}',
+ intervalMs: 60_000,
+ timer: null,
+ abortController: null,
+ abortTimer: null,
+ inflight: false,
+ pending: false,
+ lastComparison: null,
+ active: true,
+ }
+}
+
+function abortable(
+ signal: AbortSignal,
+ onAbort: () => void,
+): {
+ readonly promise: Promise
+ readonly cleanup: () => void
+} {
+ let handler: (() => void) | null = null
+ const promise = new Promise((_, reject) => {
+ handler = () => {
+ onAbort()
+ reject(new DOMException('Snapshot read aborted.', 'AbortError'))
+ }
+ if (signal.aborted) handler()
+ else signal.addEventListener('abort', handler, { once: true })
+ })
+ return {
+ promise,
+ cleanup: () => {
+ if (handler !== null) signal.removeEventListener('abort', handler)
+ },
+ }
+}
+
+export async function readSubscriptionSnapshot(
+ subscription: LiveTopicSubscription,
+ signal: AbortSignal,
+ readSnapshot: (
+ request: Request,
+ context: { request: Request; topic: LiveTopic; query: unknown },
+ ) => Response | Promise,
+ getSnapshotUrl: (subscription: LiveTopicSubscription) => string,
+ maxPayloadBytes = LIVE_MAX_PAYLOAD_BYTES,
+): Promise {
+ const headers = new Headers()
+ if (subscription.connection.cookie !== null) {
+ headers.set('cookie', subscription.connection.cookie)
+ }
+ headers.set('origin', subscription.connection.origin)
+ const request = new Request(getSnapshotUrl(subscription), { method: 'GET', headers, signal })
+ let response: Response
+ const responseAbort = abortable(signal, () => undefined)
+ try {
+ const responsePromise = Promise.resolve(
+ readSnapshot(request, {
+ request,
+ topic: subscription.topic,
+ query: subscription.query,
+ }),
+ ).then((result) => {
+ if (signal.aborted) void result.body?.cancel().catch(() => undefined)
+ return result
+ })
+ response = await Promise.race([responsePromise, responseAbort.promise])
+ } catch {
+ responseAbort.cleanup()
+ return { kind: 'failure', status: 503 }
+ }
+ responseAbort.cleanup()
+ if (!response.ok) {
+ await response.body?.cancel().catch(() => undefined)
+ return { kind: 'failure', status: response.status }
+ }
+ const bodyAbort = abortable(signal, () => {
+ void response.body?.cancel().catch(() => undefined)
+ })
+ try {
+ const data = await Promise.race([
+ readBoundedJson(response, maxPayloadBytes),
+ bodyAbort.promise,
+ ])
+ return { kind: 'success', data }
+ } catch {
+ await response.body?.cancel().catch(() => undefined)
+ return { kind: 'failure', status: signal.aborted ? 503 : 413 }
+ } finally {
+ bodyAbort.cleanup()
+ }
+}
+
+export async function readLiveHandshake(
+ connection: LiveConnectionData,
+ snapshotPath: string,
+ snapshotTimeoutMs: number,
+ maxPayloadBytes: number,
+ readSnapshot: LiveSnapshotReader,
+): Promise {
+ const controller = new AbortController()
+ connection.handshakeAbortController = controller
+ const timer = setTimeout(() => controller.abort(), snapshotTimeoutMs)
+ try {
+ const subscription = handshakeSubscription(connection)
+ const result = await readSubscriptionSnapshot(
+ subscription,
+ controller.signal,
+ readSnapshot,
+ (current) =>
+ snapshotUrl(connection.requestUrl, snapshotPath, current.topic, current.queryText),
+ maxPayloadBytes,
+ )
+ return result.kind === 'failure' ? (result.status ?? 503) : 200
+ } finally {
+ clearTimeout(timer)
+ connection.handshakeAbortController = null
+ }
+}
diff --git a/web/src/websockets/Helpers/messages.ts b/web/src/websockets/Helpers/messages.ts
new file mode 100644
index 0000000..6df4112
--- /dev/null
+++ b/web/src/websockets/Helpers/messages.ts
@@ -0,0 +1,28 @@
+import { LIVE_MAX_MESSAGE_BYTES } from './realtimeConstants'
+import { byteLength, isRecord } from './snapshot'
+
+export interface ParsedLiveMessage {
+ readonly type: unknown
+ readonly topic: unknown
+ readonly query: unknown
+}
+
+export function parseLiveMessage(
+ message: string | ArrayBuffer | Uint8Array,
+ maxBytes = LIVE_MAX_MESSAGE_BYTES,
+): ParsedLiveMessage | null {
+ const text =
+ typeof message === 'string'
+ ? message
+ : new TextDecoder().decode(
+ message instanceof ArrayBuffer ? new Uint8Array(message) : message,
+ )
+ if (byteLength(text) > maxBytes) return null
+ try {
+ const value: unknown = JSON.parse(text)
+ if (!isRecord(value)) return null
+ return { type: value.type, topic: value.topic, query: value.query }
+ } catch {
+ return null
+ }
+}
diff --git a/web/src/websockets/Helpers/publishFunctions.ts b/web/src/websockets/Helpers/publishFunctions.ts
new file mode 100644
index 0000000..6a03d1f
--- /dev/null
+++ b/web/src/websockets/Helpers/publishFunctions.ts
@@ -0,0 +1,69 @@
+import type { ApplicationChangedEvent } from '../Types/events'
+
+type ChangeListener = () => void
+type ChangePublisher = (event: ApplicationChangedEvent) => Promise
+
+declare global {
+ var rentnerproxyRealtimeEvents:
+ | {
+ version: string
+ listeners: Set
+ seen: Set
+ publish: ChangePublisher | undefined
+ }
+ | undefined
+}
+
+function getEvents() {
+ return (globalThis.rentnerproxyRealtimeEvents ??= {
+ version: crypto.randomUUID(),
+ listeners: new Set(),
+ seen: new Set(),
+ publish: undefined,
+ })
+}
+
+export function receiveApplicationChange(event: ApplicationChangedEvent): void {
+ const events = getEvents()
+ if (events.seen.has(event.payload.version)) return
+ events.seen.add(event.payload.version)
+ if (events.seen.size > 256) events.seen.delete(events.seen.values().next().value!)
+ events.version = event.payload.version
+ for (const listener of events.listeners) {
+ try {
+ listener()
+ } catch {
+ continue
+ }
+ }
+}
+
+export function publishApplicationChange(): void {
+ const event: ApplicationChangedEvent = {
+ type: 'application.updated',
+ payload: { version: crypto.randomUUID() },
+ }
+ receiveApplicationChange(event)
+ try {
+ void getEvents()
+ .publish?.(event)
+ .catch(() => undefined)
+ } catch {
+ return
+ }
+}
+
+export function setApplicationPublisher(publish: ChangePublisher | undefined): void {
+ getEvents().publish = publish
+}
+
+export function subscribeToApplicationChanges(listener: ChangeListener): () => void {
+ getEvents().listeners.add(listener)
+ return () => {
+ getEvents().listeners.delete(listener)
+ }
+}
+
+export function getApplicationRevision(): string {
+ return getEvents().version
+}
diff --git a/web/src/websockets/Helpers/realtimeConstants.ts b/web/src/websockets/Helpers/realtimeConstants.ts
new file mode 100644
index 0000000..f10b6bb
--- /dev/null
+++ b/web/src/websockets/Helpers/realtimeConstants.ts
@@ -0,0 +1,35 @@
+export const REALTIME_WS_PATH = '/api/live'
+export const LIVE_SNAPSHOT_PATH = '/api/live-snapshot'
+
+export const LIVE_TOPICS = [
+ 'access-logs',
+ 'audit-logs',
+ 'foundation',
+ 'app-events',
+ 'proxy-hosts',
+ 'certificates',
+ 'redirect-hosts',
+ 'access-policies',
+] as const
+
+export type LiveTopic = (typeof LIVE_TOPICS)[number]
+
+export const LIVE_INTERVALS: Readonly> = {
+ 'access-logs': 2_000,
+ 'audit-logs': 2_000,
+ foundation: 30_000,
+ 'app-events': 60_000,
+ 'proxy-hosts': 2_000,
+ certificates: 2_000,
+ 'redirect-hosts': 15_000,
+ 'access-policies': 15_000,
+}
+
+export const LIVE_MAX_CONNECTIONS = 100
+export const LIVE_MAX_SUBSCRIPTIONS = 8
+export const LIVE_MAX_PAYLOAD_BYTES = 4 * 1024 * 1024
+export const LIVE_MAX_QUERY_BYTES = 8 * 1024
+export const LIVE_MAX_MESSAGE_BYTES = 16 * 1024
+export const LIVE_MAX_MESSAGES_PER_SECOND = 30
+export const LIVE_IDLE_TIMEOUT_SECONDS = 90
+export const LIVE_SNAPSHOT_TIMEOUT_MS = 10_000
diff --git a/web/src/websockets/Helpers/snapshot.ts b/web/src/websockets/Helpers/snapshot.ts
new file mode 100644
index 0000000..05a664e
--- /dev/null
+++ b/web/src/websockets/Helpers/snapshot.ts
@@ -0,0 +1,161 @@
+import {
+ LIVE_MAX_QUERY_BYTES,
+ LIVE_MAX_PAYLOAD_BYTES,
+ LIVE_SNAPSHOT_PATH,
+ LIVE_TOPICS,
+ type LiveTopic,
+} from './realtimeConstants'
+
+export class LivePayloadError extends Error {
+ constructor() {
+ super('Live snapshot payload is too large or invalid.')
+ }
+}
+
+export function isRecord(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
+}
+
+export function byteLength(value: string): number {
+ return new TextEncoder().encode(value).byteLength
+}
+
+export function normalizeOrigin(value: string | null | undefined): string | null {
+ if (!value) return null
+
+ try {
+ const origin = new URL(value)
+ if (
+ (origin.protocol !== 'http:' && origin.protocol !== 'https:') ||
+ origin.username ||
+ origin.password ||
+ origin.pathname !== '/' ||
+ origin.search ||
+ origin.hash ||
+ !origin.hostname
+ ) {
+ return null
+ }
+ return origin.toString().replace(/\/$/u, '')
+ } catch {
+ return null
+ }
+}
+
+export function statusResponse(status: number): Response {
+ return new Response(null, {
+ status,
+ headers: {
+ 'Cache-Control': 'private, no-store',
+ 'X-Content-Type-Options': 'nosniff',
+ },
+ })
+}
+
+export function isWebSocketUpgrade(request: Request): boolean {
+ return request.headers.get('upgrade')?.trim().toLowerCase() === 'websocket'
+}
+
+export function topicFrom(value: unknown): LiveTopic | null {
+ return typeof value === 'string' && (LIVE_TOPICS as readonly string[]).includes(value)
+ ? (value as LiveTopic)
+ : null
+}
+
+export function parseQuery(
+ query: unknown,
+ maxQueryBytes = LIVE_MAX_QUERY_BYTES,
+): {
+ query: unknown
+ queryText: string
+} | null {
+ if (!isRecord(query)) return null
+ const queryText = JSON.stringify(query)
+ if (queryText === undefined || byteLength(queryText) > maxQueryBytes) return null
+ return { query, queryText }
+}
+
+function comparableValue(value: unknown): unknown {
+ if (Array.isArray(value)) return value.map(comparableValue)
+ if (!isRecord(value)) return value
+
+ const entries = Object.entries(value)
+ .filter(([key]) => key !== 'snapshotExpiresAt')
+ .toSorted(([left], [right]) => left.localeCompare(right))
+ .map(([key, child]) => [key, comparableValue(child)] as const)
+ return Object.fromEntries(entries)
+}
+
+export function comparisonKey(value: unknown): string {
+ const serialized = JSON.stringify(comparableValue(value))
+ if (serialized === undefined) throw new LivePayloadError()
+ return serialized
+}
+
+export function snapshotMessage(
+ topic: LiveTopic,
+ query: unknown,
+ value: unknown,
+ maxPayloadBytes = LIVE_MAX_PAYLOAD_BYTES,
+): string {
+ const serialized = JSON.stringify({ type: 'snapshot', topic, query, payload: value })
+ if (serialized === undefined || byteLength(serialized) > maxPayloadBytes) {
+ throw new LivePayloadError()
+ }
+ return serialized
+}
+
+export function snapshotUrl(
+ requestUrl: string,
+ snapshotPath = LIVE_SNAPSHOT_PATH,
+ topic: LiveTopic,
+ queryText: string,
+): string {
+ const url = new URL(snapshotPath, requestUrl)
+ url.search = ''
+ url.searchParams.set('topic', topic)
+ url.searchParams.set('query', queryText)
+ return url.toString()
+}
+
+export async function readBoundedJson(
+ response: Response,
+ maxPayloadBytes = LIVE_MAX_PAYLOAD_BYTES,
+): Promise {
+ const bodyReader = response.body?.getReader()
+ if (!bodyReader) throw new LivePayloadError()
+ const reader = bodyReader
+
+ const chunks: Uint8Array[] = []
+ async function collect(length: number): Promise {
+ const chunk = await reader.read()
+ if (chunk.done) return length
+ const nextLength = length + chunk.value.byteLength
+ if (nextLength > maxPayloadBytes) {
+ await reader.cancel()
+ throw new LivePayloadError()
+ }
+ chunks.push(chunk.value)
+ return collect(nextLength)
+ }
+
+ let length: number
+ try {
+ length = await collect(0)
+ } finally {
+ reader.releaseLock()
+ }
+
+ const bytes = new Uint8Array(length)
+ let offset = 0
+ for (const chunk of chunks) {
+ bytes.set(chunk, offset)
+ offset += chunk.byteLength
+ }
+
+ try {
+ return JSON.parse(new TextDecoder().decode(bytes)) as unknown
+ } catch {
+ throw new LivePayloadError()
+ }
+}
diff --git a/web/src/websockets/Server/realtimeHandler.ts b/web/src/websockets/Server/realtimeHandler.ts
new file mode 100644
index 0000000..df9c86c
--- /dev/null
+++ b/web/src/websockets/Server/realtimeHandler.ts
@@ -0,0 +1,85 @@
+import { LIVE_MAX_MESSAGE_BYTES, LIVE_MAX_MESSAGES_PER_SECOND } from '../Helpers/realtimeConstants'
+import { parseLiveMessage } from '../Helpers/messages'
+import { byteLength, parseQuery, topicFrom } from '../Helpers/snapshot'
+import type { LiveSocketData } from '../Types/bun'
+import type { LiveConnectionData, LiveSubscriptionManager } from './realtimeSubscriptions'
+
+interface Lifecycle {
+ readonly close: (connection: LiveConnectionData, code: number, reason: string) => void
+ readonly dispose: (connection: LiveConnectionData) => void
+}
+
+interface RealtimeHandlerOptions {
+ readonly manager: LiveSubscriptionManager
+ readonly lifecycle: Lifecycle
+ readonly maxQueryBytes: number
+ readonly maxPayloadBytes: number
+ readonly idleTimeoutSeconds: number
+}
+
+export function createRealtimeHandler(
+ options: RealtimeHandlerOptions,
+): Bun.WebSocketHandler {
+ return {
+ data: {} as LiveSocketData,
+ idleTimeout: options.idleTimeoutSeconds,
+ maxPayloadLength: LIVE_MAX_MESSAGE_BYTES,
+ backpressureLimit: options.maxPayloadBytes,
+ closeOnBackpressureLimit: true,
+ sendPings: true,
+ open(ws) {
+ const connection = ws.data.connection as LiveConnectionData
+ connection.ws = ws
+ if (connection.closed) options.lifecycle.close(connection, 1012, 'Server restarting')
+ },
+ message(ws, message) {
+ const connection = ws.data.connection as LiveConnectionData
+ const now = Date.now()
+ if (now - connection.messageWindowStarted >= 1_000) {
+ connection.messageWindowStarted = now
+ connection.messageCount = 0
+ }
+ connection.messageCount += 1
+ if (connection.messageCount > LIVE_MAX_MESSAGES_PER_SECOND) {
+ options.lifecycle.close(connection, 1008, 'Too many live messages')
+ return
+ }
+ const parsed = parseLiveMessage(message)
+ if (!parsed) {
+ options.lifecycle.close(connection, 1008, 'Invalid live message')
+ return
+ }
+ const topic = topicFrom(parsed.topic)
+ if (parsed.type === 'unsubscribe') {
+ if (!topic || parsed.query !== undefined) {
+ options.lifecycle.close(connection, 1008, 'Invalid live message')
+ return
+ }
+ options.manager.unsubscribe(connection, topic)
+ return
+ }
+ if (parsed.type !== 'subscribe' || !topic) {
+ options.lifecycle.close(connection, 1008, 'Invalid live message')
+ return
+ }
+ const parsedQuery = parseQuery(parsed.query)
+ if (!parsedQuery || byteLength(parsedQuery.queryText) > options.maxQueryBytes) {
+ options.lifecycle.close(connection, 1008, 'Invalid live query')
+ return
+ }
+ if (
+ !options.manager.subscribe(
+ connection,
+ topic,
+ parsedQuery.query,
+ parsedQuery.queryText,
+ )
+ ) {
+ options.lifecycle.close(connection, 1008, 'Too many live subscriptions')
+ }
+ },
+ close(ws) {
+ options.lifecycle.dispose(ws.data.connection as LiveConnectionData)
+ },
+ }
+}
diff --git a/web/src/websockets/Server/realtimeLifecycle.ts b/web/src/websockets/Server/realtimeLifecycle.ts
new file mode 100644
index 0000000..237df87
--- /dev/null
+++ b/web/src/websockets/Server/realtimeLifecycle.ts
@@ -0,0 +1,41 @@
+import type { LiveConnectionData } from './realtimeSubscriptions'
+import type { LiveSubscriptionManager } from './realtimeSubscriptions'
+
+interface RealtimeLifecycleOptions {
+ readonly manager: LiveSubscriptionManager
+ readonly pending: Set
+}
+
+export function createRealtimeLifecycle(options: RealtimeLifecycleOptions) {
+ function dispose(connection: LiveConnectionData): void {
+ if (connection.closed) return
+ connection.closed = true
+ options.manager.dispose(connection)
+ options.manager.connections.delete(connection)
+ options.pending.delete(connection)
+ }
+
+ function close(connection: LiveConnectionData, code: number, reason: string): void {
+ if (connection.closed) return
+ const ws = connection.ws
+ dispose(connection)
+ try {
+ ws?.close(code, reason)
+ } catch {
+ return
+ }
+ }
+
+ function unauthorized(connection: LiveConnectionData): void {
+ if (connection.closed) return
+ try {
+ connection.ws?.sendText(JSON.stringify({ type: 'unauthorized' }))
+ } catch {
+ close(connection, 4001, 'Unauthorized')
+ return
+ }
+ close(connection, 4001, 'Unauthorized')
+ }
+
+ return { dispose, close, unauthorized }
+}
diff --git a/web/src/websockets/Server/realtimeRedis.service.ts b/web/src/websockets/Server/realtimeRedis.service.ts
new file mode 100644
index 0000000..21442e7
--- /dev/null
+++ b/web/src/websockets/Server/realtimeRedis.service.ts
@@ -0,0 +1,61 @@
+import '@tanstack/react-start/server-only'
+
+import type { RedisClient } from 'bun'
+import { receiveApplicationChange, setApplicationPublisher } from '../Helpers/publishFunctions'
+import { getRedisClient } from '../../server/redis/client.server'
+import { applicationChangedEventSchema } from '../Types/events'
+
+const CHANNEL = 'rentnerproxy:realtime'
+
+declare global {
+ var rentnerproxyRealtimeRedis: { stop: () => void } | undefined
+}
+
+export function startRealtimeRedis(): void {
+ if (globalThis.rentnerproxyRealtimeRedis) return
+ let stopped = false
+ let subscriber: RedisClient | undefined
+ let retry: ReturnType | undefined
+ const stop = () => {
+ stopped = true
+ clearTimeout(retry)
+ subscriber?.close()
+ setApplicationPublisher(undefined)
+ globalThis.rentnerproxyRealtimeRedis = undefined
+ }
+ globalThis.rentnerproxyRealtimeRedis = { stop }
+ process.once('rentnerproxy:shutdown', stop)
+
+ async function connect(): Promise {
+ try {
+ const publisher = getRedisClient()
+ if (!publisher) return
+ const connection = await publisher.duplicate()
+ if (stopped) {
+ connection.close()
+ return
+ }
+ subscriber = connection
+ await connection.subscribe(CHANNEL, (message) => {
+ try {
+ const event = applicationChangedEventSchema.safeParse(JSON.parse(message))
+ if (event.success) receiveApplicationChange(event.data)
+ } catch {
+ return
+ }
+ })
+ if (stopped) return
+ setApplicationPublisher((event) => publisher.publish(CHANNEL, JSON.stringify(event)))
+ } catch {
+ subscriber?.close()
+ subscriber = undefined
+ if (!stopped) {
+ retry = setTimeout(() => {
+ void connect()
+ }, 5000)
+ retry.unref()
+ }
+ }
+ }
+ void connect()
+}
diff --git a/web/src/websockets/Server/realtimeSnapshots.service.ts b/web/src/websockets/Server/realtimeSnapshots.service.ts
new file mode 100644
index 0000000..781b89f
--- /dev/null
+++ b/web/src/websockets/Server/realtimeSnapshots.service.ts
@@ -0,0 +1,111 @@
+import '@tanstack/react-start/server-only'
+
+import { PERMISSIONS } from '../../config/permissions.config'
+import { proxyAccessLogsQuerySchema } from '../../features/Admin/ProxyAccessLogs/validation'
+import { auditEventsQuerySchema } from '../../features/Admin/AuditLogs/validation'
+import { getProxyAccessLogsService } from '../../server/Admin/ProxyAccessLogs/proxy-access-logs.service'
+import { listAuditEventsService } from '../../server/Audit/audit-reader.service'
+import { requirePermissionService } from '../../server/Auth/Access/authorization.service'
+import { isAuthDomainError } from '../../server/Auth/Core/errors.server'
+import { checkFoundationHealth } from '../../server/Foundation/health.service'
+import { getPublicOrigin } from '../../server/env.server'
+import { getApplicationRevision } from '../Helpers/publishFunctions'
+import { getProxyHostsService } from '../../server/Admin/ProxyHostManagement/proxy-hosts.service'
+import { getCertificatesService } from '../../server/Admin/CertificateManagement/certificates.service'
+import { getProxyRuntimeStatusService } from '../../server/ProxyRuntime/proxy-runtime.service'
+import { getRedirectHostsService } from '../../server/Admin/RedirectHostManagement/redirect-hosts.service'
+import { getAccessPoliciesService } from '../../server/Admin/AccessPolicyManagement/access-policies.service'
+
+function revisionOf(value: unknown): string {
+ return new Bun.CryptoHasher('sha256').update(JSON.stringify(value)).digest('hex')
+}
+
+export async function getLiveSnapshotResponse(request: Request): Promise {
+ const headers = { 'Cache-Control': 'private, no-store' }
+ const origin = request.headers.get('origin')
+ if (!origin || origin !== getPublicOrigin()) {
+ return new Response(null, { status: 403, headers })
+ }
+ try {
+ const url = new URL(request.url)
+ const encodedQuery = url.searchParams.get('query') ?? '{}'
+ if (encodedQuery.length > 4096) return new Response(null, { status: 400, headers })
+ const input: unknown = JSON.parse(encodedQuery)
+ let data: unknown
+ switch (url.searchParams.get('topic')) {
+ case 'app-events': {
+ const user = await requirePermissionService(PERMISSIONS.APP_ACCESS)
+ data = {
+ revision: getApplicationRevision(),
+ userVersion: revisionOf(user),
+ }
+ break
+ }
+ case 'proxy-hosts':
+ data = {
+ revision: revisionOf(
+ await Promise.all([getProxyHostsService(), getProxyRuntimeStatusService()]),
+ ),
+ }
+ break
+ case 'certificates':
+ data = { revision: revisionOf(await getCertificatesService()) }
+ break
+ case 'redirect-hosts':
+ data = {
+ revision: revisionOf(
+ await Promise.all([
+ getRedirectHostsService(),
+ getProxyRuntimeStatusService(PERMISSIONS.REDIRECT_HOSTS_VIEW),
+ ]),
+ ),
+ }
+ break
+ case 'access-policies':
+ data = {
+ revision: revisionOf(
+ await Promise.all([
+ getAccessPoliciesService(),
+ getProxyRuntimeStatusService(PERMISSIONS.ACCESS_POLICIES_VIEW),
+ ]),
+ ),
+ }
+ break
+ case 'access-logs': {
+ const query = proxyAccessLogsQuerySchema.safeParse(input)
+ if (!query.success || query.data.offset !== 0 || query.data.snapshot) {
+ return new Response(null, { status: 400, headers })
+ }
+ data = await getProxyAccessLogsService(query.data)
+ break
+ }
+ case 'audit-logs': {
+ const query = auditEventsQuerySchema.safeParse(input)
+ if (!query.success || query.data.cursor) {
+ return new Response(null, { status: 400, headers })
+ }
+ data = await listAuditEventsService(query.data)
+ break
+ }
+ case 'foundation':
+ await requirePermissionService(PERMISSIONS.APP_ACCESS)
+ data = await checkFoundationHealth()
+ break
+ default:
+ return new Response(null, { status: 400, headers })
+ }
+ return Response.json(data, { headers })
+ } catch (error) {
+ const status =
+ error instanceof SyntaxError
+ ? 400
+ : isAuthDomainError(error)
+ ? error.code === 'authentication_required'
+ ? 401
+ : error.code === 'permission_denied' || error.code === 'user_not_active'
+ ? 403
+ : 503
+ : 503
+ return new Response(null, { status, headers })
+ }
+}
diff --git a/web/src/websockets/Server/realtimeSubscriptions.ts b/web/src/websockets/Server/realtimeSubscriptions.ts
new file mode 100644
index 0000000..2b74452
--- /dev/null
+++ b/web/src/websockets/Server/realtimeSubscriptions.ts
@@ -0,0 +1,192 @@
+import type { ServerWebSocket } from 'bun'
+
+import {
+ LIVE_INTERVALS,
+ LIVE_MAX_SUBSCRIPTIONS,
+ type LiveTopic,
+} from '../Helpers/realtimeConstants'
+import { comparisonKey, snapshotMessage } from '../Helpers/snapshot'
+import type { LiveSocketData } from '../Types/bun'
+
+export interface LiveConnectionData {
+ readonly requestUrl: string
+ readonly cookie: string | null
+ readonly origin: string
+ ws: ServerWebSocket | null
+ closed: boolean
+ handshakeAbortController: AbortController | null
+ subscriptions: Map
+ messageWindowStarted: number
+ messageCount: number
+}
+
+export interface LiveTopicSubscription {
+ readonly connection: LiveConnectionData
+ readonly topic: LiveTopic
+ readonly query: unknown
+ readonly queryText: string
+ readonly intervalMs: number
+ timer: ReturnType | null
+ abortController: AbortController | null
+ abortTimer: ReturnType | null
+ inflight: boolean
+ pending: boolean
+ lastComparison: string | null
+ active: boolean
+}
+
+function unsubscribe(connection: LiveConnectionData, topic: LiveTopic): void {
+ const subscription = connection.subscriptions.get(topic)
+ if (!subscription) return
+ subscription.active = false
+ subscription.pending = false
+ subscription.abortController?.abort()
+ subscription.abortController = null
+ if (subscription.abortTimer !== null) clearTimeout(subscription.abortTimer)
+ subscription.abortTimer = null
+ if (subscription.timer !== null) clearInterval(subscription.timer)
+ subscription.timer = null
+ connection.subscriptions.delete(topic)
+}
+
+interface SubscriptionManagerOptions {
+ readonly readSnapshot: (
+ subscription: LiveTopicSubscription,
+ signal: AbortSignal,
+ ) => Promise<{
+ readonly kind: 'success' | 'failure'
+ readonly data?: unknown
+ readonly status?: number
+ }>
+ readonly maxPayloadBytes: number
+ readonly snapshotTimeoutMs: number
+ readonly closeUnauthorized: (connection: LiveConnectionData) => void
+ readonly closeUnavailable: (connection: LiveConnectionData, status: number) => void
+ readonly closeBackpressured: (connection: LiveConnectionData) => void
+}
+
+export interface LiveSubscriptionManager {
+ readonly connections: Set
+ readonly sample: (subscription: LiveTopicSubscription) => Promise
+ readonly sampleAll: () => void
+ readonly subscribe: (
+ connection: LiveConnectionData,
+ topic: LiveTopic,
+ query: unknown,
+ queryText: string,
+ ) => boolean
+ readonly unsubscribe: (connection: LiveConnectionData, topic: LiveTopic) => void
+ readonly dispose: (connection: LiveConnectionData) => void
+}
+
+export function createSubscriptionManager(options: SubscriptionManagerOptions) {
+ async function sample(subscription: LiveTopicSubscription): Promise {
+ const { connection } = subscription
+ if (!subscription.active || connection.closed) return
+ if (subscription.inflight) {
+ subscription.pending = true
+ return
+ }
+ subscription.inflight = true
+ subscription.pending = false
+ const controller = new AbortController()
+ subscription.abortController = controller
+ subscription.abortTimer = setTimeout(() => controller.abort(), options.snapshotTimeoutMs)
+ try {
+ const result = await options.readSnapshot(subscription, controller.signal)
+ if (!subscription.active || connection.closed) return
+ if (result.kind === 'failure') {
+ if (result.status === 401 || result.status === 403) {
+ options.closeUnauthorized(connection)
+ } else {
+ options.closeUnavailable(connection, result.status ?? 503)
+ }
+ return
+ }
+
+ let comparison: string
+ let message: string
+ try {
+ comparison = comparisonKey(result.data)
+ message = snapshotMessage(
+ subscription.topic,
+ subscription.query,
+ result.data,
+ options.maxPayloadBytes,
+ )
+ } catch {
+ options.closeUnavailable(connection, 413)
+ return
+ }
+ if (comparison === subscription.lastComparison) return
+ const ws = connection.ws
+ if (!ws || ws.sendText(message) <= 0) {
+ options.closeBackpressured(connection)
+ return
+ }
+ subscription.lastComparison = comparison
+ } finally {
+ subscription.inflight = false
+ if (subscription.abortTimer !== null) clearTimeout(subscription.abortTimer)
+ subscription.abortTimer = null
+ subscription.abortController = null
+ if (subscription.pending && subscription.active && !connection.closed) {
+ subscription.pending = false
+ queueMicrotask(() => void sample(subscription))
+ }
+ }
+ }
+
+ function subscribe(
+ connection: LiveConnectionData,
+ topic: LiveTopic,
+ query: unknown,
+ queryText: string,
+ ): boolean {
+ const existing = connection.subscriptions.get(topic)
+ if (!existing && connection.subscriptions.size >= LIVE_MAX_SUBSCRIPTIONS) return false
+ if (existing) unsubscribe(connection, topic)
+ const subscription: LiveTopicSubscription = {
+ connection,
+ topic,
+ query,
+ queryText,
+ intervalMs: LIVE_INTERVALS[topic],
+ timer: null,
+ abortController: null,
+ abortTimer: null,
+ inflight: false,
+ pending: false,
+ lastComparison: null,
+ active: true,
+ }
+ connection.subscriptions.set(topic, subscription)
+ subscription.timer = setInterval(() => void sample(subscription), subscription.intervalMs)
+ void sample(subscription)
+ return true
+ }
+
+ function dispose(connection: LiveConnectionData): void {
+ for (const topic of connection.subscriptions.keys()) unsubscribe(connection, topic)
+ connection.handshakeAbortController?.abort()
+ connection.handshakeAbortController = null
+ }
+
+ function sampleAll(): void {
+ for (const connection of connections) {
+ for (const subscription of connection.subscriptions.values()) void sample(subscription)
+ }
+ }
+
+ const connections = new Set()
+
+ const manager: LiveSubscriptionManager = {
+ connections,
+ sample,
+ sampleAll,
+ subscribe,
+ unsubscribe,
+ dispose,
+ }
+ return manager
+}
diff --git a/web/src/websockets/Server/realtimeWebSocket.ts b/web/src/websockets/Server/realtimeWebSocket.ts
new file mode 100644
index 0000000..279efea
--- /dev/null
+++ b/web/src/websockets/Server/realtimeWebSocket.ts
@@ -0,0 +1,177 @@
+import { subscribeToApplicationChanges } from '../Helpers/publishFunctions'
+import {
+ LIVE_IDLE_TIMEOUT_SECONDS,
+ LIVE_MAX_CONNECTIONS,
+ LIVE_MAX_PAYLOAD_BYTES,
+ LIVE_MAX_QUERY_BYTES,
+ LIVE_SNAPSHOT_PATH,
+ LIVE_SNAPSHOT_TIMEOUT_MS,
+ REALTIME_WS_PATH,
+} from '../Helpers/realtimeConstants'
+import {
+ isWebSocketUpgrade,
+ normalizeOrigin,
+ snapshotUrl,
+ statusResponse,
+} from '../Helpers/snapshot'
+import { readLiveHandshake, readSubscriptionSnapshot } from '../Helpers/liveReader'
+import type {
+ LiveSocketData,
+ LiveUpgradeResult,
+ LiveWebSocketRuntime,
+ LiveWebSocketRuntimeOptions,
+} from '../Types/bun'
+import { createSubscriptionManager, type LiveConnectionData } from './realtimeSubscriptions'
+import { createRealtimeLifecycle } from './realtimeLifecycle'
+import { createRealtimeHandler } from './realtimeHandler'
+
+export function createLiveWebSocketRuntime(
+ options: LiveWebSocketRuntimeOptions,
+): LiveWebSocketRuntime {
+ const snapshotPath = options.snapshotPath ?? LIVE_SNAPSHOT_PATH
+ const maxConnections = options.maxConnections ?? LIVE_MAX_CONNECTIONS
+ const maxPayloadBytes = options.maxPayloadBytes ?? LIVE_MAX_PAYLOAD_BYTES
+ const maxQueryBytes = options.maxQueryBytes ?? LIVE_MAX_QUERY_BYTES
+ const idleTimeoutSeconds = options.idleTimeoutSeconds ?? LIVE_IDLE_TIMEOUT_SECONDS
+ const snapshotTimeoutMs = options.snapshotTimeoutMs ?? LIVE_SNAPSHOT_TIMEOUT_MS
+ const pending = new Set()
+ let pendingReservations = 0
+ let shuttingDown = false
+
+ async function expectedOrigin(request: Request): Promise {
+ const value =
+ typeof options.allowedOrigin === 'function'
+ ? await options.allowedOrigin(request)
+ : options.allowedOrigin
+ return normalizeOrigin(value)
+ }
+
+ let lifecycle: ReturnType
+ const manager = createSubscriptionManager({
+ maxPayloadBytes,
+ snapshotTimeoutMs,
+ readSnapshot: (subscription, signal) =>
+ readSubscriptionSnapshot(
+ subscription,
+ signal,
+ options.readSnapshot,
+ (current) =>
+ snapshotUrl(
+ current.connection.requestUrl,
+ snapshotPath,
+ current.topic,
+ current.queryText,
+ ),
+ maxPayloadBytes,
+ ),
+ closeUnauthorized: (connection) => lifecycle.unauthorized(connection),
+ closeUnavailable: (connection, status) => {
+ lifecycle.close(
+ connection,
+ status === 413 ? 1009 : status === 429 || status === 503 ? 1013 : 1011,
+ 'Live snapshot unavailable',
+ )
+ },
+ closeBackpressured: (connection) =>
+ lifecycle.close(connection, 1013, 'Live client is backpressured'),
+ })
+ lifecycle = createRealtimeLifecycle({ manager, pending })
+
+ const websocket = createRealtimeHandler({
+ manager,
+ lifecycle,
+ maxQueryBytes,
+ maxPayloadBytes,
+ idleTimeoutSeconds,
+ })
+
+ async function handle(
+ request: Request,
+ server: Bun.Server,
+ ): Promise {
+ const url = new URL(request.url)
+ if (url.pathname !== REALTIME_WS_PATH || !isWebSocketUpgrade(request)) {
+ return { handled: false }
+ }
+ if (request.method.toUpperCase() !== 'GET')
+ return { handled: true, response: statusResponse(405) }
+ if (shuttingDown) return { handled: true, response: statusResponse(503) }
+ if (manager.connections.size + pending.size + pendingReservations >= maxConnections) {
+ return { handled: true, response: statusResponse(429) }
+ }
+
+ pendingReservations += 1
+ let pendingReservation = true
+ let reservation: LiveConnectionData | null = null
+ let upgraded = false
+ try {
+ const origin = normalizeOrigin(request.headers.get('origin'))
+ const configuredOrigin = await expectedOrigin(request)
+ if (!origin || !configuredOrigin || origin !== configuredOrigin) {
+ return { handled: true, response: statusResponse(403) }
+ }
+ reservation = {
+ requestUrl: request.url,
+ cookie: request.headers.get('cookie'),
+ origin,
+ ws: null,
+ closed: false,
+ handshakeAbortController: null,
+ subscriptions: new Map(),
+ messageWindowStarted: Date.now(),
+ messageCount: 0,
+ }
+ pending.add(reservation)
+ pendingReservations -= 1
+ pendingReservation = false
+ const authStatus = await readLiveHandshake(
+ reservation,
+ snapshotPath,
+ snapshotTimeoutMs,
+ maxPayloadBytes,
+ options.readSnapshot,
+ )
+ if (shuttingDown) return { handled: true, response: statusResponse(503) }
+ if (authStatus !== 200) return { handled: true, response: statusResponse(authStatus) }
+ try {
+ upgraded = server.upgrade(request, { data: { connection: reservation } })
+ } catch {
+ upgraded = false
+ }
+ if (!upgraded) return { handled: true, response: statusResponse(400) }
+ pending.delete(reservation)
+ manager.connections.add(reservation)
+ return { handled: true }
+ } finally {
+ if (pendingReservation) pendingReservations -= 1
+ if (reservation && !upgraded) lifecycle.dispose(reservation)
+ }
+ }
+
+ const unsubscribeFromChanges = subscribeToApplicationChanges(() => manager.sampleAll())
+
+ async function shutdown(): Promise {
+ if (shuttingDown) return
+ shuttingDown = true
+ unsubscribeFromChanges()
+ for (const connection of [...pending, ...manager.connections]) {
+ lifecycle.close(connection, 1012, 'Server restarting')
+ }
+ pending.clear()
+ manager.connections.clear()
+ }
+
+ return {
+ websocket,
+ handle,
+ shutdown,
+ get connectionCount() {
+ return manager.connections.size + pending.size + pendingReservations
+ },
+ get samplingGroupCount() {
+ let count = 0
+ for (const connection of manager.connections) count += connection.subscriptions.size
+ return count
+ },
+ }
+}
diff --git a/web/src/websockets/Types/bun.ts b/web/src/websockets/Types/bun.ts
new file mode 100644
index 0000000..8b12fac
--- /dev/null
+++ b/web/src/websockets/Types/bun.ts
@@ -0,0 +1,49 @@
+import type { ServerWebSocket } from 'bun'
+
+import type { LiveTopic } from '../Helpers/realtimeConstants'
+
+export type MaybePromise = T | Promise
+
+export interface LiveUpgradeResult {
+ readonly handled: boolean
+ readonly response?: Response
+}
+
+export interface LiveSnapshotReaderRequest {
+ readonly request: Request
+ readonly topic: LiveTopic
+ readonly query: unknown
+}
+
+export type LiveSnapshotReader = (
+ request: Request,
+ context: LiveSnapshotReaderRequest,
+) => MaybePromise
+
+export interface LiveWebSocketRuntimeOptions {
+ readonly allowedOrigin: string | ((request: Request) => MaybePromise)
+ readonly readSnapshot: LiveSnapshotReader
+ readonly snapshotPath?: string
+ readonly maxConnections?: number
+ readonly maxPayloadBytes?: number
+ readonly maxQueryBytes?: number
+ readonly idleTimeoutSeconds?: number
+ readonly snapshotTimeoutMs?: number
+}
+
+export interface LiveSocketData {
+ readonly connection: unknown
+}
+
+export interface LiveWebSocketRuntime {
+ readonly websocket: Bun.WebSocketHandler
+ readonly handle: (
+ request: Request,
+ server: Bun.Server,
+ ) => Promise
+ readonly shutdown: () => Promise
+ readonly connectionCount: number
+ readonly samplingGroupCount: number
+}
+
+export type LiveServerWebSocket = ServerWebSocket
diff --git a/web/src/websockets/Types/events.ts b/web/src/websockets/Types/events.ts
new file mode 100644
index 0000000..43c5239
--- /dev/null
+++ b/web/src/websockets/Types/events.ts
@@ -0,0 +1,25 @@
+import { z } from 'zod'
+import { LIVE_TOPICS } from '../Helpers/realtimeConstants'
+
+export const liveTopicSchema = z.enum(LIVE_TOPICS)
+
+export type LiveTopic = z.infer
+
+export const applicationChangedEventSchema = z.object({
+ type: z.literal('application.updated'),
+ payload: z.object({ version: z.string().min(1).max(64) }),
+})
+export type ApplicationChangedEvent = z.infer
+
+export const realtimeEventSchema = z.discriminatedUnion('type', [
+ z.object({
+ type: z.literal('snapshot'),
+ topic: liveTopicSchema,
+ query: z.record(z.string(), z.unknown()),
+ payload: z.unknown(),
+ }),
+ z.object({ type: z.literal('unauthorized') }),
+])
+
+export type RealtimeEvent = z.infer
+export type SnapshotEvent = Extract
diff --git a/web/vite-live.ts b/web/vite-live.ts
new file mode 100644
index 0000000..7eb0a68
--- /dev/null
+++ b/web/vite-live.ts
@@ -0,0 +1,104 @@
+import { LIVE_SNAPSHOT_PATH } from './src/websockets/Helpers/realtimeConstants'
+import { createLiveWebSocketRuntime } from './src/websockets/Server/realtimeWebSocket'
+import type { LiveSocketData } from './src/websockets/Types/bun'
+import type { Plugin, UserConfig, ViteDevServer } from 'vite'
+
+const LIVE_PROXY_PATH = '/api/live'
+
+function originHost(host: string | boolean | undefined): string {
+ if (host === true || host === '0.0.0.0' || host === '::') return 'localhost'
+ return typeof host === 'string' && host.length > 0 ? host : 'localhost'
+}
+
+function createDevelopmentOrigin(config: UserConfig): string {
+ const host = originHost(config.server?.host)
+ const port = config.server?.port ?? 5173
+ return `http://${host}:${port}`
+}
+
+export function createLiveVitePlugin(): Plugin {
+ let runtime: ReturnType | null = null
+ let sidecar: Bun.Server | null = null
+ let localOrigin: { value: string } | null = null
+ const configuredOrigin = process.env.RENTNERPROXY_PUBLIC_ORIGIN?.trim()
+ let cleaned = false
+
+ function cleanup(): void {
+ if (cleaned) return
+ cleaned = true
+ const closingRuntime = runtime
+ const closingSidecar = sidecar
+ runtime = null
+ sidecar = null
+ void closingRuntime?.shutdown()
+ closingSidecar?.stop(true)
+ }
+
+ return {
+ name: 'rentnerproxy-live-transport',
+ config(config, env) {
+ if (env.command !== 'serve') return
+
+ localOrigin = { value: createDevelopmentOrigin(config) }
+ runtime = createLiveWebSocketRuntime({
+ allowedOrigin: () => configuredOrigin ?? localOrigin?.value ?? null,
+ readSnapshot: (request) => {
+ const localOriginValue = localOrigin?.value
+ if (!localOriginValue)
+ return Promise.resolve(new Response(null, { status: 503 }))
+ const target = new URL(LIVE_SNAPSHOT_PATH, localOriginValue)
+ target.search = new URL(request.url).search
+ const headers = new Headers()
+ const cookie = request.headers.get('cookie')
+ const originHeader = request.headers.get('origin')
+ if (cookie !== null) headers.set('cookie', cookie)
+ if (originHeader !== null) headers.set('origin', originHeader)
+ return fetch(
+ new Request(target, { method: 'GET', headers, signal: request.signal }),
+ )
+ },
+ })
+ sidecar = Bun.serve({
+ hostname: '127.0.0.1',
+ port: 0,
+ websocket: runtime.websocket,
+ fetch: async (request, bunServer) => {
+ const result = await runtime?.handle(request, bunServer)
+ if (result?.handled) return result.response
+ return new Response(null, { status: 404 })
+ },
+ })
+ if (sidecar.port === undefined) throw new Error('Live transport sidecar did not bind.')
+
+ return {
+ server: {
+ proxy: {
+ [LIVE_PROXY_PATH]: {
+ target: `ws://127.0.0.1:${sidecar.port}`,
+ ws: true,
+ changeOrigin: false,
+ bypass(request) {
+ return /^\/api\/live(?:\?|$)/u.test(request.url ?? '')
+ ? undefined
+ : request.url
+ },
+ },
+ },
+ },
+ }
+ },
+ configureServer(server: ViteDevServer) {
+ if (!localOrigin || !server.httpServer) return
+ const updateOrigin = () => {
+ const resolved = server.resolvedUrls?.local[0]
+ if (resolved) localOrigin!.value = new URL(resolved).origin
+ }
+ if (server.resolvedUrls) updateOrigin()
+ else server.httpServer.once('listening', updateOrigin)
+ server.httpServer.once('close', cleanup)
+ },
+ closeBundle() {
+ cleanup()
+ },
+ }
+}
diff --git a/web/vite.config.ts b/web/vite.config.ts
index 0cf7002..1061c6f 100644
--- a/web/vite.config.ts
+++ b/web/vite.config.ts
@@ -6,6 +6,8 @@ import tailwindcss from '@tailwindcss/vite'
import viteReact from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
+import { createLiveVitePlugin } from './vite-live'
+
const webRoot = fileURLToPath(new URL('.', import.meta.url))
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const cacheDirectory = fileURLToPath(new URL('../node_modules/.vite/web', import.meta.url))
@@ -42,6 +44,7 @@ export default defineConfig({
strictPort: true,
},
plugins: [
+ createLiveVitePlugin(),
tanstackStart({
importProtection: {
client: {