feat(map): publish shared Cesium adapter
This commit is contained in:
@@ -0,0 +1,208 @@
|
||||
import type {
|
||||
MapCacheIntent,
|
||||
MapGatewayError,
|
||||
MapProviderId,
|
||||
MapProviderState,
|
||||
MapRuntimeConfiguration,
|
||||
MapRuntimeState,
|
||||
} from "./contracts.js";
|
||||
import {
|
||||
CESIUM_RENDERER_VERSION,
|
||||
MAP_PAGE_VERSION,
|
||||
MAP_RUNTIME_SCHEMA_VERSION,
|
||||
} from "./contracts.js";
|
||||
|
||||
const SAFE_ERROR_CODE = /^[a-z][a-z0-9_]{0,95}$/;
|
||||
const PROVIDER_IDS: readonly MapProviderId[] = ["imagery", "terrain", "buildings"];
|
||||
const CREDENTIAL_QUERY_KEYS = new Set([
|
||||
"access_token",
|
||||
"access-token",
|
||||
"authorization",
|
||||
"credential",
|
||||
"key",
|
||||
"signature",
|
||||
"sig",
|
||||
"token",
|
||||
]);
|
||||
|
||||
export class MapRuntimeError extends Error {
|
||||
readonly code: string;
|
||||
|
||||
constructor(code: string) {
|
||||
super(code);
|
||||
this.name = "MapRuntimeError";
|
||||
this.code = SAFE_ERROR_CODE.test(code) ? code : "map_runtime_error";
|
||||
}
|
||||
}
|
||||
|
||||
export function initialMapRuntimeState(): MapRuntimeState {
|
||||
return {
|
||||
phase: "loading",
|
||||
providers: {
|
||||
imagery: { phase: "idle" },
|
||||
terrain: { phase: "idle" },
|
||||
buildings: { phase: "idle" },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function withProviderState(
|
||||
current: MapRuntimeState,
|
||||
providerId: MapProviderId,
|
||||
providerState: MapProviderState,
|
||||
): MapRuntimeState {
|
||||
const providers = {
|
||||
...current.providers,
|
||||
[providerId]: providerState,
|
||||
};
|
||||
const enabled = PROVIDER_IDS.map((id) => providers[id]).filter(
|
||||
(state) => state.phase !== "disabled",
|
||||
);
|
||||
const readyCount = enabled.filter((state) => state.phase === "ready").length;
|
||||
const errorCount = enabled.filter((state) => state.phase === "error").length;
|
||||
const loadingCount = enabled.filter((state) =>
|
||||
["idle", "loading"].includes(state.phase),
|
||||
).length;
|
||||
const phase =
|
||||
loadingCount > 0
|
||||
? "loading"
|
||||
: errorCount === 0
|
||||
? "ready"
|
||||
: readyCount > 0
|
||||
? "degraded"
|
||||
: "gateway-unavailable";
|
||||
return {
|
||||
phase,
|
||||
providers,
|
||||
...(errorCount > 0
|
||||
? {
|
||||
code:
|
||||
enabled.find((state) => state.phase === "error")?.code ??
|
||||
"map_provider_unavailable",
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchMapRuntimeConfiguration(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<MapRuntimeConfiguration> {
|
||||
const document = await fetchSameOriginJson(path, signal);
|
||||
if (
|
||||
document.schema_version !== MAP_RUNTIME_SCHEMA_VERSION ||
|
||||
document.map_page_version !== MAP_PAGE_VERSION ||
|
||||
!isRecord(document.renderer) ||
|
||||
document.renderer.id !== "cesium" ||
|
||||
document.renderer.version !== CESIUM_RENDERER_VERSION ||
|
||||
!isRecord(document.gateway) ||
|
||||
document.gateway.configured !== true ||
|
||||
!isSafeSameOriginPath(document.gateway.health_url) ||
|
||||
!isSafeSameOriginPath(document.gateway.asset_endpoint_template) ||
|
||||
!isSafeSameOriginPath(document.gateway.cache_proxy_prefix) ||
|
||||
!isRecord(document.assets) ||
|
||||
document.assets.imagery !== 2 ||
|
||||
document.assets.terrain !== 1 ||
|
||||
document.assets.buildings !== 96188
|
||||
) {
|
||||
throw new MapRuntimeError("map_runtime_contract_mismatch");
|
||||
}
|
||||
return document as MapRuntimeConfiguration;
|
||||
}
|
||||
|
||||
export async function fetchSameOriginJson(
|
||||
path: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (!isSafeSameOriginPath(path)) {
|
||||
throw new MapRuntimeError("map_runtime_cross_origin_forbidden");
|
||||
}
|
||||
const response = await fetch(path, {
|
||||
method: "GET",
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
const document = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new MapRuntimeError(readSafeErrorCode(document));
|
||||
}
|
||||
if (!isRecord(document)) {
|
||||
throw new MapRuntimeError("map_runtime_invalid_response");
|
||||
}
|
||||
return document;
|
||||
}
|
||||
|
||||
export function assetEndpointPath(
|
||||
configuration: MapRuntimeConfiguration,
|
||||
providerId: MapProviderId,
|
||||
): string {
|
||||
const assetId = configuration.assets[providerId];
|
||||
return configuration.gateway.asset_endpoint_template.replace(
|
||||
"{asset_id}",
|
||||
String(assetId),
|
||||
);
|
||||
}
|
||||
|
||||
export function applyCacheIntent(
|
||||
rawUrl: string,
|
||||
intent: MapCacheIntent,
|
||||
refresh: boolean,
|
||||
): string {
|
||||
const url = new URL(rawUrl);
|
||||
if (url.protocol !== "https:" || url.username || url.password || url.hash) {
|
||||
throw new MapRuntimeError("map_provider_url_rejected");
|
||||
}
|
||||
for (const key of url.searchParams.keys()) {
|
||||
if (CREDENTIAL_QUERY_KEYS.has(key.toLowerCase())) {
|
||||
throw new MapRuntimeError("map_provider_credential_rejected");
|
||||
}
|
||||
}
|
||||
if (!intent.enabled) {
|
||||
url.searchParams.set("nodedc_cache_mode", "passthrough");
|
||||
} else {
|
||||
url.searchParams.set("nodedc_cache_profile", "live");
|
||||
if (!intent.no_overwrite || refresh) {
|
||||
url.searchParams.set("nodedc_cache_refresh", "1");
|
||||
}
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
export function isSafeSameOriginPath(value: unknown): value is string {
|
||||
if (typeof value !== "string" || !value.startsWith("/") || value.startsWith("//")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const resolved = new URL(value, window.location.origin);
|
||||
return resolved.origin === window.location.origin;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function readSafeErrorCode(value: unknown): string {
|
||||
if (isRecord(value)) {
|
||||
const direct = value.code;
|
||||
if (typeof direct === "string" && SAFE_ERROR_CODE.test(direct)) {
|
||||
return direct;
|
||||
}
|
||||
const error = value.error;
|
||||
if (isRecord(error) && typeof error.code === "string" && SAFE_ERROR_CODE.test(error.code)) {
|
||||
return error.code;
|
||||
}
|
||||
if (typeof error === "string" && SAFE_ERROR_CODE.test(error)) {
|
||||
return error;
|
||||
}
|
||||
}
|
||||
return "map_gateway_request_failed";
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function asMapGatewayError(value: unknown): MapGatewayError | null {
|
||||
return isRecord(value) ? (value as MapGatewayError) : null;
|
||||
}
|
||||
Reference in New Issue
Block a user