feat(config): add server config + can disable message-bus
- server now provide it's config via /api/config (possibility to feature flag) - client use /api/config to enable / disable some features - capability to disable the message bus, somme OPS may not want this feature and consume persistent connections from server (websocket): OXICLOUD_MESSAGEBUS_ENABLE (true by default)
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
import log from 'loglevel';
|
||||
import { setSessionExpiredHandler } from '$lib/api/client';
|
||||
import { initI18n } from '$lib/i18n/index.svelte';
|
||||
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||
import { session } from '$lib/stores/session.svelte';
|
||||
import { seedNonceFromCookie } from '$lib/auth/dpop-proof';
|
||||
|
||||
@@ -14,7 +15,8 @@ import { seedNonceFromCookie } from '$lib/auth/dpop-proof';
|
||||
// needing to import anything.
|
||||
//
|
||||
// Log levels — namespaces used today: `oxi:upload` (delta + direct
|
||||
// upload pipeline), `oxi:message-bus` (WebSocket client + `useTopic`).
|
||||
// upload pipeline), `oxi:message-bus` (WebSocket client + `useTopic`),
|
||||
// `oxi:config` (server-config boot fetch).
|
||||
// Levels: 'trace' | 'debug' | 'info' | 'warn' | 'error' | 'silent'.
|
||||
// Choices persist to `localStorage['loglevel:<namespace>']` via loglevel.
|
||||
//
|
||||
@@ -119,5 +121,12 @@ export async function init(): Promise<void> {
|
||||
// bound request and eat a `use_dpop_nonce` 401 → retry cycle.
|
||||
seedNonceFromCookie();
|
||||
|
||||
await initI18n();
|
||||
// Boot in parallel: translations and server-config discovery are
|
||||
// independent of each other, and both must resolve before any route
|
||||
// mounts. `serverConfig.load()` primes the reactive feature-flag
|
||||
// store; `useTopic` / `useFolderTopic` / `useReconnect` read from
|
||||
// it to decide whether to open a WebSocket at all. See
|
||||
// `stores/serverConfig.svelte.ts` for the failure semantics
|
||||
// (defaults preserved on fetch error).
|
||||
await Promise.all([initI18n(), serverConfig.load()]);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* `GET /api/config` — public server-configuration discovery.
|
||||
*
|
||||
* Called once at SPA boot from `hooks.client.ts` to hydrate the
|
||||
* `serverConfig` reactive store. Feature flags and server-status live
|
||||
* side-by-side on the response so a single round-trip primes the FE
|
||||
* for the whole session. Subsequent live status changes propagate
|
||||
* through the `X-Server-Status` response header (same shape).
|
||||
*
|
||||
* Unauthenticated — no session cookie required. Nothing on this
|
||||
* endpoint is per-user or privacy-sensitive.
|
||||
*/
|
||||
|
||||
import { apiJson } from '$lib/api/client';
|
||||
import type { ServerConfig } from '$lib/api/types';
|
||||
|
||||
export function fetchServerConfig(): Promise<ServerConfig> {
|
||||
return apiJson<ServerConfig>('/api/config');
|
||||
}
|
||||
@@ -878,3 +878,56 @@ export interface AdminSessionsPage {
|
||||
* but any in-flight JWT stays valid until its `exp`. */
|
||||
access_token_expiry_secs: number;
|
||||
}
|
||||
|
||||
// ── /api/config — public server-configuration discovery ────────────────────
|
||||
|
||||
/** Boolean matrix of enabled optional subsystems. Mirrors the server's
|
||||
* `FeaturesConfig`; adding a field is additive (clients ignore unknown
|
||||
* fields, no field is ever repurposed — same discipline as JSON-RPC
|
||||
* error codes on the message bus). */
|
||||
export interface ServerFeatures {
|
||||
/** Message bus over WebSocket. When `false`, `/api/rt/ws` and
|
||||
* `/api/rt/ticket` are unmounted server-side — clients skip WS setup
|
||||
* entirely (see `$lib/message-bus/client.svelte.ts`). */
|
||||
message_bus: boolean;
|
||||
trash: boolean;
|
||||
search: boolean;
|
||||
sharing: boolean;
|
||||
quotas: boolean;
|
||||
music: boolean;
|
||||
places: boolean;
|
||||
faces: boolean;
|
||||
video_thumbnails: boolean;
|
||||
external_mounts: boolean;
|
||||
}
|
||||
|
||||
/** One row in `ServerStatus.migration` / `ServerStatus.rotation` — a
|
||||
* server-side long-running operation surfacing its progress to the SPA
|
||||
* banner. Same JSON shape both fields share. */
|
||||
export interface ServerStatusProgress {
|
||||
/** Short target name (e.g. `"backend_migration"`, `"rotation_v2"`). */
|
||||
target: string;
|
||||
migrated: number;
|
||||
total: number;
|
||||
/** Integer 0-100. */
|
||||
percent: number;
|
||||
}
|
||||
|
||||
/** Live server-status snapshot. Same shape and field names as the
|
||||
* `X-Server-Status` header stamped on every response — the boot fetch
|
||||
* from `/api/config` and the per-request header both share this wire
|
||||
* vocabulary. Field-level absence means "nothing running"; the client
|
||||
* can safely assume `readonly === false && !migration && !rotation` is
|
||||
* the normal case. */
|
||||
export interface ServerStatus {
|
||||
readonly: boolean;
|
||||
migration?: ServerStatusProgress;
|
||||
rotation?: ServerStatusProgress;
|
||||
}
|
||||
|
||||
/** Response of `GET /api/config`. Public, unauthenticated. */
|
||||
export interface ServerConfig {
|
||||
version: string;
|
||||
features: ServerFeatures;
|
||||
server_status: ServerStatus;
|
||||
}
|
||||
|
||||
@@ -11,16 +11,20 @@
|
||||
// `project_message_bus_reconnect_gap` memory for the gap it closes.
|
||||
|
||||
import { messageBus } from '$lib/message-bus/client.svelte';
|
||||
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||
|
||||
/**
|
||||
* Register `cb` as a reconnect handler for the lifetime of the
|
||||
* calling component. Auto-unregisters on destroy via `$effect`
|
||||
* cleanup. Passing `null`/`undefined` is a no-op — convenient for
|
||||
* conditional wiring (`useReconnect(handlers.onReconnect)`).
|
||||
*
|
||||
* Also a no-op when the server has the message bus disabled — the
|
||||
* WS never opens, so a reconnect callback can never fire.
|
||||
*/
|
||||
export function useReconnect(cb: (() => void) | null | undefined): void {
|
||||
$effect(() => {
|
||||
if (!cb) return;
|
||||
if (!cb || !serverConfig.features.message_bus) return;
|
||||
const release = messageBus.onReconnect(cb);
|
||||
return () => release();
|
||||
});
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
// re-subscribes when it changes. Static `topic`: pass a plain string.
|
||||
|
||||
import { messageBus } from '$lib/message-bus/client.svelte';
|
||||
import { serverConfig } from '$lib/stores/serverConfig.svelte';
|
||||
import type RtEventParams from '$lib/generated/message-bus/RtEventParams';
|
||||
import type RtRevokedParams from '$lib/generated/message-bus/RtRevokedParams';
|
||||
|
||||
@@ -32,6 +33,13 @@ export function useTopic(
|
||||
onRevoked?: (params: RtRevokedParams) => void
|
||||
): void {
|
||||
$effect(() => {
|
||||
// Server may have the message bus disabled (`/api/rt/ws` route
|
||||
// unmounted → 404). Skip the subscribe entirely to avoid a
|
||||
// pointless connect + circuit-breaker cycle. `serverConfig` is
|
||||
// loaded before any route mounts (`hooks.client.ts` awaits it),
|
||||
// so this read reflects the real server value, not the
|
||||
// pre-load default.
|
||||
if (!serverConfig.features.message_bus) return;
|
||||
const resolved = typeof topic === 'function' ? topic() : topic;
|
||||
if (!resolved) return;
|
||||
const release = messageBus.subscribe(resolved, onEvent, onRevoked);
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Server-configuration store — hydrated once at SPA boot from
|
||||
* `GET /api/config`.
|
||||
*
|
||||
* Exposes feature-flag and server-status snapshots that the rest of
|
||||
* the app reads reactively to enable/disable optional UI. The most
|
||||
* consequential consumer today is the message bus: `useTopic`,
|
||||
* `useFolderTopic`, and `useReconnect` all return early when
|
||||
* `serverConfig.features.message_bus === false`, so a deployment
|
||||
* with the bus disabled produces zero WS traffic from the client.
|
||||
*
|
||||
* Boot order (see `hooks.client.ts`): this store's `load()` runs
|
||||
* alongside `initI18n()` before any route mounts, guaranteeing every
|
||||
* composable reads a real value (never the pre-load defaults).
|
||||
*
|
||||
* Failure to load `/api/config` (network error, 5xx) leaves the
|
||||
* defaults in place — every feature `true`, `readonly: false`. That's
|
||||
* the pre-flag behavior; downstream WS setup then hits its own
|
||||
* failure paths (503 for the endpoint if truly disabled, circuit
|
||||
* breaker after 20 retries) instead of crashing boot. A warn line is
|
||||
* logged either way so operators can spot the failure.
|
||||
*/
|
||||
|
||||
import log from 'loglevel';
|
||||
|
||||
import { fetchServerConfig } from '$lib/api/endpoints/config';
|
||||
import type { ServerConfig, ServerFeatures, ServerStatus } from '$lib/api/types';
|
||||
|
||||
/** Sensible defaults for every field. Used before `load()` resolves
|
||||
* and as the fallback if the fetch fails — every feature enabled,
|
||||
* server status nominal. Matches the pre-`OXICLOUD_MESSAGEBUS_ENABLE`
|
||||
* behavior so an SPA that can't reach the endpoint still tries the
|
||||
* same code paths it always did. */
|
||||
const DEFAULT_FEATURES: ServerFeatures = {
|
||||
message_bus: true,
|
||||
trash: true,
|
||||
search: true,
|
||||
sharing: true,
|
||||
quotas: false,
|
||||
music: true,
|
||||
places: true,
|
||||
faces: false,
|
||||
video_thumbnails: true,
|
||||
external_mounts: false
|
||||
};
|
||||
|
||||
const DEFAULT_STATUS: ServerStatus = {
|
||||
readonly: false
|
||||
};
|
||||
|
||||
const cfgLog = log.getLogger('oxi:config');
|
||||
|
||||
class ServerConfigStore {
|
||||
/** Server version — populated after `load()`. `null` before. */
|
||||
version = $state<string | null>(null);
|
||||
/** Feature flags. Defaults are all-enabled so pre-load code paths
|
||||
* don't accidentally hide UI while the fetch is in flight. */
|
||||
features = $state<ServerFeatures>({ ...DEFAULT_FEATURES });
|
||||
/** Server-status snapshot. Live changes after `load()` propagate
|
||||
* through the `X-Server-Status` header (see
|
||||
* `stores/serverStatus.svelte.ts` — separate store, updated by
|
||||
* `apiFetch`). This store's `server_status` reflects only the
|
||||
* boot snapshot; consumers that need live status should read
|
||||
* the other store. */
|
||||
serverStatus = $state<ServerStatus>({ ...DEFAULT_STATUS });
|
||||
/** `true` once `load()` has resolved (success OR failure). Guards
|
||||
* callers that want to skip work until the boot snapshot is in. */
|
||||
loaded = $state(false);
|
||||
|
||||
async load(): Promise<void> {
|
||||
try {
|
||||
const cfg: ServerConfig = await fetchServerConfig();
|
||||
this.version = cfg.version;
|
||||
this.features = cfg.features;
|
||||
this.serverStatus = cfg.server_status;
|
||||
cfgLog.debug('server config loaded', {
|
||||
version: cfg.version,
|
||||
message_bus: cfg.features.message_bus
|
||||
});
|
||||
} catch (err) {
|
||||
// Fall through to defaults — SPA still boots. Any feature
|
||||
// actually disabled server-side will surface as a 404 at
|
||||
// call time (which is fine — that's how the guards are
|
||||
// designed to be observable).
|
||||
cfgLog.warn('server config fetch failed — using defaults', { error: err });
|
||||
} finally {
|
||||
this.loaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const serverConfig = new ServerConfigStore();
|
||||
Reference in New Issue
Block a user