diff --git a/docs/MAP_CESIUM_REACT_ADAPTER.md b/docs/MAP_CESIUM_REACT_ADAPTER.md new file mode 100644 index 0000000..92225e5 --- /dev/null +++ b/docs/MAP_CESIUM_REACT_ADAPTER.md @@ -0,0 +1,52 @@ +# Shared Cesium React adapter + +`@nodedc/map-cesium-react 0.1.0` is the shared renderer boundary for the +canonical `Map Page 0.1.0` and CesiumJS `1.143.0`. + +It is not a Foundry application component and does not own product navigation, +Inspector fields, tokens, provider allowlists, TileCache storage or admin +settings. A product supplies a same-origin BFF runtime contract and persisted +provider-neutral view state. + +## Security and runtime contract + +- The browser receives only same-origin BFF paths. +- Runtime config must use `missioncore.map-runtime/v1`, Map Page `0.1.0` and + Cesium `1.143.0`. +- Canonical assets are fixed to imagery `2`, terrain `1` and buildings + `96188`. +- Provider endpoint documents must declare `credentialMode=gateway`. +- Every provider resource uses the BFF cache proxy through Cesium + `DefaultProxy`. +- Direct provider credentials, Gateway origins and arbitrary proxy paths are + rejected. +- Imagery, terrain and buildings start independently. One failed provider + yields a degraded scene when another provider remains ready. +- Render-loop recovery is bounded to one request; a repeated fault becomes + `cesium_render_error`. + +## Asset delivery + +The package keeps Cesium in a dynamic import so the application receives it as +a lazy chunk only when the map renderer mounts. Consumers import +`@nodedc/map-cesium-react/widgets.css` and copy the four runtime directories +from `cesium/Build/Cesium` into the deployed `CESIUM_BASE_URL`: + +- `Assets`; +- `ThirdParty`; +- `Widgets`; +- `Workers`. + +The build/runtime integration must not copy Cesium credentials or NODE.DC +TileCache data. Those remain outside application artifacts. + +## Cache intent + +`cacheIntent.enabled=false` adds `nodedc_cache_mode=passthrough` while +preserving the Gateway security boundary. Enabled mode selects the shared +`live` profile. `no_overwrite=false` or a separate explicit cache-refresh +request adds `nodedc_cache_refresh=1`. Changing `rendererGeneration` only +recreates the local Cesium viewer and never changes the TileCache write intent. + +The package never creates browser, application, Docker or per-user TileCache +storage. diff --git a/package-lock.json b/package-lock.json index 796c809..e58a60b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -921,6 +921,10 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@nodedc/map-cesium-react": { + "resolved": "packages/map-cesium-react", + "link": true + }, "node_modules/@nodedc/page-patterns": { "resolved": "packages/page-patterns", "link": true @@ -2456,6 +2460,20 @@ "dev": true, "license": "ISC" }, + "packages/map-cesium-react": { + "name": "@nodedc/map-cesium-react", + "version": "0.1.0", + "dependencies": { + "cesium": "1.143.0" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "react": "^19.1.0" + }, + "peerDependencies": { + "react": ">=18" + } + }, "packages/page-patterns": { "name": "@nodedc/page-patterns", "version": "0.1.0" diff --git a/package.json b/package.json index 02fc7a4..a6206d2 100644 --- a/package.json +++ b/package.json @@ -9,8 +9,8 @@ "apps/*" ], "scripts": { - "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/ui-catalog", - "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns", + "build": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react && npm run build --workspace @nodedc/ui-catalog", + "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react && npm run build --workspace @nodedc/page-patterns && npm run build --workspace @nodedc/map-cesium-react", "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry && npm run test:floating-position && npm run test:inspector-select && npm run test:range-control && npm run test:hgeozone-projection && npm run test:map-object-layers && npm run test:map-inspector-overlay-state && npm run test:map-reference-stations && npm run test:map-search && npm run test:map-subject-card && npm run test:map-subject-detail-profile", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", "serve": "node server/catalog-server.mjs", diff --git a/packages/map-cesium-react/package.json b/packages/map-cesium-react/package.json new file mode 100644 index 0000000..4047443 --- /dev/null +++ b/packages/map-cesium-react/package.json @@ -0,0 +1,33 @@ +{ + "name": "@nodedc/map-cesium-react", + "version": "0.1.0", + "description": "Shared credential-free Cesium adapter for NODE.DC Map Page consumers.", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js" + }, + "./widgets.css": "./dist/widgets.css" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.json && cp ../../node_modules/cesium/Build/Cesium/Widgets/widgets.css dist/widgets.css", + "test": "npm run build && node --test test/*.test.mjs", + "typecheck": "tsc -p tsconfig.json --noEmit" + }, + "dependencies": { + "cesium": "1.143.0" + }, + "peerDependencies": { + "react": ">=18" + }, + "devDependencies": { + "@types/react": "^19.1.0", + "react": "^19.1.0" + } +} diff --git a/packages/map-cesium-react/src/CesiumMapRenderer.tsx b/packages/map-cesium-react/src/CesiumMapRenderer.tsx new file mode 100644 index 0000000..1a4d5ad --- /dev/null +++ b/packages/map-cesium-react/src/CesiumMapRenderer.tsx @@ -0,0 +1,403 @@ +import { useEffect, useRef } from "react"; +import type * as CesiumModule from "cesium"; + +import type { + CesiumMapRendererProps, + MapCamera, + MapProviderId, + MapRuntimeState, +} from "./contracts.js"; +import { + MapRuntimeError, + applyCacheIntent, + assetEndpointPath, + fetchMapRuntimeConfiguration, + fetchSameOriginJson, + initialMapRuntimeState, + withProviderState, +} from "./runtime.js"; + +type CesiumNamespace = typeof CesiumModule; +type ProviderEndpoint = { + assetId: number; + type: "IMAGERY" | "TERRAIN" | "3DTILES"; + externalType?: "BING"; + credentialMode: "gateway"; + url?: string; + options?: { + url?: string; + mapStyle?: string; + }; + attributions?: readonly unknown[]; +}; + +export function CesiumMapRenderer({ + runtimeConfigUrl, + camera, + layers, + settings, + cacheIntent, + rendererGeneration = 0, + className, + onRuntimeStateChange, + onCameraChange, +}: CesiumMapRendererProps) { + const containerRef = useRef(null); + const stateCallbackRef = useRef(onRuntimeStateChange); + const cameraCallbackRef = useRef(onCameraChange); + + stateCallbackRef.current = onRuntimeStateChange; + cameraCallbackRef.current = onCameraChange; + + useEffect(() => { + const container = containerRef.current; + if (!container) { + return; + } + const abortController = new AbortController(); + let disposed = false; + let viewer: CesiumModule.Viewer | null = null; + let runtimeState = initialMapRuntimeState(); + + const publish = (next: MapRuntimeState) => { + runtimeState = next; + if (!disposed) { + stateCallbackRef.current?.(next); + } + }; + publish(runtimeState); + + void (async () => { + try { + const configuration = await fetchMapRuntimeConfiguration( + runtimeConfigUrl, + abortController.signal, + ); + const Cesium = await import("cesium"); + if (disposed) { + return; + } + viewer = createViewer(Cesium, container, settings); + applyCamera(Cesium, viewer, camera); + const removeCameraListener = bindCamera(Cesium, viewer, cameraCallbackRef); + const removeRenderListener = bindRenderRecovery(viewer, publish); + + const providerJobs = (["imagery", "terrain", "buildings"] as const).map( + async (providerId) => { + const visible = layers[providerId]; + publish( + withProviderState(runtimeState, providerId, { + phase: visible ? "loading" : "disabled", + }), + ); + if (!visible) { + return; + } + try { + const endpoint = await fetchProviderEndpoint( + configuration, + providerId, + abortController.signal, + ); + if (disposed || !viewer) { + return; + } + await attachProvider( + Cesium, + viewer, + configuration.gateway.cache_proxy_prefix, + endpoint, + providerId, + cacheIntent, + false, + settings, + ); + publish( + withProviderState(runtimeState, providerId, { + phase: "ready", + attributions: endpoint.attributions, + }), + ); + } catch (error) { + if (abortController.signal.aborted) { + return; + } + publish( + withProviderState(runtimeState, providerId, { + phase: "error", + code: safeErrorCode(error), + }), + ); + } + }, + ); + await Promise.allSettled(providerJobs); + if (!disposed && viewer && layers.grid) { + viewer.imageryLayers.addImageryProvider( + new Cesium.GridImageryProvider({ + cells: 16, + color: tokenColor( + Cesium, + container, + "--nodedc-text-secondary", + "rgba(247, 248, 244, 0.72)", + ), + glowColor: tokenColor( + Cesium, + container, + "--nodedc-text-muted", + "rgba(247, 248, 244, 0.48)", + ), + backgroundColor: Cesium.Color.TRANSPARENT, + }), + ); + } + if (disposed) { + removeCameraListener(); + removeRenderListener(); + } + } catch (error) { + if (!abortController.signal.aborted) { + const code = safeErrorCode(error); + publish({ + phase: "gateway-unavailable", + providers: { + imagery: { phase: "error", code }, + terrain: { phase: "error", code }, + buildings: { phase: "error", code }, + }, + code, + }); + } + } + })(); + + return () => { + disposed = true; + abortController.abort(); + viewer?.destroy(); + viewer = null; + container.replaceChildren(); + }; + }, [ + runtimeConfigUrl, + rendererGeneration, + layers.imagery, + layers.terrain, + layers.buildings, + layers.grid, + cacheIntent.enabled, + cacheIntent.no_overwrite, + settings.atmosphere_enabled, + settings.lighting_enabled, + settings.monochrome_enabled, + settings.terrain_exaggeration, + settings.buildings_maximum_screen_space_error, + settings.camera_animation_enabled, + ]); + + return
; +} + +function createViewer( + Cesium: CesiumNamespace, + container: HTMLElement, + settings: CesiumMapRendererProps["settings"], +): CesiumModule.Viewer { + const viewer = new Cesium.Viewer(container, { + animation: false, + baseLayer: false, + baseLayerPicker: false, + fullscreenButton: false, + geocoder: false, + homeButton: false, + infoBox: false, + navigationHelpButton: false, + sceneModePicker: false, + selectionIndicator: false, + timeline: false, + terrainProvider: new Cesium.EllipsoidTerrainProvider(), + requestRenderMode: true, + maximumRenderTimeChange: Number.POSITIVE_INFINITY, + }); + viewer.scene.globe.enableLighting = settings.lighting_enabled; + if (viewer.scene.skyAtmosphere) { + viewer.scene.skyAtmosphere.show = settings.atmosphere_enabled; + } + viewer.scene.verticalExaggeration = settings.terrain_exaggeration; + viewer.scene.rethrowRenderErrors = false; + return viewer; +} + +async function fetchProviderEndpoint( + configuration: Awaited>, + providerId: MapProviderId, + signal: AbortSignal, +): Promise { + const document = await fetchSameOriginJson( + assetEndpointPath(configuration, providerId), + signal, + ); + const expectedAssetId = configuration.assets[providerId]; + const expectedType = + providerId === "imagery" + ? "IMAGERY" + : providerId === "terrain" + ? "TERRAIN" + : "3DTILES"; + if ( + document.assetId !== expectedAssetId || + document.type !== expectedType || + document.credentialMode !== "gateway" + ) { + throw new MapRuntimeError("map_provider_contract_mismatch"); + } + if ( + (providerId === "imagery" && + (document.externalType !== "BING" || + !isEndpointOptions(document.options) || + typeof document.options.url !== "string")) || + (providerId !== "imagery" && typeof document.url !== "string") + ) { + throw new MapRuntimeError("map_provider_contract_mismatch"); + } + return document as ProviderEndpoint; +} + +async function attachProvider( + Cesium: CesiumNamespace, + viewer: CesiumModule.Viewer, + cacheProxyPrefix: string, + endpoint: ProviderEndpoint, + providerId: MapProviderId, + cacheIntent: CesiumMapRendererProps["cacheIntent"], + refresh: boolean, + settings: CesiumMapRendererProps["settings"], +): Promise { + const publicUrl = + providerId === "imagery" ? endpoint.options?.url : endpoint.url; + if (!publicUrl) { + throw new MapRuntimeError("map_provider_contract_mismatch"); + } + const resource = new Cesium.Resource({ + url: applyCacheIntent(publicUrl, cacheIntent, refresh), + proxy: new Cesium.DefaultProxy(cacheProxyPrefix), + }); + if (providerId === "imagery") { + const provider = await Cesium.BingMapsImageryProvider.fromUrl(resource, { + key: "gateway-proxy", + mapStyle: bingMapStyle(Cesium, endpoint.options?.mapStyle), + }); + const layer = viewer.imageryLayers.addImageryProvider(provider); + if (settings.monochrome_enabled) { + layer.saturation = 0; + } + return; + } + if (providerId === "terrain") { + viewer.terrainProvider = await Cesium.CesiumTerrainProvider.fromUrl(resource); + return; + } + const tileset = await Cesium.Cesium3DTileset.fromUrl(resource, { + maximumScreenSpaceError: settings.buildings_maximum_screen_space_error, + }); + viewer.scene.primitives.add(tileset); +} + +function bindCamera( + Cesium: CesiumNamespace, + viewer: CesiumModule.Viewer, + callbackRef: { current: ((camera: MapCamera) => void) | undefined }, +): () => void { + const listener = () => { + const position = viewer.camera.positionCartographic; + callbackRef.current?.({ + longitude: Cesium.Math.toDegrees(position.longitude), + latitude: Cesium.Math.toDegrees(position.latitude), + height: position.height, + heading: Cesium.Math.toDegrees(viewer.camera.heading), + pitch: Cesium.Math.toDegrees(viewer.camera.pitch), + roll: Cesium.Math.toDegrees(viewer.camera.roll), + }); + }; + return viewer.camera.moveEnd.addEventListener(listener); +} + +function bindRenderRecovery( + viewer: CesiumModule.Viewer, + publish: (state: MapRuntimeState) => void, +): () => void { + let recoveryAttempted = false; + return viewer.scene.renderError.addEventListener(() => { + if (!recoveryAttempted) { + recoveryAttempted = true; + viewer.scene.requestRender(); + return; + } + publish({ + phase: "render-error", + providers: { + imagery: { phase: "error", code: "cesium_render_error" }, + terrain: { phase: "error", code: "cesium_render_error" }, + buildings: { phase: "error", code: "cesium_render_error" }, + }, + code: "cesium_render_error", + }); + }); +} + +function applyCamera( + Cesium: CesiumNamespace, + viewer: CesiumModule.Viewer, + camera: MapCamera | null, +): void { + if (!camera) { + viewer.camera.flyHome(0); + return; + } + viewer.camera.setView({ + destination: Cesium.Cartesian3.fromDegrees( + camera.longitude, + camera.latitude, + camera.height, + ), + orientation: { + heading: Cesium.Math.toRadians(camera.heading), + pitch: Cesium.Math.toRadians(camera.pitch), + roll: Cesium.Math.toRadians(camera.roll), + }, + }); +} + +function bingMapStyle( + Cesium: CesiumNamespace, + value: string | undefined, +): CesiumModule.BingMapsStyle { + if (value === "Road") { + return Cesium.BingMapsStyle.ROAD; + } + if (value === "AerialWithLabels") { + return Cesium.BingMapsStyle.AERIAL_WITH_LABELS; + } + return Cesium.BingMapsStyle.AERIAL; +} + +function safeErrorCode(error: unknown): string { + return error instanceof MapRuntimeError ? error.code : "map_provider_unavailable"; +} + +function isEndpointOptions( + value: unknown, +): value is NonNullable { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function tokenColor( + Cesium: CesiumNamespace, + container: HTMLElement, + token: string, + fallback: string, +): CesiumModule.Color { + const value = getComputedStyle(container).getPropertyValue(token).trim(); + return Cesium.Color.fromCssColorString(value || fallback) ?? Cesium.Color.WHITE; +} diff --git a/packages/map-cesium-react/src/contracts.ts b/packages/map-cesium-react/src/contracts.ts new file mode 100644 index 0000000..0c5a066 --- /dev/null +++ b/packages/map-cesium-react/src/contracts.ts @@ -0,0 +1,89 @@ +export const MAP_RUNTIME_SCHEMA_VERSION = "missioncore.map-runtime/v1" as const; +export const MAP_PAGE_VERSION = "0.1.0" as const; +export const CESIUM_RENDERER_VERSION = "1.143.0" as const; + +export type MapProviderId = "imagery" | "terrain" | "buildings"; +export type MapProviderPhase = "idle" | "loading" | "ready" | "disabled" | "error"; +export type MapRenderPhase = + | "loading" + | "ready" + | "degraded" + | "gateway-unavailable" + | "render-error"; + +export type MapRuntimeConfiguration = { + schema_version: typeof MAP_RUNTIME_SCHEMA_VERSION; + map_page_version: typeof MAP_PAGE_VERSION; + renderer: { + id: "cesium"; + version: typeof CESIUM_RENDERER_VERSION; + }; + gateway: { + configured: true; + health_url: string; + asset_endpoint_template: string; + cache_proxy_prefix: string; + }; + assets: Record; +}; + +export type MapGatewayError = { + schema_version?: string; + code?: string; + retryable?: boolean; +}; + +export type MapProviderState = { + phase: MapProviderPhase; + code?: string; + attributions?: readonly unknown[]; +}; + +export type MapRuntimeState = { + phase: MapRenderPhase; + providers: Record; + code?: string; +}; + +export type MapCamera = { + longitude: number; + latitude: number; + height: number; + heading: number; + pitch: number; + roll: number; +}; + +export type MapLayerVisibility = { + imagery: boolean; + terrain: boolean; + buildings: boolean; + grid: boolean; + targets: boolean; +}; + +export type MapVisualSettings = { + atmosphere_enabled: boolean; + lighting_enabled: boolean; + monochrome_enabled: boolean; + terrain_exaggeration: number; + buildings_maximum_screen_space_error: number; + camera_animation_enabled: boolean; +}; + +export type MapCacheIntent = { + enabled: boolean; + no_overwrite: boolean; +}; + +export type CesiumMapRendererProps = { + runtimeConfigUrl: string; + camera: MapCamera | null; + layers: MapLayerVisibility; + settings: MapVisualSettings; + cacheIntent: MapCacheIntent; + rendererGeneration?: number; + className?: string; + onRuntimeStateChange?: (state: MapRuntimeState) => void; + onCameraChange?: (camera: MapCamera) => void; +}; diff --git a/packages/map-cesium-react/src/index.ts b/packages/map-cesium-react/src/index.ts new file mode 100644 index 0000000..67d0de5 --- /dev/null +++ b/packages/map-cesium-react/src/index.ts @@ -0,0 +1,28 @@ +export { CesiumMapRenderer } from "./CesiumMapRenderer.js"; +export { + CESIUM_RENDERER_VERSION, + MAP_PAGE_VERSION, + MAP_RUNTIME_SCHEMA_VERSION, +} from "./contracts.js"; +export type { + CesiumMapRendererProps, + MapCacheIntent, + MapCamera, + MapLayerVisibility, + MapProviderId, + MapProviderPhase, + MapProviderState, + MapRenderPhase, + MapRuntimeConfiguration, + MapRuntimeState, + MapVisualSettings, +} from "./contracts.js"; +export { + MapRuntimeError, + applyCacheIntent, + assetEndpointPath, + fetchMapRuntimeConfiguration, + initialMapRuntimeState, + readSafeErrorCode, + withProviderState, +} from "./runtime.js"; diff --git a/packages/map-cesium-react/src/runtime.ts b/packages/map-cesium-react/src/runtime.ts new file mode 100644 index 0000000..62fc429 --- /dev/null +++ b/packages/map-cesium-react/src/runtime.ts @@ -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 { + 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> { + 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 { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function asMapGatewayError(value: unknown): MapGatewayError | null { + return isRecord(value) ? (value as MapGatewayError) : null; +} diff --git a/packages/map-cesium-react/test/runtime.test.mjs b/packages/map-cesium-react/test/runtime.test.mjs new file mode 100644 index 0000000..c59c2dc --- /dev/null +++ b/packages/map-cesium-react/test/runtime.test.mjs @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + MapRuntimeError, + applyCacheIntent, + initialMapRuntimeState, + withProviderState, +} from "../dist/index.js"; + +test("cache intent keeps all provider traffic behind the gateway contract", () => { + const cached = new URL( + applyCacheIntent("https://assets.ion.cesium.com/tileset.json?v=1", { + enabled: true, + no_overwrite: true, + }, false), + ); + assert.equal(cached.searchParams.get("nodedc_cache_profile"), "live"); + assert.equal(cached.searchParams.has("nodedc_cache_refresh"), false); + + const passthrough = new URL( + applyCacheIntent("https://assets.ion.cesium.com/tileset.json", { + enabled: false, + no_overwrite: true, + }, false), + ); + assert.equal(passthrough.searchParams.get("nodedc_cache_mode"), "passthrough"); + + const refresh = new URL( + applyCacheIntent("https://assets.ion.cesium.com/tileset.json", { + enabled: true, + no_overwrite: true, + }, true), + ); + assert.equal(refresh.searchParams.get("nodedc_cache_refresh"), "1"); +}); + +test("provider URLs reject direct credentials and non-HTTPS transport", () => { + for (const value of [ + "http://assets.ion.cesium.com/tile.bin", + "https://user:pass@assets.ion.cesium.com/tile.bin", + "https://assets.ion.cesium.com/tile.bin?access_token=forbidden", + "https://assets.ion.cesium.com/tile.bin#token", + ]) { + assert.throws( + () => applyCacheIntent(value, { enabled: true, no_overwrite: true }, false), + MapRuntimeError, + ); + } +}); + +test("independent provider states produce ready, degraded and unavailable phases", () => { + let state = initialMapRuntimeState(); + state = withProviderState(state, "imagery", { phase: "ready" }); + state = withProviderState(state, "terrain", { phase: "ready" }); + state = withProviderState(state, "buildings", { phase: "ready" }); + assert.equal(state.phase, "ready"); + + state = withProviderState(state, "buildings", { + phase: "error", + code: "cesium_ion_endpoint_unavailable", + }); + assert.equal(state.phase, "degraded"); + assert.equal(state.code, "cesium_ion_endpoint_unavailable"); + + state = withProviderState(state, "imagery", { + phase: "error", + code: "map_upstream_timeout", + }); + state = withProviderState(state, "terrain", { + phase: "error", + code: "map_upstream_timeout", + }); + assert.equal(state.phase, "gateway-unavailable"); +}); diff --git a/packages/map-cesium-react/tsconfig.json b/packages/map-cesium-react/tsconfig.json new file mode 100644 index 0000000..3493e6b --- /dev/null +++ b/packages/map-cesium-react/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "dist" + }, + "include": [ + "src" + ] +} diff --git a/registry/registry.json b/registry/registry.json index b95ead2..9fd1c6d 100644 --- a/registry/registry.json +++ b/registry/registry.json @@ -28,6 +28,11 @@ "name": "@nodedc/page-patterns", "path": "packages/page-patterns", "role": "runtime-neutral page template contracts and canonical template manifests" + }, + { + "name": "@nodedc/map-cesium-react", + "path": "packages/map-cesium-react", + "role": "credential-free Cesium renderer adapter for the canonical Map Page and same-origin Platform Map Gateway BFFs" } ], "sourceRegistry": "sources.json",