84 lines
2.8 KiB
TypeScript
84 lines
2.8 KiB
TypeScript
import type { ChatTokenProvider } from "@hivegrid/realtime";
|
|
|
|
export type ChatGatewayRouting = {
|
|
readonly protocol: "http" | "https";
|
|
readonly apiHost: string;
|
|
};
|
|
|
|
export type ChatTokenFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
|
|
|
|
export type CreateChatTokenProviderInput = {
|
|
readonly tenantSlug: string;
|
|
readonly csrfToken: string;
|
|
readonly fetchImpl?: ChatTokenFetch;
|
|
};
|
|
|
|
type ChatTokenPayload = {
|
|
readonly ok: true;
|
|
readonly data: {
|
|
readonly token: string;
|
|
};
|
|
};
|
|
|
|
/** The gateway lives on the configured API host and exposes one fixed path. */
|
|
export function buildChatWebSocketUrl(routing: ChatGatewayRouting): string {
|
|
const url = new URL(`${routing.protocol}://${routing.apiHost}`);
|
|
if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) {
|
|
throw new Error("Chat API routing must contain a host without credentials or a path.");
|
|
}
|
|
url.protocol = routing.protocol === "https" ? "wss:" : "ws:";
|
|
url.pathname = "/ws/chat";
|
|
return url.toString();
|
|
}
|
|
|
|
/** Loads a short-lived token over the same-origin session boundary. */
|
|
export function createChatTokenProvider(input: CreateChatTokenProviderInput): ChatTokenProvider {
|
|
const tenantSlug = requireNonEmpty(input.tenantSlug, "tenant slug");
|
|
const csrfToken = requireNonEmpty(input.csrfToken, "CSRF token");
|
|
const fetchImpl = input.fetchImpl ?? globalThis.fetch;
|
|
if (typeof fetchImpl !== "function") {
|
|
throw new Error("Fetch is not available; provide a fetch implementation.");
|
|
}
|
|
const tokenPath = `/${encodeURIComponent(tenantSlug)}/chat/token`;
|
|
|
|
return async () => {
|
|
const response = await fetchImpl(tokenPath, {
|
|
method: "POST",
|
|
headers: {
|
|
accept: "application/json",
|
|
"x-csrf-token": csrfToken,
|
|
},
|
|
credentials: "same-origin",
|
|
cache: "no-store",
|
|
redirect: "error",
|
|
});
|
|
if (!response.ok) throw new Error("Chat gateway token could not be loaded.");
|
|
|
|
let payload: unknown;
|
|
try {
|
|
payload = await response.json();
|
|
} catch {
|
|
throw new Error("Chat gateway token response was invalid.");
|
|
}
|
|
if (!isChatTokenPayload(payload)) {
|
|
throw new Error("Chat gateway token response was invalid.");
|
|
}
|
|
return payload.data.token;
|
|
};
|
|
}
|
|
|
|
function isChatTokenPayload(value: unknown): value is ChatTokenPayload {
|
|
if (!isRecord(value) || value.ok !== true || !isRecord(value.data)) return false;
|
|
const token = value.data.token;
|
|
return typeof token === "string" && token.length > 0 && token === token.trim();
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function requireNonEmpty(value: string, label: string): string {
|
|
if (value.length === 0 || value !== value.trim()) throw new Error(`Chat ${label} is invalid.`);
|
|
return value;
|
|
}
|