242 lines
7.6 KiB
JavaScript
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();
|
|
}
|
|
};
|