mirror of
https://github.com/Monadical-SAS/reflector.git
synced 2026-03-22 07:06:47 +00:00
* feat: add Caddy reverse proxy with auto HTTPS for LAN access and auto-derive WebSocket URL Add a Caddy service to docker-compose.standalone.yml that provides automatic HTTPS with local certificates, enabling secure access to both the frontend and API from the local network through a single entrypoint. Backend changes: - Add ROOT_PATH setting to FastAPI so the API can be served under /api prefix - Route frontend and API (/server-api) through Caddy reverse proxy Frontend changes: - Support WEBSOCKET_URL=auto to derive the WebSocket URL from API_URL automatically, using the page protocol (http→ws, https→wss) and host - Make WEBSOCKET_URL env var optional instead of required * style: pre-commit * fix: make standalone compose self-contained (drop !reset dependency) docker-compose.standalone.yml used !reset YAML tags to clear network_mode and volumes from the base compose. !reset requires Compose v2.24+ and breaks on Colima + brew-installed compose. Rewrite as a fully self-contained file with all services defined directly (server, worker, beat, redis, postgres, web, garage, cpu, gpu-nvidia, ollama, ollama-cpu). No longer overlays docker-compose.yml. Update setup-standalone.sh compose_cmd() to use only the standalone file instead of both files. * fix: update standalone docs to match self-contained compose usage --------- Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
95 lines
3.1 KiB
TypeScript
95 lines
3.1 KiB
TypeScript
"use client";
|
|
|
|
import createClient from "openapi-fetch";
|
|
import type { paths } from "../reflector-api";
|
|
import createFetchClient from "openapi-react-query";
|
|
import { parseNonEmptyString } from "./utils";
|
|
import { isBuildPhase } from "./next";
|
|
import { getSession } from "next-auth/react";
|
|
import { assertExtendedToken } from "./types";
|
|
import { getClientEnv } from "./clientEnv";
|
|
|
|
export const API_URL = !isBuildPhase
|
|
? getClientEnv().API_URL
|
|
: "http://localhost";
|
|
|
|
/**
|
|
* Derive a WebSocket URL from the API_URL.
|
|
* Handles full URLs (http://host/api, https://host/api) and relative paths (/api).
|
|
* For full URLs, ws/wss is derived from the URL's own protocol.
|
|
* For relative URLs, ws/wss is derived from window.location.protocol.
|
|
*/
|
|
const deriveWebSocketUrl = (apiUrl: string): string => {
|
|
if (typeof window === "undefined") {
|
|
return "ws://localhost";
|
|
}
|
|
const parsed = new URL(apiUrl, window.location.origin);
|
|
const wsProtocol = parsed.protocol === "https:" ? "wss:" : "ws:";
|
|
// Normalize: remove trailing slash from pathname
|
|
const pathname = parsed.pathname.replace(/\/+$/, "");
|
|
return `${wsProtocol}//${parsed.host}${pathname}`;
|
|
};
|
|
|
|
const resolveWebSocketUrl = (): string => {
|
|
if (isBuildPhase) return "ws://localhost";
|
|
const raw = getClientEnv().WEBSOCKET_URL;
|
|
if (!raw || raw === "auto") {
|
|
return deriveWebSocketUrl(API_URL);
|
|
}
|
|
return raw;
|
|
};
|
|
|
|
export const WEBSOCKET_URL = resolveWebSocketUrl();
|
|
|
|
export const client = createClient<paths>({
|
|
baseUrl: API_URL,
|
|
});
|
|
|
|
// will assert presence/absence of login initially
|
|
const initialSessionPromise = getSession();
|
|
|
|
const waitForAuthTokenDefinitivePresenceOrAbsence = async () => {
|
|
const initialSession = await initialSessionPromise;
|
|
if (currentAuthToken === undefined) {
|
|
currentAuthToken =
|
|
initialSession === null
|
|
? null
|
|
: assertExtendedToken(initialSession).accessToken;
|
|
}
|
|
// otherwise already overwritten by external forces
|
|
return currentAuthToken;
|
|
};
|
|
|
|
client.use({
|
|
async onRequest({ request }) {
|
|
const token = await waitForAuthTokenDefinitivePresenceOrAbsence();
|
|
if (token !== null) {
|
|
request.headers.set(
|
|
"Authorization",
|
|
`Bearer ${parseNonEmptyString(token, true, "panic! token is required")}`,
|
|
);
|
|
}
|
|
// XXX Only set Content-Type if not already set (FormData will set its own boundary)
|
|
// This is a work around for uploading file, we're passing a formdata
|
|
// but the content type was still application/json
|
|
if (
|
|
!request.headers.has("Content-Type") &&
|
|
!(request.body instanceof FormData)
|
|
) {
|
|
request.headers.set("Content-Type", "application/json");
|
|
}
|
|
return request;
|
|
},
|
|
});
|
|
|
|
export const $api = createFetchClient<paths>(client);
|
|
|
|
let currentAuthToken: string | null | undefined = undefined;
|
|
|
|
// the function contract: lightweight, idempotent
|
|
export const configureApiAuth = (token: string | null | undefined) => {
|
|
// watch only for the initial loading; "reloading" state assumes token presence/absence
|
|
if (token === undefined && currentAuthToken !== undefined) return;
|
|
currentAuthToken = token;
|
|
};
|