Files
pleno-vue/src/services/releaseBootstrap.js
T

242 lines
7.6 KiB
JavaScript

import { API_URL, RELEASE_PUBLIC_GATEWAY_API_URL } from "@/config.js";
import { buildReleaseHeaders } from "@/services/releaseHeaders.js";
export const RELEASE_RUNTIME_GLOBAL_KEY = "__TRUCKWASH_RELEASE_RUNTIME__";
export const RELEASE_CHANNEL_SELECTION_STORAGE_KEY = "release_channel_selected_slug";
export const RELEASE_ENTRY_FILENAME = "release-entry.json";
const normalizeReleaseChannelSlug = (value) =>
String(value || "")
.trim()
.toLowerCase()
.replace(/[^a-z0-9_-]/g, "-")
.replace(/-+/g, "-")
.replace(/^-|-$/g, "")
.slice(0, 64);
const normalizeBaseUrl = (value) => String(value || "").trim().replace(/\/+$/, "");
const browserStorage = () => {
if (typeof window === "undefined") {
return null;
}
try {
return window.localStorage || null;
} catch {
return null;
}
};
const readSelectedReleaseChannel = () =>
normalizeReleaseChannelSlug(browserStorage()?.getItem(RELEASE_CHANNEL_SELECTION_STORAGE_KEY) || "");
const releaseChannelGatewayApiBaseUrl = (channelSlug, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
const slug = normalizeReleaseChannelSlug(channelSlug);
if (!slug || slug === "stable") {
return "";
}
const baseUrl = normalizeBaseUrl(gatewayBaseUrl);
if (!baseUrl || !/^https?:\/\//i.test(baseUrl)) {
return "";
}
return new URL(`${slug}/api/`, `${baseUrl}/`).href.replace(/\/+$/, "");
};
const buildRuntimeHeaders = () => {
const storage = browserStorage();
const headers = {
Accept: "application/json",
...buildReleaseHeaders({ channelSlug: readSelectedReleaseChannel() }),
};
const token = storage?.getItem("token");
if (token) {
headers.Authorization = `Bearer ${token}`;
}
const selectedCustomerNumber = storage?.getItem("selected_customer_number");
if (storage?.getItem("is_subuser") === "true" && selectedCustomerNumber) {
headers["X-Customer-Number"] = selectedCustomerNumber;
}
return headers;
};
export const runtimeApiUrl = (apiBaseUrl = API_URL, gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL) => {
const selectedChannel = readSelectedReleaseChannel();
const selectedChannelApiBaseUrl = releaseChannelGatewayApiBaseUrl(selectedChannel, gatewayBaseUrl);
const runtimeBaseUrl = selectedChannelApiBaseUrl || normalizeBaseUrl(apiBaseUrl);
const url = new URL("release/runtime", `${runtimeBaseUrl}/`);
if (selectedChannel) {
url.searchParams.set("release_channel", selectedChannel);
}
return url.href;
};
const parseJsonResponse = async (response, label) => {
if (typeof response?.text === "function") {
const body = await response.text();
try {
return JSON.parse(body);
} catch (error) {
const prefix = body.trim().slice(0, 120);
const details = prefix ? ` Body starts with: ${prefix}` : "";
throw new Error(`${label} returned invalid JSON.${details}`, { cause: error });
}
}
return response?.json?.();
};
export const fetchReleaseRuntime = async ({
fetchFn = globalThis.fetch,
apiBaseUrl = API_URL,
gatewayBaseUrl = RELEASE_PUBLIC_GATEWAY_API_URL,
} = {}) => {
if (typeof fetchFn !== "function") {
return null;
}
const response = await fetchFn(runtimeApiUrl(apiBaseUrl, gatewayBaseUrl), {
method: "GET",
headers: buildRuntimeHeaders(),
credentials: "omit",
cache: "no-store",
});
if (!response?.ok) {
throw new Error(`Release runtime request failed with HTTP ${response?.status || 0}.`);
}
const payload = await parseJsonResponse(response, "Release runtime");
return payload?.data || payload || null;
};
const runtimeFrontendBaseUrl = (runtime = {}) => {
const urls = runtime?.urls && typeof runtime.urls === "object" ? runtime.urls : {};
return normalizeBaseUrl(runtime?.frontend_base_url || urls.frontend_base_url || "");
};
const runtimeChannel = (runtime = {}) => runtime?.channel || {};
export const shouldLoadRemoteRelease = (runtime = {}) => {
const channel = runtimeChannel(runtime);
const slug = normalizeReleaseChannelSlug(channel?.slug || "");
const isDefault = channel?.default_channel === true || channel?.default_channel === 1 || slug === "stable";
return !isDefault && runtime?.availability?.configured !== false && Boolean(runtimeFrontendBaseUrl(runtime));
};
export const releaseEntryUrl = (frontendBaseUrl) =>
new URL(RELEASE_ENTRY_FILENAME, `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
const resolveReleaseAssetUrl = (frontendBaseUrl, value) =>
new URL(String(value || "").replace(/^\/+/, ""), `${normalizeBaseUrl(frontendBaseUrl)}/`).href;
export const loadRemoteReleaseEntry = async ({
runtime,
fetchFn = globalThis.fetch,
documentRef = globalThis.document,
importModule = (url) => import(/* @vite-ignore */ url),
} = {}) => {
const frontendBaseUrl = runtimeFrontendBaseUrl(runtime);
const response = await fetchFn(releaseEntryUrl(frontendBaseUrl), {
method: "GET",
cache: "no-store",
mode: "cors",
});
if (!response?.ok) {
throw new Error(`Release entry request failed with HTTP ${response?.status || 0}.`);
}
const entry = await parseJsonResponse(response, "Release entry");
const entryModule = String(entry?.entry || "").trim();
if (!entryModule) {
throw new Error("Release entry is missing an app module.");
}
for (const cssFile of Array.isArray(entry?.css) ? entry.css : []) {
const href = resolveReleaseAssetUrl(frontendBaseUrl, cssFile);
if (documentRef?.querySelector?.(`link[data-release-entry-css="${href}"]`)) {
continue;
}
const link = documentRef.createElement("link");
link.rel = "stylesheet";
link.href = href;
link.crossOrigin = "anonymous";
link.dataset.releaseEntryCss = href;
documentRef.head.appendChild(link);
}
return importModule(resolveReleaseAssetUrl(frontendBaseUrl, entryModule));
};
const unavailableRuntime = (runtime, missing) => ({
...(runtime || {}),
availability: {
...(runtime?.availability || {}),
configured: false,
missing: Array.from(new Set([...(runtime?.availability?.missing || []), missing])),
status: "unconfigured",
},
});
const unavailableSelectedRuntime = (missing) => {
const selectedChannel = readSelectedReleaseChannel();
if (!selectedChannel || selectedChannel === "stable") {
return null;
}
return unavailableRuntime(
{
channel: {
slug: selectedChannel,
name: selectedChannel,
default_channel: false,
},
availability: {
explicit: true,
},
},
missing
);
};
export const setReleaseRuntimeGlobal = (runtime) => {
if (typeof window !== "undefined") {
window[RELEASE_RUNTIME_GLOBAL_KEY] = runtime || null;
}
return runtime;
};
export const bootstrapReleaseApp = async ({
loadLocalApp,
fetchFn = globalThis.fetch,
importModule,
documentRef = globalThis.document,
} = {}) => {
if (typeof loadLocalApp !== "function") {
throw new Error("Release bootstrap requires a local app loader.");
}
let runtime = null;
try {
runtime = await fetchReleaseRuntime({ fetchFn });
setReleaseRuntimeGlobal(runtime);
} catch (error) {
console.warn("Could not resolve release runtime before app bootstrap.", error);
const unavailable = unavailableSelectedRuntime("release_runtime");
if (unavailable) {
setReleaseRuntimeGlobal(unavailable);
}
}
if (!shouldLoadRemoteRelease(runtime)) {
return loadLocalApp();
}
try {
return await loadRemoteReleaseEntry({ runtime, fetchFn, importModule, documentRef });
} catch (error) {
console.error("Could not load release channel frontend entry.", error);
setReleaseRuntimeGlobal(unavailableRuntime(runtime, "frontend_entry"));
return loadLocalApp();
}
};