From d9ecd126c8c9b8c7cea07a86fd7e38959149c873 Mon Sep 17 00:00:00 2001 From: Sameh Khamis Date: Fri, 2 Oct 2026 11:00:40 -0700 Subject: [PATCH] Let the app run under a panel page's document base A panel page loads the app with pointing at the app's server, so addresses the app builds resolve against the document base instead of the page location, and hash updates stay on the page's own address. The dev server answers panel origins with CORS on the API routes too. --- build/vite.js | 36 +++++++++++++++++++++++++++- src/data/aisLiveVessels.js | 3 +++ src/keySetup.js | 6 ++++- src/reasonableDefaults.test.mjs | 2 +- src/sharelink.celestial.test.mjs | 4 ++-- src/sharelink.js | 8 ++++++- src/standalone/layerSources.js | 3 +++ src/standalone/toolCatalog.js | 2 +- src/standalone/tools.js | 2 +- src/tooling/viteBuild.test.mjs | 41 +++++++++++++++++++++++++++++--- 10 files changed, 96 insertions(+), 11 deletions(-) diff --git a/build/vite.js b/build/vite.js index 977e25eb2..12915f72f 100644 --- a/build/vite.js +++ b/build/vite.js @@ -8,6 +8,39 @@ export const PANEL_CORS_ORIGINS = Object.freeze([ /^https:\/\/[a-z0-9]+\.claudemcpcontent\.com$/, ]); +/** + * Vite plugin answering cross-origin reads from PANEL_CORS_ORIGINS on every + * path. It runs ahead of the API middlewares, which Vite's own `cors` option + * would not cover. + */ +function panelCorsPlugin() { + const install = (server) => { + server.middlewares.use((req, res, next) => { + const origin = req.headers.origin; + if ( + !origin || + !PANEL_CORS_ORIGINS.some((allowed) => allowed.test(origin)) + ) + return next(); + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + if (req.method !== 'OPTIONS') return next(); + res.setHeader('Access-Control-Allow-Methods', 'GET, HEAD, POST, OPTIONS'); + res.setHeader( + 'Access-Control-Allow-Headers', + req.headers['access-control-request-headers'] || '', + ); + res.statusCode = 204; + res.end(); + }); + }; + return { + name: 'panel-cors', + configureServer: install, + configurePreviewServer: install, + }; +} + /** Build browser assets with explicit inputs; never load environment or providers. */ export function createBrowserViteConfig({ plugins = [], @@ -20,6 +53,7 @@ export function createBrowserViteConfig({ } = {}) { return { plugins: [ + panelCorsPlugin(), cesium(), applicationHtmlPlugin(), ...plugins, @@ -52,7 +86,7 @@ export function createBrowserViteConfig({ }, // Cross-origin reads: local pages, as Vite allows by default, plus the // MCP Apps panels that load the app into a conversation (Claude's - // sandbox origins). Development and preview only. + // sandbox origins); see panelCorsPlugin. Development and preview only. cors: { origin: PANEL_CORS_ORIGINS }, // These headers protect the document containing Provider Settings. // Embed-mode documents are framable instead; see embed-framing.js. diff --git a/src/data/aisLiveVessels.js b/src/data/aisLiveVessels.js index 348ca26c3..320318742 100644 --- a/src/data/aisLiveVessels.js +++ b/src/data/aisLiveVessels.js @@ -5,6 +5,9 @@ import { createAisStreamSource } from '../sources/live/standalone.js'; const aisLiveVesselsLayer = createApplicationVessels({ source: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', + // The app's server, also when a panel page loads the app under . + origin: () => + new URL(globalThis.document?.baseURI ?? 'http://localhost').origin, }), options: { maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS, diff --git a/src/keySetup.js b/src/keySetup.js index ee1d3b2dd..d66d23867 100644 --- a/src/keySetup.js +++ b/src/keySetup.js @@ -318,7 +318,11 @@ export async function initKeySetup({ globalThis.location?.hash?.slice(1) || '', ); if (next !== null) - globalThis.history?.replaceState?.(null, '', `#${next}`); + globalThis.history?.replaceState?.( + null, + '', + new URL(`#${next}`, globalThis.location.href).href, + ); } catch { // Continuity is a nicety, never a blocker. } diff --git a/src/reasonableDefaults.test.mjs b/src/reasonableDefaults.test.mjs index 4772630aa..dfd951377 100644 --- a/src/reasonableDefaults.test.mjs +++ b/src/reasonableDefaults.test.mjs @@ -61,7 +61,7 @@ function uiBlock(start, end) { /** A ShareLinkManager over a synthetic hash — enough surface for parseInitialHash. */ function managerForHash(hash) { globalThis.window = { location: { hash, href: `http://localhost/${hash}` } }; - globalThis.history = { replaceState(_s, _t, next) { window.location.hash = next; } }; + globalThis.history = { replaceState(_s, _t, next) { window.location.hash = new URL(next, window.location.href).hash; } }; const viewer = { camera: { changed: { addEventListener() {} }, diff --git a/src/sharelink.celestial.test.mjs b/src/sharelink.celestial.test.mjs index cc2672785..567bb09dd 100644 --- a/src/sharelink.celestial.test.mjs +++ b/src/sharelink.celestial.test.mjs @@ -34,8 +34,8 @@ function assertClaimsBefore(block, mutation, label) { function makeManager(hash = '') { globalThis.window = { location: { hash, href: `http://localhost/${hash}` } }; globalThis.history = { - replaceState(_state, _title, nextHash) { - window.location.hash = nextHash; + replaceState(_state, _title, next) { + window.location.hash = new URL(next, window.location.href).hash; }, }; const viewer = { diff --git a/src/sharelink.js b/src/sharelink.js index 9c7c3728e..36862dcbf 100644 --- a/src/sharelink.js +++ b/src/sharelink.js @@ -529,7 +529,13 @@ export class ShareLinkManager { if (this._destroyed || this._initialRestorePending) return; const params = this._buildHashParams(); if (!params) return; - history.replaceState(null, '', `#${params.toString()}`); + // The hash belongs to this page's address, not the document base the + // app may load under. + history.replaceState( + null, + '', + new URL(`#${params.toString()}`, window.location.href).href, + ); } /** Build a deterministic snapshot without mutating history. */ diff --git a/src/standalone/layerSources.js b/src/standalone/layerSources.js index 1856dfb69..e51e64530 100644 --- a/src/standalone/layerSources.js +++ b/src/standalone/layerSources.js @@ -29,6 +29,9 @@ export function createStandaloneLayerSources() { military: createAdsbLolSource(), vessels: createAisStreamSource({ apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live', + // The app's server, also when a panel page loads the app under . + origin: () => + new URL(globalThis.document?.baseURI ?? 'http://localhost').origin, }), cctv: createCctvSource(), radio: createRadioSource(), diff --git a/src/standalone/toolCatalog.js b/src/standalone/toolCatalog.js index 53a1f6a7b..4d697d9ed 100644 --- a/src/standalone/toolCatalog.js +++ b/src/standalone/toolCatalog.js @@ -16,7 +16,7 @@ export function loadToolCatalog() { tools: coreTools, services: createToolServices({ fetchImpl: (...args) => globalThis.fetch(...args), - appUrl: globalThis.location.origin, + appUrl: new URL(globalThis.document.baseURI).origin, }), }), 'voice', diff --git a/src/standalone/tools.js b/src/standalone/tools.js index 047434924..d2e6043af 100644 --- a/src/standalone/tools.js +++ b/src/standalone/tools.js @@ -8,7 +8,7 @@ export function createStandaloneTools(options) { sceneDataPacks: { sources: { assets: createAssetDirectorySource({ - baseUrl: new URL('/scene-assets/', window.location.href).href, + baseUrl: new URL('/scene-assets/', document.baseURI).href, }), }, }, diff --git a/src/tooling/viteBuild.test.mjs b/src/tooling/viteBuild.test.mjs index 169e9d515..f2c0c586a 100644 --- a/src/tooling/viteBuild.test.mjs +++ b/src/tooling/viteBuild.test.mjs @@ -12,7 +12,8 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop googleApiKey: 'browser-fixture', cesiumToken: 'ion-fixture', }); - assert.equal(config.plugins[2], plugin); + assert.equal(config.plugins[0].name, 'panel-cors'); + assert.equal(config.plugins[3], plugin); assert.equal(config.server.host, 'localhost'); assert.equal(config.server.port, 4173); assert.deepEqual(config.server.allowedHosts, [ @@ -42,6 +43,40 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop ); }); +test('panel origins may read every dev server path, other sites may not', () => { + let middleware; + createBrowserViteConfig().plugins[0].configureServer({ + middlewares: { use: (fn) => (middleware = fn) }, + }); + const request = (origin, method = 'GET') => { + const headers = {}; + let ended = false; + let passed = false; + middleware( + { method, headers: origin ? { origin } : {} }, + { + setHeader: (name, value) => (headers[name] = value), + end: () => (ended = true), + }, + () => (passed = true), + ); + return { headers, ended, passed }; + }; + const panel = request('https://abc123.claudemcpcontent.com'); + assert.equal( + panel.headers['Access-Control-Allow-Origin'], + 'https://abc123.claudemcpcontent.com', + ); + assert.equal(panel.passed, true); + assert.equal( + request('https://abc123.claudemcpcontent.com', 'OPTIONS').ended, + true, + ); + const other = request('https://example.com'); + assert.deepEqual(other.headers, {}); + assert.equal(other.passed, true); +}); + test('build helper does not discover environment values or construct local providers', () => { const before = process.env.GOOGLE_MAPS_API_KEY; process.env.GOOGLE_MAPS_API_KEY = 'environment-fixture'; @@ -52,7 +87,7 @@ test('build helper does not discover environment values or construct local provi undefined, ); assert.deepEqual( - config.plugins.slice(2).map((plugin) => plugin.name), + config.plugins.slice(3).map((plugin) => plugin.name), ['embed-framing'], ); } finally { @@ -66,7 +101,7 @@ test('root config retains existing named exports and standalone provider order', assert.equal(compatibility[name], value, name); const config = standaloneConfig({ mode: 'test' }); assert.deepEqual( - config.plugins.slice(2, -3).map((plugin) => plugin.name), + config.plugins.slice(3, -3).map((plugin) => plugin.name), providers.localProviderPlugins().map((plugin) => plugin.name), ); assert.equal(config.plugins.at(-4).name, 'gev-key-setup');