Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions helm/spritz/templates/ui-deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ spec:
env:
- name: SPRITZ_API_BASE_URL
value: {{ .Values.ui.apiBaseUrl | default (include "spritz.routeModel.apiPathPrefix" .) | quote }}
- name: SPRITZ_UI_WEBSOCKET_BASE_URL
value: {{ .Values.ui.websocketBaseUrl | quote }}
- name: SPRITZ_UI_CHAT_PATH_PREFIX
value: {{ include "spritz.routeModel.chatPathPrefix" . | quote }}
- name: SPRITZ_UI_OWNER_ID
Expand Down
1 change: 1 addition & 0 deletions helm/spritz/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -304,6 +304,7 @@ ui:
ingress:
enabled: true
apiBaseUrl: "/api"
websocketBaseUrl: ""
ownerId: ""
assetVersion: ""
presets: []
Expand Down
3 changes: 3 additions & 0 deletions ui/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
set -eu

API_BASE_URL="${SPRITZ_API_BASE_URL:-}"
WEBSOCKET_BASE_URL="${SPRITZ_UI_WEBSOCKET_BASE_URL:-}"
CHAT_PATH_PREFIX="${SPRITZ_UI_CHAT_PATH_PREFIX:-}"
OWNER_ID="${SPRITZ_UI_OWNER_ID:-}"
AUTH_MODE="${SPRITZ_UI_AUTH_MODE:-}"
Expand Down Expand Up @@ -46,6 +47,7 @@ escape_sed() {
}

API_BASE_URL_ESCAPED="$(escape_sed "$API_BASE_URL")"
WEBSOCKET_BASE_URL_ESCAPED="$(escape_sed "$WEBSOCKET_BASE_URL")"
CHAT_PATH_PREFIX_ESCAPED="$(escape_sed "$CHAT_PATH_PREFIX")"
OWNER_ID_ESCAPED="$(escape_sed "$OWNER_ID")"
AUTH_MODE_ESCAPED="$(escape_sed "$AUTH_MODE")"
Expand Down Expand Up @@ -76,6 +78,7 @@ BRANDING_CONFIG_ESCAPED="$(escape_sed "$BRANDING_CONFIG_VALUE")"
ASSET_VERSION_ESCAPED="$(escape_sed "$ASSET_VERSION")"

sed "s|__SPRITZ_API_BASE_URL__|${API_BASE_URL_ESCAPED}|g" "${HTML_DIR}/config.js" \
| sed "s|__SPRITZ_UI_WEBSOCKET_BASE_URL__|${WEBSOCKET_BASE_URL_ESCAPED}|g" \
| sed "s|__SPRITZ_UI_CHAT_PATH_PREFIX__|${CHAT_PATH_PREFIX_ESCAPED}|g" \
| sed "s|__SPRITZ_OWNER_ID__|${OWNER_ID_ESCAPED}|g" \
| sed "s|__SPRITZ_UI_AUTH_MODE__|${AUTH_MODE_ESCAPED}|g" \
Expand Down
1 change: 1 addition & 0 deletions ui/public/config.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
window.SPRITZ_CONFIG = {
apiBaseUrl: '__SPRITZ_API_BASE_URL__',
websocketBaseUrl: '__SPRITZ_UI_WEBSOCKET_BASE_URL__',
chatPathPrefix: '__SPRITZ_UI_CHAT_PATH_PREFIX__',
ownerId: '__SPRITZ_OWNER_ID__',
presets: __SPRITZ_UI_PRESETS__,
Expand Down
19 changes: 19 additions & 0 deletions ui/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,25 @@ export function getAuthToken(): string {
return readTokenFromStorage(authTokenStorageKeys);
}

/**
* Attempts the configured bearer refresh flow for direct WebSocket connections.
* Returns the latest stored bearer token and whether a refresh succeeded.
*/
export async function refreshAuthTokenForWebSocket(): Promise<{
token: string;
refreshed: boolean;
}> {
const token = getAuthToken();
if (!shouldAttemptAuthRefresh()) {
return { token, refreshed: false };
}
const refreshResult = await runAuthRefresh();
return {
token: getAuthToken(),
refreshed: refreshResult.ok,
};
}

function getAuthRefreshToken(): string {
return readTokenFromStorage(authRefreshTokenStorageKeys);
}
Expand Down
10 changes: 10 additions & 0 deletions ui/src/lib/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,14 @@ describe('resolveConfig', () => {
expect(config.branding.theme.background).toBe('');
expect(config.branding.terminal.background).toBe('');
});

it('preserves websocket base overrides separately from the api base url', () => {
const config = resolveConfig({
apiBaseUrl: 'https://api.example.com/base',
websocketBaseUrl: 'https://ws.example.com/base',
});

expect(config.apiBaseUrl).toBe('https://api.example.com/base');
expect(config.websocketBaseUrl).toBe('https://ws.example.com/base');
});
});
2 changes: 2 additions & 0 deletions ui/src/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ export interface Preset {

export interface SpritzConfig {
apiBaseUrl: string;
websocketBaseUrl: string;
chatPathPrefix: string;
ownerId: string;
presets: Preset[] | string;
Expand All @@ -100,6 +101,7 @@ declare global {
export function resolveConfig(raw: RawSpritzConfig = {}): SpritzConfig {
return {
apiBaseUrl: raw.apiBaseUrl || '',
websocketBaseUrl: raw.websocketBaseUrl || '',
chatPathPrefix: raw.chatPathPrefix || '/c',
ownerId: raw.ownerId || '',
presets: raw.presets || [],
Expand Down
81 changes: 81 additions & 0 deletions ui/src/lib/network.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
const URL_PARSE_BASE = 'http://spritz.local';

function resolveLocationHref(locationHref?: string): string {
if (locationHref) return locationHref;
if (typeof window !== 'undefined' && window.location?.href) {
return window.location.href;
}
return `${URL_PARSE_BASE}/`;
}

function normalizeApiBaseUrl(apiBaseUrl: string, locationHref?: string): URL {
const trimmed = String(apiBaseUrl || '').trim();
const base = trimmed || '/';
return new URL(base, resolveLocationHref(locationHref));
}

function normalizeWebSocketBaseUrl(
apiBaseUrl: string,
websocketBaseUrl?: string,
locationHref?: string,
): URL {
const location = new URL(resolveLocationHref(locationHref));
const explicitBase = String(websocketBaseUrl || '').trim();
if (explicitBase) {
return new URL(explicitBase, location.href);
}
const apiUrl = normalizeApiBaseUrl(apiBaseUrl, locationHref);
const sameHostUrl = new URL(location.origin);
sameHostUrl.pathname = apiUrl.pathname;
sameHostUrl.search = apiUrl.search;
sameHostUrl.hash = apiUrl.hash;
return sameHostUrl;
}

function normalizeRelativePath(path: string): URL {
const trimmed = String(path || '').trim();
const normalized = trimmed.startsWith('/') ? trimmed : `/${trimmed}`;
return new URL(normalized || '/', URL_PARSE_BASE);
}

function joinPaths(basePath: string, relativePath: string): string {
const normalizedBase = `/${String(basePath || '').replace(/^\/+|\/+$/g, '')}`;
const normalizedRelative = String(relativePath || '').replace(/^\/+/, '');
if (!normalizedRelative) return normalizedBase === '/' ? '/' : normalizedBase;
if (normalizedBase === '/') return `/${normalizedRelative}`;
return `${normalizedBase}/${normalizedRelative}`;
}

export function buildApiWebSocketUrl(
apiBaseUrl: string,
path: string,
options?: {
bearerToken?: string;
bearerTokenParam?: string;
websocketBaseUrl?: string;
locationHref?: string;
},
): string {
const url = normalizeWebSocketBaseUrl(
apiBaseUrl,
options?.websocketBaseUrl,
options?.locationHref,
);
const relative = normalizeRelativePath(path);
url.pathname = joinPaths(url.pathname, relative.pathname);
url.search = relative.search;
url.hash = relative.hash;
if (url.protocol === 'https:') {
url.protocol = 'wss:';
} else if (url.protocol === 'http:') {
url.protocol = 'ws:';
}
const bearerToken = String(options?.bearerToken || '').trim();
if (bearerToken) {
url.searchParams.set(
String(options?.bearerTokenParam || 'token').trim() || 'token',
bearerToken,
);
}
return url.toString();
}
Loading
Loading