182 lines
6.1 KiB
TypeScript
182 lines
6.1 KiB
TypeScript
import type { AppState } from "@/lib/state/store";
|
|
import { getBackendConfig } from "@/lib/config";
|
|
import type { FetchedSessionData } from "@/lib/ssr/session-page-state";
|
|
import type { Repository, Task, Workflow, WorkflowStep } from "@/lib/types/http";
|
|
import type { ActivePlugin } from "@/lib/plugins/types";
|
|
|
|
export type { ActivePlugin };
|
|
|
|
export type BootRoute = {
|
|
kind?: string;
|
|
route?: string;
|
|
path?: string;
|
|
params?: Record<string, string>;
|
|
};
|
|
|
|
export type BootRuntime = {
|
|
apiPrefix?: string;
|
|
webSocketPath?: string;
|
|
lspAutoInstallPreferenceLanguages?: string[];
|
|
debug?: boolean;
|
|
/**
|
|
* True for a dev or e2e build. The e2e harness serves a PRODUCTION bundle, so
|
|
* `import.meta.env.PROD` cannot distinguish it from a real release — this flag
|
|
* is what gates QA-only UI such as the pseudo-locale option.
|
|
*/
|
|
nonProduction?: boolean;
|
|
/** Active UI locale from the kandev_locale cookie; drives first-paint i18n. */
|
|
locale?: string;
|
|
/**
|
|
* Operator-configured browser tab title prefix (KANDEV_WEB_TITLE_PREFIX), so
|
|
* several Kandev instances are distinguishable in adjacent tabs. The Go shell
|
|
* already rewrites `<title>`; this covers the /api/v1/app-state boot path,
|
|
* which never renders through the shell.
|
|
*/
|
|
titlePrefix?: string;
|
|
};
|
|
|
|
export type BootRouteData = {
|
|
taskDetail?: FetchedSessionData;
|
|
routeContext?: {
|
|
activeWorkspaceId?: string | null;
|
|
workflows?: Workflow[];
|
|
steps?: WorkflowStep[];
|
|
repositories?: Repository[];
|
|
};
|
|
tasksPage?: {
|
|
activeWorkspaceId?: string | null;
|
|
workflows?: Workflow[];
|
|
steps?: WorkflowStep[];
|
|
repositories?: Repository[];
|
|
tasks?: Task[];
|
|
total?: number;
|
|
tasksListSort?: string;
|
|
tasksListGroup?: string;
|
|
};
|
|
};
|
|
|
|
export type BootPayload = {
|
|
version?: number;
|
|
route?: BootRoute;
|
|
runtime?: BootRuntime;
|
|
initialState?: Partial<AppState>;
|
|
routeData?: BootRouteData;
|
|
plugins?: ActivePlugin[];
|
|
/** Replayable per-boot CSRF/accidental-mutation interlock; not authentication. */
|
|
interimSettingsInterlockToken?: string;
|
|
};
|
|
|
|
type BootWindow = Window & {
|
|
__KANDEV_BOOT_PAYLOAD__?: unknown;
|
|
__KANDEV_DEBUG?: boolean;
|
|
};
|
|
|
|
export function readBootPayload(win: Window = window): BootPayload {
|
|
const payload = (win as BootWindow).__KANDEV_BOOT_PAYLOAD__;
|
|
if (!isRecord(payload)) return { initialState: {} };
|
|
const runtime = isRecord(payload.runtime) ? readRuntime(payload.runtime) : undefined;
|
|
if (runtime?.debug) {
|
|
(win as BootWindow).__KANDEV_DEBUG = true;
|
|
}
|
|
|
|
return {
|
|
version: typeof payload.version === "number" ? payload.version : undefined,
|
|
route: isRecord(payload.route) ? readRoute(payload.route) : undefined,
|
|
runtime,
|
|
initialState: isRecord(payload.initialState) ? (payload.initialState as Partial<AppState>) : {},
|
|
routeData: isRecord(payload.routeData) ? (payload.routeData as BootRouteData) : undefined,
|
|
plugins: Array.isArray(payload.plugins) ? readPlugins(payload.plugins) : undefined,
|
|
interimSettingsInterlockToken: readNonEmptyString(payload.interimSettingsInterlockToken),
|
|
};
|
|
}
|
|
|
|
export function readInterimSettingsInterlockToken(): string | undefined {
|
|
if (typeof window === "undefined") return undefined;
|
|
return readBootPayload(window).interimSettingsInterlockToken;
|
|
}
|
|
|
|
function readPlugins(value: unknown[]): ActivePlugin[] {
|
|
return value.filter(isRecord).flatMap((entry) => {
|
|
const plugin = readPlugin(entry);
|
|
return plugin ? [plugin] : [];
|
|
});
|
|
}
|
|
|
|
function readPlugin(value: Record<string, unknown>): ActivePlugin | undefined {
|
|
const id = readString(value.id);
|
|
const name = readString(value.name);
|
|
const bundleUrl = readString(value.bundleUrl);
|
|
if (!id || !name || !bundleUrl) return undefined;
|
|
const styleUrls = Array.isArray(value.styleUrls)
|
|
? value.styleUrls.filter((entry): entry is string => typeof entry === "string")
|
|
: undefined;
|
|
return { id, name, bundleUrl, styleUrls };
|
|
}
|
|
|
|
export async function loadBootPayload(
|
|
win: Window = window,
|
|
fetcher: typeof fetch = fetch,
|
|
): Promise<BootPayload> {
|
|
const injected = (win as BootWindow).__KANDEV_BOOT_PAYLOAD__;
|
|
if (isRecord(injected)) {
|
|
return readBootPayload(win);
|
|
}
|
|
|
|
try {
|
|
const path = `${win.location?.pathname || "/"}${win.location?.search || ""}`;
|
|
const url = new URL(`${getBackendConfig().apiBaseUrl}/api/v1/app-state`);
|
|
url.searchParams.set("path", path);
|
|
const response = await fetcher(url.toString(), { cache: "no-store", credentials: "include" });
|
|
if (!response.ok) return { initialState: {} };
|
|
const payload = await response.json();
|
|
(win as BootWindow).__KANDEV_BOOT_PAYLOAD__ = payload;
|
|
return readBootPayload(win);
|
|
} catch {
|
|
return { initialState: {} };
|
|
}
|
|
}
|
|
|
|
function readRoute(value: Record<string, unknown>): BootRoute {
|
|
return {
|
|
kind: readString(value.kind),
|
|
route: readString(value.route),
|
|
path: readString(value.path),
|
|
params: isStringRecord(value.params) ? value.params : undefined,
|
|
};
|
|
}
|
|
|
|
function readRuntime(value: Record<string, unknown>): BootRuntime {
|
|
return {
|
|
apiPrefix: readString(value.apiPrefix),
|
|
webSocketPath: readString(value.webSocketPath),
|
|
lspAutoInstallPreferenceLanguages: readStringArray(value.lspAutoInstallPreferenceLanguages),
|
|
debug: value.debug === true ? true : undefined,
|
|
nonProduction: value.nonProduction === true ? true : undefined,
|
|
locale: readString(value.locale),
|
|
titlePrefix: readString(value.titlePrefix),
|
|
};
|
|
}
|
|
|
|
function readString(value: unknown): string | undefined {
|
|
return typeof value === "string" ? value : undefined;
|
|
}
|
|
|
|
function readStringArray(value: unknown): string[] | undefined {
|
|
if (!Array.isArray(value) || !value.every((entry) => typeof entry === "string")) return undefined;
|
|
return value;
|
|
}
|
|
|
|
function readNonEmptyString(value: unknown): string | undefined {
|
|
const result = readString(value);
|
|
return result ? result : undefined;
|
|
}
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
}
|
|
|
|
function isStringRecord(value: unknown): value is Record<string, string> {
|
|
if (!isRecord(value)) return false;
|
|
return Object.values(value).every((entry) => typeof entry === "string");
|
|
}
|