Merge remote-tracking branch 'upstream/main' into fix/363-geocode-proxy

# Conflicts:
#	SECURITY.md
#	server/providers/places/google.js
This commit is contained in:
Niccolò Saggese committed 2026-10-05 09:26:08 +02:00
commit e2ce7fa38e
223 files changed
+20759 -1752

No files matched your search

+305
View File
@@ -0,0 +1,305 @@
/**
* Embed mode and views in the running app.
*
* With `?embed=1` the app shows only the globe: clean view, with the HUD,
* welcome and setup prompts hidden. A page that frames it changes what it
* shows by posting `{ type: 'gev:view', id, view }`; the app applies the view
* through its own actions and answers `{ type: 'gev:view-applied', id, ok,
* steps }`. It announces `{ type: 'gev:ready' }` once it can take views.
*
* Annotations in a share link are drawn once the link has been restored,
* embedded or not.
*/
import * as Cesium from 'cesium';
import { announceNavigationAuthority } from '../navigationPolicy.js';
import { annotationsFromParams, createView } from '../view/index.js';
export const EMBED_VIEW_MESSAGE = 'gev:view';
export const EMBED_APPLIED_MESSAGE = 'gev:view-applied';
export const EMBED_READY_MESSAGE = 'gev:ready';
const FOLLOW_LAYERS = {
aircraft: 'flights',
military_aircraft: 'military',
satellite: 'satellites',
};
const FOLLOW_ATTEMPTS = 20;
const FOLLOW_RETRY_MS = 1000;
const FLIGHT_SECONDS = 2;
/**
* Whether this page shows the app embedded: `?embed=1` when another page
* frames it, or inline when a panel page loads the app into itself and sets
* `globalThis.GEV_EMBED_INLINE` first.
*/
export function isEmbedded(location = globalThis.location) {
return (
isEmbeddedInline() ||
new URLSearchParams(location?.search || '').get('embed') === '1'
);
}
/** Whether a panel page loaded the app into itself. */
export function isEmbeddedInline() {
return globalThis.GEV_EMBED_INLINE === true;
}
// A panel keeps drawing at about this rate when its host stops animation
// frames; see keepPanelRendering.
const PANEL_FRAME_MS = 33;
const MISSED_FRAMES_MS = 250;
/** A render error's details, including the plain objects workers report. */
function describeError(error) {
if (error instanceof Error)
return `${error.name}: ${error.message}\n${error.stack}`;
try {
return JSON.stringify(error, Object.getOwnPropertyNames(error ?? {}));
} catch {
return String(error);
}
}
/**
* Keep the globe drawing in an inline panel. Some hosts report a panel on
* screen as hidden, which stops the browser's animation frames and with them
* Cesium's render loop; while frames stop arriving, draw from a timer.
* Returns a function that stops it.
*/
export function keepPanelRendering(
viewer,
{ windowRef = globalThis.window, now = () => performance.now() } = {},
) {
let lastFrame = now();
let frameRequest = null;
const onFrame = () => {
lastFrame = now();
frameRequest = windowRef.requestAnimationFrame(onFrame);
};
frameRequest = windowRef.requestAnimationFrame(onFrame);
// Cesium stops drawing after a render error, and so does the timer.
// Report the error in full: workers report plain objects, which Cesium
// prints as [object Object].
let failed = false;
const removeErrorListener = viewer.scene?.renderError?.addEventListener(
(_scene, error) => {
failed = true;
console.error(
"[God's Eye View panel] render error:",
describeError(error),
);
},
);
const timer = windowRef.setInterval(() => {
if (
failed ||
now() - lastFrame < MISSED_FRAMES_MS ||
viewer.isDestroyed?.()
)
return;
viewer.resize();
viewer.render();
}, PANEL_FRAME_MS);
return () => {
windowRef.clearInterval(timer);
windowRef.cancelAnimationFrame(frameRequest);
removeErrorListener?.();
};
}
const delay = (ms, signal) =>
new Promise((resolve) => {
const timer = setTimeout(resolve, ms);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
resolve();
},
{ once: true },
);
});
/** Fly the camera to a view's camera and resolve when the flight ends. */
function flyTo(viewer, camera) {
announceNavigationAuthority('embed-view');
return new Promise((resolve) => {
viewer.camera.flyTo({
destination: Cesium.Cartesian3.fromDegrees(
camera.lon,
camera.lat,
camera.altitude_m,
),
orientation: {
heading: Cesium.Math.toRadians(camera.heading_deg),
pitch: Cesium.Math.toRadians(camera.pitch_deg),
roll: 0,
},
duration: FLIGHT_SECONDS,
complete: () => resolve(true),
cancel: () => resolve(false),
});
});
}
/**
* Make the app show a view: style and map, exactly the view's layers,
* annotations, the camera, and the followed entity. Each step runs through the
* app's own actions (`run(name, args)`); a failed step is reported and the
* rest still run. Resolves to the steps and whether each succeeded.
*/
export async function applyView(
view,
{ viewer, dataManager, run, signal, retryMs = FOLLOW_RETRY_MS },
) {
const steps = [];
const act = async (name, args) => {
let result = null;
try {
result = await run(name, args);
} catch (error) {
result = { ok: false, error: error?.message || String(error) };
}
steps.push({
step: name,
ok: result?.ok !== false,
...(result?.error ? { error: result.error } : {}),
});
return result;
};
if (view.style) await act('set_visual_style', { style: view.style });
if (view.map) await act('set_map_stack', { stack: view.map });
const wanted = new Set(view.layers);
for (const layer of dataManager.getAll()) {
if (layer.enabled && !wanted.has(layer.id))
await act('set_layer_visibility', { layerId: layer.id, enabled: false });
}
for (const layerId of view.layers) {
if (!dataManager.isEnabled(layerId))
await act('set_layer_visibility', { layerId, enabled: true });
}
if (!view.follow) await act('stop_tracking', {});
// Annotations go first: drawing them may frame the marks, and the view's
// own camera should have the last word.
await act('clear_annotations', {});
if (view.annotations.length)
await act('annotate_map', { annotations: [...view.annotations] });
if (!view.follow) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
return steps;
}
// A followed entity owns the camera, and a camera flight would end the
// follow, so fly to the view only while the entity is not there yet. It
// appears once its layer has data; retry until then.
const layerId = FOLLOW_LAYERS[view.follow.kind];
const follow = async () => {
try {
return (
(await run('track_entity', { query: view.follow.id, layerId }))?.ok ===
true
);
} catch {
return false;
}
};
let followed = await follow();
if (!followed) {
steps.push({ step: 'camera', ok: await flyTo(viewer, view.camera) });
for (let attempt = 1; attempt < FOLLOW_ATTEMPTS && !followed; attempt++) {
if (signal?.aborted) break;
await delay(retryMs, signal);
followed = await follow();
}
}
steps.push({ step: 'follow', ok: followed });
if (followed && view.follow.cockpit)
await act('control_cockpit', { action: 'enter', targetLayer: layerId });
return steps;
}
/**
* Install embed mode when the page asks for it, and draw any annotations the
* opening link carries. Returns a function that removes the message handler.
*/
export function installViews({
shell,
viewer,
dataManager,
run,
signal,
location = globalThis.location,
windowRef = globalThis.window,
}) {
const ready = Promise.resolve(shell.initialRestorePromise).catch(() => {});
const linked = annotationsFromParams(
new URLSearchParams(String(location?.hash || '').replace(/^#/, '')),
);
if (linked.length)
void ready.then(() => {
if (!signal?.aborted) void run('annotate_map', { annotations: linked });
});
if (!isEmbedded(location)) return () => {};
windowRef.document.body.classList.add('ui-embed');
shell.setCleanView?.(true);
const stopRendering = isEmbeddedInline()
? keepPanelRendering(viewer, { windowRef })
: () => {};
// A framing page talks to the app across frames; an inline panel shares
// the page with it and talks through the page's own window.
const peer = isEmbeddedInline() ? windowRef : windowRef.parent;
// Answers go back only to the origin that asked. An inline panel posts to
// its own window, and a sandboxed framing page has no origin to name.
const post = (message, origin = '*') => {
if (peer && (peer !== windowRef || isEmbeddedInline()))
peer.postMessage(message, origin);
};
// Views apply one at a time, in the order they arrive.
let queue = ready;
const onMessage = (event) => {
if (event.source !== peer || event.data?.type !== EMBED_VIEW_MESSAGE)
return;
const { id = null } = event.data;
const replyOrigin =
isEmbeddedInline() || !event.origin || event.origin === 'null'
? '*'
: event.origin;
let view;
try {
view = createView(event.data.view);
} catch (error) {
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: false,
error: error.message,
},
replyOrigin,
);
return;
}
queue = queue.then(async () => {
if (signal?.aborted) return;
const steps = await applyView(view, { viewer, dataManager, run, signal });
post(
{
type: EMBED_APPLIED_MESSAGE,
id,
ok: steps.every((step) => step.ok),
steps,
},
replyOrigin,
);
});
};
windowRef.addEventListener('message', onMessage);
void ready.then(() => {
if (!signal?.aborted) post({ type: EMBED_READY_MESSAGE });
});
return () => {
stopRendering();
windowRef.removeEventListener('message', onMessage);
};
}
+325
View File
@@ -0,0 +1,325 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
applyView,
installViews,
isEmbedded,
keepPanelRendering,
} from './embed.js';
import { createView } from '../view/index.js';
const fakeViewer = () => {
const flights = [];
return {
flights,
camera: {
flyTo(options) {
flights.push(options);
options.complete();
},
},
};
};
const fakeLayers = (enabled) => ({
getAll: () =>
['flights', 'earthquakes', 'ais-live-vessels'].map((id) => ({
id,
enabled: enabled.has(id),
})),
isEnabled: (id) => enabled.has(id),
});
const recorder = (answers = {}) => {
const calls = [];
const run = async (name, args) => {
calls.push([name, args]);
const answer = answers[name];
return typeof answer === 'function'
? answer(calls)
: (answer ?? { ok: true });
};
return { calls, run };
};
test('embed mode is opt-in by query parameter', () => {
assert.equal(isEmbedded({ search: '?embed=1' }), true);
assert.equal(isEmbedded({ search: '?welcome=0' }), false);
});
test('a view is applied through the app actions in order', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const view = createView({
camera: { lat: 25, lon: 121, altitude_m: 300000, pitch_deg: -40 },
layers: ['ais-live-vessels'],
style: 'thermal',
map: 'osm',
annotations: [{ type: 'pin', target: 'Taipei 101' }],
});
const steps = await applyView(view, {
viewer,
dataManager: fakeLayers(new Set(['earthquakes'])),
run,
});
assert.deepEqual(
calls.map(([name, args]) => [
name,
args.layerId ?? args.style ?? args.stack ?? null,
args.enabled ?? null,
]),
[
['set_visual_style', 'thermal', null],
['set_map_stack', 'osm', null],
['set_layer_visibility', 'earthquakes', false],
['set_layer_visibility', 'ais-live-vessels', true],
['stop_tracking', null, null],
['clear_annotations', null, null],
['annotate_map', null, null],
],
);
assert.equal(viewer.flights.length, 1);
assert.ok(steps.every((step) => step.ok));
assert.ok(steps.some((step) => step.step === 'camera'));
});
test('a followed entity is retried until its layer has it', async () => {
let tries = 0;
const { run } = recorder({
track_entity: () => ({ ok: ++tries >= 3 }),
});
const view = createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'aircraft', id: 'abc123' },
});
const steps = await applyView(view, {
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
retryMs: 1,
});
assert.equal(tries, 3);
assert.deepEqual(steps.at(-1), { step: 'follow', ok: true });
});
test('an embedded page takes views only from its parent and answers it', async () => {
const posted = [];
const targets = [];
const parent = {
postMessage: (message, target) => {
posted.push(message);
targets.push(target);
},
};
const windowRef = new EventTarget();
windowRef.parent = parent;
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
const shell = {
initialRestorePromise: Promise.resolve(),
clean: null,
setCleanView(value) {
this.clean = value;
},
};
const { calls, run } = recorder();
const remove = installViews({
shell,
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '?embed=1', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(shell.clean, true);
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const send = (source, data, origin = 'https://host.example') => {
const event = new Event('message');
Object.assign(event, { source, data, origin });
windowRef.dispatchEvent(event);
};
send({}, { type: 'gev:view', id: 1, view: { camera: { lat: 1, lon: 2 } } });
send(parent, { type: 'gev:view', id: 2, view: { camera: {} } });
send(parent, {
type: 'gev:view',
id: 3,
view: { camera: { lat: 1, lon: 2 }, layers: ['flights'] },
});
await new Promise((resolve) => setTimeout(resolve, 10));
assert.deepEqual(posted[1], {
type: 'gev:view-applied',
id: 2,
ok: false,
error: 'A view needs a camera lat and lon',
});
assert.equal(posted[2].id, 3);
assert.equal(posted[2].ok, true);
assert.ok(
calls.some(
([name, args]) =>
name === 'set_layer_visibility' && args.layerId === 'flights',
),
);
assert.equal(posted.filter((message) => message.id === 1).length, 0);
// Ready names no data and goes to any parent; answers go only to the
// origin that asked.
assert.deepEqual(targets, [
'*',
'https://host.example',
'https://host.example',
]);
remove();
});
test('an app loaded inline into a panel page talks through its own window', async () => {
const posted = [];
const windowRef = new EventTarget();
windowRef.parent = windowRef;
windowRef.postMessage = (message) => posted.push(message);
windowRef.document = { body: { classList: new Set() } };
windowRef.document.body.classList.add = Set.prototype.add;
Object.assign(windowRef, {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: () => 1,
clearInterval() {},
});
globalThis.GEV_EMBED_INLINE = true;
try {
assert.equal(isEmbedded({ search: '' }), true);
const remove = installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run: recorder().run,
location: { search: '', hash: '' },
windowRef,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(posted, [{ type: 'gev:ready' }]);
const event = new Event('message');
Object.assign(event, {
source: windowRef,
data: { type: 'gev:view', id: 7, view: { camera: { lat: 1, lon: 2 } } },
});
windowRef.dispatchEvent(event);
await new Promise((resolve) => setTimeout(resolve, 10));
assert.equal(posted[1].id, 7);
assert.equal(posted[1].ok, true);
remove();
} finally {
delete globalThis.GEV_EMBED_INLINE;
}
});
test('annotations in a link are drawn after it restores, embedded or not', async () => {
const { calls, run } = recorder();
const an = encodeURIComponent(
JSON.stringify([{ type: 'pin', target: 'Austin' }]),
);
installViews({
shell: { initialRestorePromise: Promise.resolve() },
viewer: fakeViewer(),
dataManager: fakeLayers(new Set()),
run,
location: { search: '', hash: `#v=2&lat=1&lon=2&an=${an}` },
windowRef: new EventTarget(),
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.deepEqual(calls, [
['annotate_map', { annotations: [{ type: 'pin', target: 'Austin' }] }],
]);
});
test('a panel keeps drawing from a timer while animation frames stop', () => {
let clock = 0;
let tick = null;
let frame = null;
const windowRef = {
requestAnimationFrame: (fn) => ((frame = fn), 1),
cancelAnimationFrame: () => (frame = null),
setInterval: (fn) => ((tick = fn), 1),
clearInterval: () => (tick = null),
};
const drawn = [];
const viewer = {
resize: () => drawn.push('resize'),
render: () => drawn.push('render'),
};
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
// Frames arrive: the render loop draws, so the timer does nothing.
clock = 100;
frame();
tick();
assert.deepEqual(drawn, []);
// Frames stop, as in a panel its host reports hidden: the timer draws.
clock = 400;
tick();
assert.deepEqual(drawn, ['resize', 'render']);
stop();
assert.equal(tick, null);
assert.equal(frame, null);
});
test('a panel reports a render error in full and stops drawing', () => {
let listener = null;
let tick = null;
const windowRef = {
requestAnimationFrame: () => 1,
cancelAnimationFrame() {},
setInterval: (fn) => ((tick = fn), 1),
clearInterval() {},
};
const drawn = [];
const viewer = {
resize() {},
render: () => drawn.push('render'),
scene: {
renderError: {
addEventListener: (fn) => ((listener = fn), () => (listener = null)),
},
},
};
const logged = [];
const original = console.error;
console.error = (...args) => logged.push(args.join(' '));
try {
let clock = 0;
const stop = keepPanelRendering(viewer, { windowRef, now: () => clock });
listener(viewer.scene, { name: 'RuntimeError', message: 'worker failed' });
assert.match(logged[0], /render error: .*"message":"worker failed"/);
clock = 1000;
tick();
assert.deepEqual(drawn, []);
stop();
assert.equal(listener, null);
} finally {
console.error = original;
}
});
test('a followed aircraft owns the camera, and cockpit view is entered after it', async () => {
const viewer = fakeViewer();
const { calls, run } = recorder();
const steps = await applyView(
createView({
camera: { lat: 32.7, lon: -117.2 },
follow: { kind: 'military_aircraft', id: 'AE1234', cockpit: true },
}),
{ viewer, dataManager: fakeLayers(new Set()), run, retryMs: 0 },
);
// Following worked at once, so no camera flight ended it.
assert.equal(viewer.flights.length, 0);
assert.deepEqual(calls.slice(-2), [
['track_entity', { query: 'ae1234', layerId: 'military' }],
['control_cockpit', { action: 'enter', targetLayer: 'military' }],
]);
assert.ok(steps.every((step) => step.ok));
assert.throws(
() =>
createView({
camera: { lat: 0, lon: 0 },
follow: { kind: 'satellite', id: '25544', cockpit: true },
}),
/no cockpit view/,
);
});
+662
View File
@@ -0,0 +1,662 @@
/**
* The God's Eye View panel's script, run inside the panel page a host shows.
* It is sent as source text (see src/tools/globePanel.js), so it must not use
* imports or anything outside the function.
*
* The page lives on the host's site, and hosts may refuse other addresses,
* such as a server on the user's machine. So the panel never requests the
* app's server itself: every request for the app's own paths goes to the
* panel's MCP server as a call to `config.toolName`, which requests the path
* from the app's server. The app's built page, scripts, styles, images and
* data all arrive that way; map imagery from providers loads directly.
*/
export function panelRuntime(config) {
const status = document.getElementById('status');
const open = document.getElementById('open');
const expand = document.getElementById('expand');
const say = (text) => {
if (status.isConnected) status.textContent = text;
};
let blocked = null;
document.addEventListener('securitypolicyviolation', (event) => {
blocked = `${event.effectiveDirective} blocked ${event.blockedURI || 'a resource'}`;
});
// JSON-RPC with the host.
let nextId = 1;
const pending = new Map();
const send = (message) =>
window.parent.postMessage({ jsonrpc: '2.0', ...message }, '*');
const request = (method, params) =>
new Promise((resolve, reject) => {
const id = nextId++;
pending.set(id, { resolve, reject });
send({ id, method, params });
});
const notify = (method, params) => send({ method, params });
// Requests to the app's server, through the MCP server.
const MAX_CALLS = 6;
let calls = 0;
const waiting = [];
async function callTool(args) {
if (calls >= MAX_CALLS) await new Promise((go) => waiting.push(go));
calls += 1;
try {
const result = await request('tools/call', {
name: config.toolName,
// The server refuses the panel's requests without its key.
arguments: { ...args, key: config.panelKey },
});
if (result?.isError || !result?.structuredContent)
throw new Error(result?.content?.[0]?.text || 'The request failed');
return result.structuredContent;
} finally {
calls -= 1;
waiting.shift()?.();
}
}
const toBase64 = (bytes) => {
let binary = '';
for (let start = 0; start < bytes.length; start += 0x8000)
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
return btoa(binary);
};
const fromBase64 = (text) =>
Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
async function bodyBytes(body) {
if (body == null) return null;
if (typeof body === 'string') return new TextEncoder().encode(body);
if (body instanceof ArrayBuffer) return new Uint8Array(body);
if (ArrayBuffer.isView(body))
return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
return new Uint8Array(await new Response(body).arrayBuffer());
}
const NULL_BODY = new Set([101, 204, 205, 304]);
const held = new Map();
/** Request a path on the app's server; resolves to a Response. */
function appFetch(path, init = {}) {
const method = (init.method || 'GET').toUpperCase();
// Built files never change while the panel runs, so each loads once.
const shared = method === 'GET' && path.startsWith(config.panelBase);
if (shared && held.has(path)) return held.get(path).then(respond);
const loading = (async () => {
const headers = {};
new Headers(init.headers || {}).forEach((value, name) => {
headers[name] = value;
});
const bytes = await bodyBytes(init.body);
let part = await callTool({
path,
method,
headers,
...(bytes ? { body: toBase64(bytes) } : {}),
});
const parts = [fromBase64(part.body)];
while (part.nextOffset !== undefined) {
if (init.signal?.aborted) break;
part = await callTool({ id: part.id, offset: part.nextOffset });
parts.push(fromBase64(part.body));
}
let body = new Blob(parts);
if (part.encoding === 'gzip')
body = await new Response(
body.stream().pipeThrough(new DecompressionStream('gzip')),
).blob();
return {
body: await body.arrayBuffer(),
status: part.status,
statusText: part.statusText,
headers: part.headers,
};
})();
if (shared) {
held.set(path, loading);
loading.catch(() => held.delete(path));
}
const result = loading.then(respond);
if (!init.signal) return result;
return Promise.race([
result,
new Promise((_, reject) => {
const abort = () =>
reject(new DOMException('The request was aborted', 'AbortError'));
if (init.signal.aborted) abort();
init.signal.addEventListener('abort', abort, { once: true });
}),
]);
}
function respond({ body, status, statusText, headers }) {
return new Response(NULL_BODY.has(status) ? null : body.slice(0), {
status,
statusText,
headers,
});
}
// The app's own paths: relative ones, absolute ones on this page's site
// (matched by scheme and host, since a host may use its own scheme), and
// ones under the app's address in the page, an https address for code
// that needs one (`config.appBaseUrl`).
const page = new URL(document.baseURI);
const appOrigin = new URL(config.appBaseUrl).origin;
function appPath(value) {
let url;
try {
url = new URL(String(value), document.baseURI);
} catch {
return null;
}
const own =
url.origin === appOrigin ||
(url.protocol === page.protocol && url.host === page.host);
return own ? url.pathname + url.search : null;
}
const fileUrls = new Map();
/**
* A data: URL holding an app file, for elements that load by URL. Hosts
* allow data: images where some refuse blob: ones. Files of the panel
* build never change and are kept; others, such as live camera frames
* that change address on every refresh, are not.
*/
function appFileUrl(path) {
if (fileUrls.has(path)) return fileUrls.get(path);
const loading = appFetch(path)
.then((response) => response.blob())
.then(
(blob) =>
new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result);
reader.onerror = () => reject(reader.error);
reader.readAsDataURL(blob);
}),
);
if (path.startsWith(config.panelBase)) {
fileUrls.set(path, loading);
loading.catch(() => fileUrls.delete(path));
}
return loading;
}
function installRequests() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input, init = {}) => {
const isRequest = input instanceof Request;
const path = appPath(isRequest ? input.url : input);
if (path === null) return nativeFetch(input, init);
if (!isRequest) return appFetch(path, init);
return appFetch(path, {
method: init.method || input.method,
headers: init.headers || input.headers,
body:
init.body ??
(['GET', 'HEAD'].includes(input.method)
? undefined
: await input.arrayBuffer()),
signal: init.signal || input.signal,
});
};
// XMLHttpRequest for app paths: answered from appFetch, with the
// instance's state replaced, and the usual events dispatched.
const xhr = XMLHttpRequest.prototype;
const nativeOpen = xhr.open;
const nativeSend = xhr.send;
const nativeHeader = xhr.setRequestHeader;
const nativeAbort = xhr.abort;
xhr.open = function (method, url, ...rest) {
const path = appPath(url);
if (path === null) {
this.__gev = null;
return nativeOpen.call(this, method, url, ...rest);
}
this.__gev = { method, path, headers: {}, controller: null };
setState(this, { readyState: 1 });
this.dispatchEvent(new Event('readystatechange'));
};
xhr.setRequestHeader = function (name, value) {
if (!this.__gev) return nativeHeader.call(this, name, value);
this.__gev.headers[name] = value;
};
xhr.abort = function () {
if (!this.__gev) return nativeAbort.call(this);
this.__gev.controller?.abort();
};
xhr.send = function (body) {
const state = this.__gev;
if (!state) return nativeSend.call(this, body);
state.controller = new AbortController();
appFetch(state.path, {
method: state.method,
headers: state.headers,
body,
signal: state.controller.signal,
})
.then(async (response) => {
const type = this.responseType;
const value =
type === 'arraybuffer'
? await response.arrayBuffer()
: type === 'blob'
? await response.blob()
: await response.text();
const text = typeof value === 'string' ? value : null;
let parsed = value;
if (type === 'json') {
try {
parsed = JSON.parse(text);
} catch {
parsed = null;
}
} else if (type === 'document')
parsed = new DOMParser().parseFromString(text, 'text/html');
const headers = [...response.headers]
.map(([name, val]) => `${name}: ${val}`)
.join('\r\n');
setState(this, {
readyState: 4,
status: response.status,
statusText: response.statusText,
response: parsed,
responseText: type === '' || type === 'text' ? text : '',
responseURL: new URL(state.path, document.baseURI).href,
getAllResponseHeaders: () => headers,
getResponseHeader: (name) => response.headers.get(name),
});
for (const name of ['readystatechange', 'load', 'loadend'])
this.dispatchEvent(
name === 'readystatechange'
? new Event(name)
: new ProgressEvent(name),
);
})
.catch((error) => {
setState(this, { readyState: 4, status: 0 });
const name = error?.name === 'AbortError' ? 'abort' : 'error';
for (const event of ['readystatechange', name, 'loadend'])
this.dispatchEvent(
event === 'readystatechange'
? new Event(event)
: new ProgressEvent(event),
);
});
};
function setState(target, values) {
for (const [name, value] of Object.entries(values))
Object.defineProperty(target, name, {
configurable: true,
...(typeof value === 'function' ? { value } : { get: () => value }),
});
}
const nativeBeacon = navigator.sendBeacon?.bind(navigator);
if (nativeBeacon)
navigator.sendBeacon = (url, data) => {
const path = appPath(url);
if (path === null) return nativeBeacon(url, data);
appFetch(path, { method: 'POST', body: data }).catch(() => {});
return true;
};
// Images name app files by URL; give them the file as a data: URL.
const image = Object.getOwnPropertyDescriptor(
HTMLImageElement.prototype,
'src',
);
Object.defineProperty(HTMLImageElement.prototype, 'src', {
...image,
set(value) {
const path = appPath(value);
if (path === null) return image.set.call(this, value);
appFileUrl(path).then(
(url) => image.set.call(this, url),
() => this.dispatchEvent(new Event('error')),
);
},
});
// Workers on the app's server start from a small worker here that is
// handed the script once it has loaded; messages wait until then.
const NativeWorker = window.Worker;
const BOOT =
'const early=[];let ready=false;' +
"addEventListener('message',e=>{if(e.data&&e.data.__gevScript){" +
'const go=()=>{ready=true;for(const d of early)dispatchEvent(new MessageEvent("message",{data:d}))};' +
'if(e.data.module)import(e.data.__gevScript).then(go);else{importScripts(e.data.__gevScript);go()}' +
'e.stopImmediatePropagation();return}' +
'if(!ready){early.push(e.data);e.stopImmediatePropagation()}});';
window.Worker = function Worker(url, options) {
const path = appPath(url);
if (path === null || String(url).startsWith('blob:'))
return new NativeWorker(url, options);
const worker = new NativeWorker(
URL.createObjectURL(new Blob([BOOT], { type: 'text/javascript' })),
options,
);
appFetch(path)
.then((response) => response.blob())
.then((blob) => URL.createObjectURL(blob))
.then((script) =>
worker.postMessage({
__gevScript: script,
module: options?.type === 'module',
}),
);
return worker;
};
window.Worker.prototype = NativeWorker.prototype;
// Some hosts drop the GPU contents of a panel's 2D canvases while they
// consider the panel off screen, and the overlays then cover the globe
// in black. Keep 2D canvases in memory instead, which hosts do not drop.
const getContext = HTMLCanvasElement.prototype.getContext;
HTMLCanvasElement.prototype.getContext = function (type, options) {
return type === '2d'
? getContext.call(this, type, { ...options, willReadFrequently: true })
: getContext.call(this, type, options);
};
// Markup the app adds as HTML text would request its images from this
// page's site at once; hold their addresses until the files are here.
const htmlWithHeldImages = (html) =>
String(html).replace(
/(<(?:img|source)\b[^>]*?\s)src=(["'])([^"']*)\2/gi,
(tag, before, quote, value) =>
appPath(value) === null
? tag
: `${before}data-gev-src=${quote}${value}${quote}`,
);
const fillHeldImages = (root) => {
for (const element of root?.querySelectorAll?.('[data-gev-src]') ?? []) {
const path = appPath(element.getAttribute('data-gev-src'));
element.removeAttribute('data-gev-src');
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
};
const innerHtml = Object.getOwnPropertyDescriptor(
Element.prototype,
'innerHTML',
);
Object.defineProperty(Element.prototype, 'innerHTML', {
...innerHtml,
set(value) {
innerHtml.set.call(this, htmlWithHeldImages(value));
fillHeldImages(this);
},
});
const insertHtml = Element.prototype.insertAdjacentHTML;
Element.prototype.insertAdjacentHTML = function (position, html) {
insertHtml.call(this, position, htmlWithHeldImages(html));
fillHeldImages(this.parentNode ?? this);
};
// Markup and styles the app adds later name app files too.
new MutationObserver((records) => {
for (const record of records)
for (const node of record.addedNodes) {
if (node.nodeType !== 1) continue;
if (node.nodeName === 'STYLE') void inlineStyleUrls(node);
for (const element of [node, ...node.querySelectorAll('img[src]')]) {
if (element.nodeName !== 'IMG') continue;
const path = appPath(element.getAttribute('src'));
if (path !== null)
appFileUrl(path).then((url) => element.setAttribute('src', url));
}
}
}).observe(document.documentElement, { childList: true, subtree: true });
}
/** Replace url() references to app files in CSS with data: URLs. */
async function cssWithFiles(text, baseUrl) {
const found = new Map();
for (const match of text.matchAll(/url\(\s*(['"]?)([^'")]+)\1\s*\)/g)) {
const reference = match[2];
if (/^(data|blob):/.test(reference)) continue;
const path = appPath(new URL(reference, baseUrl).href);
if (path !== null && !found.has(reference))
found.set(
reference,
appFileUrl(path).catch(() => null),
);
}
let result = text;
for (const [reference, loading] of found) {
const url = await loading;
if (url) result = result.split(reference).join(url);
}
return result;
}
async function inlineStyleUrls(style) {
if (!/url\(/.test(style.textContent)) return;
style.textContent = await cssWithFiles(style.textContent, document.baseURI);
}
async function addStylesheet(href) {
const path = appPath(href);
const style = document.createElement('style');
document.head.appendChild(style);
if (path === null) {
const link = document.createElement('link');
link.rel = 'stylesheet';
link.href = href;
style.replaceWith(link);
return;
}
const text = await (await appFetch(path)).text();
style.textContent = await cssWithFiles(
text,
new URL(path, document.baseURI).href,
);
}
function runScript(text, type) {
const script = document.createElement('script');
if (type) script.type = type;
script.textContent = text;
document.body.appendChild(script);
}
/**
* Load God's Eye View into this page from the app's panel build, in
* inline embed mode, starting at the first view as its link would.
*/
async function startApp(url, view) {
window.GEV_EMBED_INLINE = true;
window.GEV_APP_BASE_URL = config.appBaseUrl;
try {
history.replaceState(
null,
'',
location.href.split('#')[0] + new URL(url).hash,
);
// The app restores this view from its link, following included, so
// it needs applying again only for what links cannot carry: cockpit.
if (queued === view && !view.follow?.cockpit) queued = null;
} catch {
// The view still arrives once the app is ready.
}
installRequests();
const response = await appFetch(config.panelBase);
if (!response.ok)
throw new Error(
response.status === 404
? 'the app has no panel build (npm run build:panel)'
: `the app answered ${response.status}`,
);
const built = new DOMParser().parseFromString(
await response.text(),
'text/html',
);
window.CESIUM_BASE_URL = `${config.panelBase}cesium/`;
const [prelude] = await Promise.all([
appFetch(config.panelBase + config.workerPreludePath).then((r) =>
r.text(),
),
...[...built.head.querySelectorAll('link[rel="stylesheet"]')].map(
(link) => addStylesheet(link.getAttribute('href')),
),
]);
// Cesium's script sets CESIUM_WORKERS, its workers as one script it
// starts from memory. Workers request files themselves, which this page
// cannot answer, so the prelude, holding those files, runs ahead of it.
let workers;
Object.defineProperty(window, 'CESIUM_WORKERS', {
configurable: true,
get: () => workers,
set: (script) => {
workers = prelude + script;
},
});
// Images in the markup load their files once added; give them the
// files instead of letting them request this page's site.
const images = [];
for (const image of built.body.querySelectorAll('img[src]')) {
const path = appPath(image.getAttribute('src'));
if (path === null) continue;
image.removeAttribute('src');
images.push([image, path]);
}
for (const node of [...built.body.childNodes]) {
if (node.nodeName !== 'SCRIPT')
document.body.insertBefore(document.adoptNode(node), status);
}
for (const [image, path] of images)
appFileUrl(path).then((url) => image.setAttribute('src', url));
const scripts = await Promise.all(
[...built.querySelectorAll('script')].map(async (script) => ({
type: script.type,
text: script.getAttribute('src')
? await (await appFetch(appPath(script.getAttribute('src')))).text()
: script.textContent,
})),
);
for (const { type, text } of scripts) runScript(text, type);
// Browsers cap live 3D contexts across pages and take one back when
// there are too many; say so rather than show no map.
document.addEventListener(
'webglcontextlost',
() => {
const notice = document.createElement('div');
notice.id = 'status';
notice.textContent =
"This panel lost its 3D graphics, likely because other God's Eye " +
"View panels in this conversation hold them. Use Open in God's " +
'Eye View above, or show it in a new conversation.';
document.body.appendChild(notice);
},
{ capture: true, once: true },
);
}
// Tool results: the first view starts the app, later ones move it.
let started = false;
let ready = false;
let queued = null;
let currentUrl = null;
const postView = (view) =>
window.postMessage({ type: 'gev:view', id: nextId++, view }, '*');
function show(result) {
const data = result?.structuredContent;
const view = data?.view;
const url = data?.url || view?.url;
if (!view || !url) return;
currentUrl = url;
open.hidden = false;
if (ready) return postView(view);
queued = view;
if (started) return;
started = true;
say("Loading God's Eye View…");
startApp(url, view).catch((error) =>
say(`God's Eye View could not load here: ${error?.message || error}.`),
);
setTimeout(() => {
if (!ready)
say(
"God's Eye View did not load here" +
(blocked ? ` (${blocked})` : '') +
". Use Open in God's Eye View above.",
);
}, config.loadTimeoutMs);
}
window.addEventListener('message', (event) => {
const message = event.data;
if (event.source === window) {
if (message?.type === 'gev:ready') {
ready = true;
status.remove();
if (queued) postView(queued);
queued = null;
}
return;
}
if (event.source !== window.parent || message?.jsonrpc !== '2.0') return;
if (
message.id !== undefined &&
pending.has(message.id) &&
!message.method
) {
const { resolve, reject } = pending.get(message.id);
pending.delete(message.id);
if (message.error) reject(message.error);
else resolve(message.result);
return;
}
if (message.method === 'ui/notifications/tool-result') show(message.params);
if (message.method === 'ui/notifications/host-context-changed')
showDisplayMode(message.params);
});
// Fullscreen, where the host offers it, gives the globe the room.
let displayMode = 'inline';
const showDisplayMode = (context) => {
if (context?.displayMode) displayMode = context.displayMode;
if (context?.availableDisplayModes)
expand.hidden = !context.availableDisplayModes.includes('fullscreen');
expand.textContent = displayMode === 'fullscreen' ? 'Collapse' : 'Expand';
};
expand.addEventListener('click', () => {
request('ui/request-display-mode', {
mode: displayMode === 'fullscreen' ? 'inline' : 'fullscreen',
}).then(
(result) => showDisplayMode({ displayMode: result?.mode }),
() => {},
);
});
open.addEventListener('click', () => {
if (!currentUrl) return;
request('ui/open-link', { url: currentUrl }).catch(() =>
window.open(currentUrl, '_blank', 'noopener'),
);
});
// The handshake the MCP Apps SDK's App.connect performs: hosts validate
// these parameters, and keep a view hidden until it is initialized.
request('ui/initialize', {
appCapabilities: { availableDisplayModes: ['inline', 'fullscreen'] },
appInfo: {
name: 'gods-eye-view',
title: "God's Eye View",
version: '1.0.0',
},
protocolVersion: config.protocolVersion,
}).then(
(result) => {
showDisplayMode(result?.hostContext);
send({ method: 'ui/notifications/initialized' });
notify('ui/notifications/size-changed', {
width: document.body.clientWidth,
height: config.panelHeight,
});
},
(error) =>
say(
"This client did not accept the God's Eye View panel" +
(error?.message ? `: ${error.message}` : '.'),
),
);
}
+3 -1
View File
@@ -19,6 +19,7 @@ import { describeError } from './errors.js';
export async function createApplicationScene({
requestServices,
googleApiKey,
googleTokens = null,
cesiumToken,
credits,
MapController = MapStackController,
@@ -59,11 +60,12 @@ export async function createApplicationScene({
registerDataCredits(viewer, credits);
configureCreditKeyboardAccess(document);
loaderStatus.textContent =
googleApiKey || cesiumToken
googleApiKey || googleTokens || cesiumToken
? 'Loading Google 3D Tiles...'
: 'Loading the keyless globe...';
const photoreal = await loadPhotorealisticTileset(Cesium, {
googleApiKey,
googleTokens,
cesiumToken,
});
const tileset = photoreal.tileset;
+13 -1
View File
@@ -4,6 +4,7 @@ import { initDrawTool } from '../annotations/drawTool.js';
import { initImageryBoxTool } from '../ui/imageryBoxTool.js';
import { createRecentImageryPanel } from '../ui/recentImagery.js';
import { initGevVoiceCommands } from '../voice/gevRealtime.js';
import { installViews, isEmbeddedInline } from './embed.js';
import { installScopeMask, destroyScopeMask } from '../scopeMask.js';
import {
installRenderGovernor,
@@ -118,7 +119,9 @@ export function createApplicationTools({
// GPU. Holder/data state is untouched, so return is seamless: restore
// the loop, refresh the one DOM surface we gated, render a frame.
const syncVisibilitySuspension = () => {
const hidden = document.hidden;
// A panel's host may report it hidden while it is on screen; the panel
// keeps drawing itself (see keepPanelRendering in embed.js).
const hidden = document.hidden && !isEmbeddedInline();
viewer.useDefaultRenderLoop = !hidden;
cockpitCloudEffects?.setSuspended?.(hidden);
if (!hidden) {
@@ -176,5 +179,14 @@ export function createApplicationTools({
delete window.__gevVoiceCommands;
});
debug.voiceCommands = voiceCommands;
defer(
installViews({
shell: styleManager,
viewer,
dataManager,
run: (name, args) => voiceCommands.runner(name, args, { signal }),
signal,
}),
);
return { sceneDirector, annotations, voiceCommands };
}
+33 -9
View File
@@ -86,23 +86,47 @@ test('Cockpit heading tape leaves the bottom exit row unobstructed', () => {
assert.doesNotMatch(css, /cockpit-compass-label/);
});
test('Cockpit vision cycle exposes exactly five real visual styles without NONE', () => {
assert.match(cycleVisionMode.toString(), /const modes = COCKPIT_VISION_MODES;/);
assert.match(setVisionMode.toString(), /const labels = \{\s*optical: inherited,\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*noir: 'NOIR',?\s*\};/);
test('Cockpit vision cycle exposes every map preset exactly once', () => {
assert.match(
cycleVisionMode.toString(),
/const modes = COCKPIT_VISION_MODES;/,
);
assert.match(
setVisionMode.toString(),
/const labels = \{\s*optical: 'NORMAL',\s*crt: 'CRT',\s*nvg: 'NVG',\s*thermal: 'FLIR',\s*anime: 'ANIME',\s*noir: 'NOIR',\s*snow: 'SNOW',?\s*\};/,
);
assert.doesNotMatch(setVisionMode.toString(), /none: 'NONE'/);
assert.match(ui, /getInheritedVisionLabel: \(\) =>\s*\(?[\s\S]*?STYLE_STATUS_LABELS\[this\.activeStyle\]/);
assert.doesNotMatch(ui, /getInheritedVisionLabel/);
assert.match(html, /id="cockpit-vision-current-label"[^>]*>NORMAL<\/strong>/);
assert.match(ui, /const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);/);
assert.match(ui, /this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/);
assert.match(
cockpitEnter.toString(),
/this\.setVisionMode\(this\.getInitialVisionMode\(\)\)/,
'Cockpit must enter on the mode matching the active map preset',
);
assert.match(
readRadioSource(
new URL('./ui/cockpitCoordinator.js', import.meta.url),
'utf8',
),
/getInitialVisionMode:\s*\(\)\s*=>\s*cockpitVisionModeForStyle\(this\.activeStyle\)/,
);
assert.match(
ui,
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,\s*this\._cockpitVisionRestore,?\s*\);[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
'the inherited entry must restore the map shader while CRT, NVG, FLIR, and NOIR remain temporary Cockpit overrides',
/const target = applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);/,
);
assert.match(
ui,
/this\._cockpitVisionRestore = captureCockpitVisionBaseline\(\s*this\.stages,\s*this\.transitions,?\s*\);/,
);
assert.match(
ui,
/if \(next === 'optical'\) \{[\s\S]*?applyCockpitVisionStageIntensities\(\s*this\.stages,\s*next,?\s*\);[\s\S]*?this\._updateSliderPanel\(null,[\s\S]*?return;[\s\S]*?const target = applyCockpitVisionStageIntensities/,
'Normal must clear the inherited map shader while the other map presets remain temporary Cockpit overrides',
);
assert.match(
ui,
/_syncCockpitInheritedStyle\(\)[\s\S]*?name === this\.activeStyle \? 1 : 0[\s\S]*?this\.transitions\.delete\(name\)[\s\S]*?setVisionMode\(this\.cockpitView\.visionMode\)/,
'changing the map preset in Cockpit must refresh both the inherited label and restore baseline',
'changing the map preset in Cockpit must refresh the exit restore baseline without replacing Normal',
);
assert.match(ui, /setStyle\([\s\S]*?this\._syncCockpitInheritedStyle\(\);/);
});
+22 -5
View File
@@ -3,21 +3,40 @@ export const COCKPIT_VISION_MODES = Object.freeze([
'crt',
'nvg',
'thermal',
'anime',
'noir',
'snow',
]);
const TARGET_STYLE_BY_MODE = Object.freeze({
crt: 'retro',
nvg: 'surveillance',
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
/** Normalize a requested Cockpit vision mode to the inherited preset entry. */
const COCKPIT_MODE_BY_STYLE = Object.freeze({
normal: 'optical',
retro: 'crt',
surveillance: 'nvg',
thermal: 'thermal',
anime: 'anime',
noir: 'noir',
snow: 'snow',
});
/** Normalize a requested Cockpit vision mode to the unfiltered Normal entry. */
export function normalizeCockpitVisionMode(mode) {
return COCKPIT_VISION_MODES.includes(mode) ? mode : 'optical';
}
/** Start Cockpit on the equivalent supported map preset. */
export function cockpitVisionModeForStyle(style) {
return COCKPIT_MODE_BY_STYLE[String(style || '').toLowerCase()] || 'optical';
}
/** Settle pending map-style crossfades and return their intended final intensities. */
export function captureCockpitVisionBaseline(stages, transitions) {
const baseline = {};
@@ -34,12 +53,10 @@ export function captureCockpitVisionBaseline(stages, transitions) {
* Apply Cockpit-only stage intensities without changing any shader parameters.
* Returns the temporary style whose parameters should be shown, or null.
*/
export function applyCockpitVisionStageIntensities(stages, mode, restore = {}) {
export function applyCockpitVisionStageIntensities(stages, mode) {
const next = normalizeCockpitVisionMode(mode);
if (next === 'optical') {
for (const [name, intensity] of Object.entries(restore)) {
if (stages[name]) stages[name].uniforms.intensity = intensity;
}
for (const stage of Object.values(stages)) stage.uniforms.intensity = 0;
return null;
}
+75 -23
View File
@@ -3,6 +3,7 @@ import test from 'node:test';
import {
applyCockpitVisionStageIntensities,
captureCockpitVisionBaseline,
cockpitVisionModeForStyle,
COCKPIT_VISION_MODES,
normalizeCockpitVisionMode,
} from './cockpitVisionPolicy.js';
@@ -14,12 +15,31 @@ const createStages = () => ({
thermal: { uniforms: { intensity: 0, heat: 0.8 } },
});
test('Cockpit vision order exposes inherited, CRT, NVG, FLIR, and NOIR modes', () => {
assert.deepEqual(COCKPIT_VISION_MODES, ['optical', 'crt', 'nvg', 'thermal', 'noir']);
test('Cockpit vision order mirrors the unique map preset order', () => {
assert.deepEqual(COCKPIT_VISION_MODES, [
'optical',
'crt',
'nvg',
'thermal',
'anime',
'noir',
'snow',
]);
assert.equal(normalizeCockpitVisionMode('none'), 'optical');
assert.equal(normalizeCockpitVisionMode('unknown'), 'optical');
});
test('Cockpit entry selects the vision mode equivalent to the active map style', () => {
assert.equal(cockpitVisionModeForStyle('normal'), 'optical');
assert.equal(cockpitVisionModeForStyle('retro'), 'crt');
assert.equal(cockpitVisionModeForStyle('surveillance'), 'nvg');
assert.equal(cockpitVisionModeForStyle('thermal'), 'thermal');
assert.equal(cockpitVisionModeForStyle('anime'), 'anime');
assert.equal(cockpitVisionModeForStyle('noir'), 'noir');
assert.equal(cockpitVisionModeForStyle('snow'), 'snow');
assert.equal(cockpitVisionModeForStyle('unsupported'), 'optical');
});
test('Cockpit settles pending map crossfades before a temporary preset takes ownership', () => {
const stages = createStages();
const transitions = new Map([
@@ -29,49 +49,81 @@ test('Cockpit settles pending map crossfades before a temporary preset takes own
const restore = captureCockpitVisionBaseline(stages, transitions);
assert.deepEqual(restore, { noir: 1, retro: 0, surveillance: 0, thermal: 0 });
assert.equal(transitions.size, 0);
applyCockpitVisionStageIntensities(stages, 'nvg', restore);
applyCockpitVisionStageIntensities(stages, 'nvg');
assert.equal(stages.surveillance.uniforms.intensity, 1);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 1);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
});
test('returning from a temporary preset restores the exact inherited intensities', () => {
test('Normal clears every temporary and inherited visual stage', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
applyCockpitVisionStageIntensities(stages, 'thermal');
applyCockpitVisionStageIntensities(stages, 'optical');
assert.deepEqual(
Object.fromEntries(Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity])),
restore,
Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
),
{ noir: 0, retro: 0, surveillance: 0, thermal: 0 },
);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
test('temporary styles replace each other without changing the inherited restore snapshot', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
applyCockpitVisionStageIntensities(stages, 'thermal', restore);
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt', restore), 'retro');
applyCockpitVisionStageIntensities(stages, 'thermal');
assert.equal(applyCockpitVisionStageIntensities(stages, 'crt'), 'retro');
assert.equal(stages.retro.uniforms.intensity, 1);
assert.equal(stages.noir.uniforms.intensity, 0);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 0.72);
assert.equal(stages.retro.uniforms.intensity, 0.18);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
test('NOIR is a temporary Cockpit override and inherited restores the captured map style', () => {
test('NOIR is a temporary Cockpit override and Normal clears it', () => {
const stages = createStages();
const restore = Object.fromEntries(
Object.entries(stages).map(([name, stage]) => [name, stage.uniforms.intensity]),
Object.entries(stages).map(([name, stage]) => [
name,
stage.uniforms.intensity,
]),
);
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir', restore), 'noir');
assert.equal(applyCockpitVisionStageIntensities(stages, 'noir'), 'noir');
assert.equal(stages.noir.uniforms.intensity, 1);
assert.equal(stages.retro.uniforms.intensity, 0);
applyCockpitVisionStageIntensities(stages, 'optical', restore);
assert.equal(stages.noir.uniforms.intensity, 0.72);
assert.equal(stages.retro.uniforms.intensity, 0.18);
applyCockpitVisionStageIntensities(stages, 'optical');
assert.equal(stages.noir.uniforms.intensity, 0);
assert.equal(stages.retro.uniforms.intensity, 0);
assert.deepEqual(restore, {
noir: 0.72,
retro: 0.18,
surveillance: 0,
thermal: 0,
});
});
+281
View File
@@ -0,0 +1,281 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { Readable } from 'node:stream';
import { readPinokioEnvironment } from '../scripts/pinokio-environment.mjs';
import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import {
makeCostRateLimiter,
resolvePerMinuteCap,
} from '../server/providers/common/rate-limit.js';
import { OPENAI_DEFAULT_PER_MIN } from '../server/providers/openai/rate-limit.js';
import { handleHudSummary } from '../server/providers/openai/hud-summary.js';
import {
googlePlacesContextProxy,
GOOGLE_DEFAULT_PER_MIN,
} from '../server/providers/places/google.js';
// Both limiters are cached at module scope on first use, so the env value that
// matters is the one in place when a route is first exercised — restoring the
// variable afterwards does not rebuild the limiter. Each route is therefore
// driven by exactly one test here; a second test wanting a different cap would
// need its own process, not just a different env value.
/** Set an env var for one test and put the old value back afterwards. */
function env(t, name, value) {
const old = process.env[name];
if (value === undefined) delete process.env[name];
else process.env[name] = value;
t.after(() => {
if (old === undefined) delete process.env[name];
else process.env[name] = old;
});
}
function install(plugin) {
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
restart: async () => {},
});
return routes;
}
function request(
handler,
{ url = '/', method = 'POST', remoteAddress = '127.0.0.1' } = {},
) {
return new Promise((resolve, reject) => {
const req = Readable.from([]);
Object.assign(req, {
method,
url,
headers: { host: 'localhost:4173', 'content-type': 'application/json' },
socket: { remoteAddress },
});
const headers = {};
const res = {
statusCode: 200,
setHeader(name, value) {
headers[name.toLowerCase()] = value;
},
end(body = '') {
resolve({ status: this.statusCode, headers, body: String(body) });
},
};
Promise.resolve(handler(req, res)).catch(reject);
});
}
test('an unset throttle resolves to the documented default, not to unlimited', () => {
// The defect #16 reports: the cap was opt-in, so an unconfigured server let
// anyone who could reach it mint tokens on the operator's key without bound.
assert.equal(resolvePerMinuteCap(undefined, 30), 30);
assert.equal(resolvePerMinuteCap('', 30), 30);
assert.equal(resolvePerMinuteCap(' ', 30), 30);
assert.ok(
typeof makeCostRateLimiter(undefined, 30) === 'function',
'an unconfigured cost endpoint must still be throttled',
);
});
test('an explicit 0 is the documented way to run unthrottled', () => {
assert.equal(resolvePerMinuteCap('0', 30), 0);
assert.equal(
makeCostRateLimiter('0', 30),
null,
'operators who mean "no throttle" must still be able to say so',
);
});
test('a configured value wins, and a fractional one floors', () => {
assert.equal(resolvePerMinuteCap('45', 30), 45);
assert.equal(resolvePerMinuteCap(45, 30), 45);
assert.equal(resolvePerMinuteCap('30.7', 30), 30);
});
test('a positive fraction keeps the guard rather than flooring to unlimited', () => {
for (const fraction of ['0.5', '0.99', 0.01, '1e-9']) {
assert.equal(resolvePerMinuteCap(fraction, 30), 1, String(fraction));
assert.equal(
typeof makeCostRateLimiter(fraction, 30),
'function',
`${fraction} must still throttle`,
);
}
// Only an explicit zero turns the limiter off.
assert.equal(makeCostRateLimiter('0', 30), null);
assert.equal(makeCostRateLimiter('0.0', 30), null);
});
test('an unreadable value keeps the guard instead of removing it', () => {
// `3O` (letter O) and `60/min` are the shapes a hand-edited .env produces.
// Reading either as "unlimited" would disarm the spend guard on a typo —
// the opposite of what the operator was reaching for when they typed it.
for (const typo of ['3O', '60/min', 'thirty', '-5', 'NaN']) {
assert.equal(
resolvePerMinuteCap(typo, 30),
30,
`${typo} must fall back to the default, not to unlimited`,
);
}
});
test('the default cap clears the app’s own demand with room to spare', () => {
// src/hud.js HUD_SUMMARY_INTERVAL_MS = 15000 -> 4 summaries/min per open
// tab, plus one token mint per voice session. The cap has to sit well above
// that or it would throttle the app rather than an abuser.
const hudRequestsPerMinutePerTab = 60_000 / 15_000;
assert.ok(
OPENAI_DEFAULT_PER_MIN >= hudRequestsPerMinutePerTab * 4,
`default ${OPENAI_DEFAULT_PER_MIN}/min leaves too little headroom over ${hudRequestsPerMinutePerTab}/min of ordinary use`,
);
});
test('the realtime token route throttles by default, and says so in a 429', async (t) => {
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ value: 'fixture-ephemeral' });
});
const handler = install(openAiRealtimeProxy({})).get('/api/realtime/token');
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN + 1; i += 1) {
statuses.push((await request(handler)).status);
}
assert.deepEqual(
statuses.slice(0, OPENAI_DEFAULT_PER_MIN),
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler);
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
});
test('the HUD summary route throttles the paid path, never the free one', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
let upstreamCalls = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstreamCalls += 1;
return Response.json({ output_text: 'five word hud summary here' });
});
// Its own client address: the limiter is cached at module scope and shared
// across the OpenAI routes, so the realtime case above has already spent
// 127.0.0.1's budget inside this process.
const from = '10.9.9.9';
const statuses = [];
for (let i = 0; i < OPENAI_DEFAULT_PER_MIN; i += 1)
statuses.push(
(await request(handleHudSummary, { remoteAddress: from })).status,
);
assert.deepEqual(
statuses,
Array(OPENAI_DEFAULT_PER_MIN).fill(200),
'the default must not throttle ordinary HUD use',
);
const overLimit = await request(handleHudSummary, { remoteAddress: from });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
assert.equal(JSON.parse(overLimit.body).error, 'Rate limit exceeded');
assert.equal(
overLimit.body.includes('fixture-upstream-secret'),
false,
'a throttled reply must not leak the server-held key',
);
assert.ok(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches OpenAI, or it still costs money',
);
// The keyless HUD answer is deliberately NOT throttled: it is composed
// locally, spends nothing upstream, and capping it would only take the free
// HUD away from an unconfigured install. Same exhausted client, no key.
delete process.env.OPENAI_API_KEY;
const free = await request(handleHudSummary, { remoteAddress: from });
assert.equal(free.status, 200, 'the free path answers past a spent budget');
assert.equal(JSON.parse(free.body).configured, false);
assert.equal(
upstreamCalls <= OPENAI_DEFAULT_PER_MIN,
true,
'the free path reaches no provider',
);
});
test('the Google places route throttles by default and keeps its places[] contract', async (t) => {
env(t, 'GEV_RATELIMIT_GOOGLE_PER_MIN', undefined);
let upstreamCalls = 0;
const handler = install(
googlePlacesContextProxy({
resolveApiKey: () => 'fixture-google-server-key',
fetchImpl: async () => {
upstreamCalls += 1;
return Response.json({ places: [] });
},
}),
).get('/api/google/nearby-places');
const url = '/?lat=37.77493&lon=-122.41942';
const statuses = [];
for (let i = 0; i < GOOGLE_DEFAULT_PER_MIN; i += 1) {
statuses.push((await request(handler, { url, method: 'GET' })).status);
}
assert.ok(
statuses.every((s) => s === 200),
'the default must not throttle ordinary use',
);
const overLimit = await request(handler, { url, method: 'GET' });
assert.equal(overLimit.status, 429);
assert.equal(overLimit.headers['retry-after'], '5');
// Every error response on this route carries places[]; the client reads it
// unconditionally, so a throttled reply must not be the one shape that omits it.
assert.deepEqual(JSON.parse(overLimit.body).places, []);
assert.equal(
upstreamCalls,
GOOGLE_DEFAULT_PER_MIN,
'a throttled request must be refused before it reaches Google, or it still costs money',
);
});
test('the code defaults match the caps the Pinokio build ships', () => {
// These two numbers used to live only in pinokio/_ENVIRONMENT, and the value
// this repo suggested elsewhere had already drifted away from them. Now that
// they are the server's defaults, the packaged app and an unconfigured
// checkout must agree, or a Pinokio user's throttle silently stops matching
// the one everybody else gets. If you deliberately want them to differ, this
// assertion is the place to say so.
const shipped = readPinokioEnvironment(
path.join(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'pinokio',
'_ENVIRONMENT',
),
);
assert.equal(
Number(shipped.GEV_RATELIMIT_OPENAI_PER_MIN),
OPENAI_DEFAULT_PER_MIN,
);
assert.equal(
Number(shipped.GEV_RATELIMIT_GOOGLE_PER_MIN),
GOOGLE_DEFAULT_PER_MIN,
);
});
+3 -21
View File
@@ -58,30 +58,18 @@ export function normalizeAdminName(value) {
const PACKS = {
countries: {
url: new URL('./local_data/natural_earth/countries.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/countries.json', {
with: { type: 'json' },
}),
},
admin1: {
url: new URL(
'./local_data/natural_earth/states_provinces.json',
import.meta.url,
),
importJson: () =>
import('./local_data/natural_earth/states_provinces.json', {
with: { type: 'json' },
}),
},
counties: {
url: new URL(
'./local_data/us_census_counties/counties.json',
import.meta.url,
),
importJson: () =>
import('./local_data/us_census_counties/counties.json', {
with: { type: 'json' },
}),
},
};
@@ -297,7 +285,7 @@ function addKey(index, key, entry, tier) {
const loadAdmin1 = createRetryableLoader(async () => {
packLoads.admin1 += 1;
const pack = await loadBundledJson(PACKS.admin1.url, PACKS.admin1.importJson);
const pack = await loadBundledJson(PACKS.admin1.url);
const decimals = pack.meta?.decimals ?? 3;
const index = new Map();
for (const feature of pack.features || []) {
@@ -323,10 +311,7 @@ const loadAdmin1 = createRetryableLoader(async () => {
const loadCounties = createRetryableLoader(async () => {
packLoads.counties += 1;
const pack = await loadBundledJson(
PACKS.counties.url,
PACKS.counties.importJson,
);
const pack = await loadBundledJson(PACKS.counties.url);
const decimals = pack.meta?.decimals ?? 4;
const byFull = new Map();
const byName = new Map();
@@ -348,10 +333,7 @@ const loadCounties = createRetryableLoader(async () => {
const loadCountries = createRetryableLoader(async () => {
packLoads.countries += 1;
const pack = await loadBundledJson(
PACKS.countries.url,
PACKS.countries.importJson,
);
const pack = await loadBundledJson(PACKS.countries.url);
const index = new Map();
for (const feature of pack.features || []) {
const entry = {
+1 -1
View File
@@ -28,7 +28,7 @@ test('ais analyst record: full record maps every contract field', () => {
courseDeg: 214.0,
shipType: 'Cargo',
destination: 'OAKLAND',
navStatus: null, // /api/ais-live does not surface NavigationalStatus
navStatus: null, // /api/vessels does not surface NavigationalStatus
});
});
+6 -3
View File
@@ -1,10 +1,13 @@
import { createApplicationVessels } from '../app/layers/aisLiveVessels.js';
import { createAisStreamSource } from '../sources/live/standalone.js';
import { createVesselSource } from '../sources/live/standalone.js';
const aisLiveVesselsLayer = createApplicationVessels({
source: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
source: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may serve
// from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
}),
options: {
maxRows: import.meta.env?.VITE_AIS_LIVE_MAX_ROWS,
+2 -2
View File
@@ -1668,7 +1668,7 @@ test('a vessel analyst record carries the MMSI the tracker keys on', () => {
});
test('vessel selection passes the opaque source reference to optional history', async () => {
const { createAisStreamSource } = await import('../sources/live/standalone.js');
const { createVesselSource } = await import('../sources/live/standalone.js');
_setVesselStateForTest({ enabled: false });
const requests = [];
aisLiveVesselsLayer.setSource({
@@ -1688,6 +1688,6 @@ test('vessel selection passes the opaque source reference to optional history',
} finally {
harness.cleanup();
_setVesselStateForTest({ enabled: false });
aisLiveVesselsLayer.setSource(createAisStreamSource());
aisLiveVesselsLayer.setSource(createVesselSource());
}
});
+1 -1
View File
@@ -450,7 +450,7 @@ export function createAisWatchdog(options = {}) {
return generation;
}
/** Status metadata for /api/ais-live. */
/** Status metadata for /api/vessels. */
function snapshot() {
const monoNow = clock.mono();
const wallNow = clock.wall();
+22 -8
View File
@@ -4,24 +4,38 @@
* The browser fetches the file from `url` — a `new URL('./….json',
* import.meta.url)` written in the calling module, which Vite serves as-is in
* dev and emits as an asset in the production build. Under Node (node:test)
* that URL is a `file:` URL, which `fetch` cannot read, so the caller's
* JSON-attributed import runs instead.
* that URL is a `file:` URL, which `fetch` cannot read, so the file is read
* from disk instead.
*
* The attributed import cannot serve the browser: the Vite dev server answers
* it with a JavaScript module, which the browser rejects for a JSON import.
* Nothing here imports the JSON as a module. A JSON-attributed import cannot
* serve the browser (the Vite dev server answers it with a JavaScript module,
* which the browser rejects), and a literal `import('./….json')` makes the
* production build emit a JavaScript copy of every pack that the browser
* never loads.
*
* @module data/bundledJson
*/
/**
* @param {URL} url - Resolved against the calling module's `import.meta.url`.
* @param {() => Promise<{default: any}>} importJson - The caller's literal
* `import('./….json', { with: { type: 'json' } })`, used only under Node.
* @param {{signal?: AbortSignal}} [options]
* @returns {Promise<any>} The parsed JSON.
*/
export async function loadBundledJson(url, importJson, { signal } = {}) {
if (url.protocol === 'file:') return (await importJson()).default;
export async function loadBundledJson(url, { signal } = {}) {
signal?.throwIfAborted();
if (url.protocol === 'file:') {
// Node's built-in, reached without an import, so the browser build never
// bundles or rewrites it; only Node, reading `file:` URLs, gets here. The
// read is synchronous so the result settles as promptly as the cached
// module import it replaces; callers already memoize each pack.
const getBuiltinModule = globalThis.process?.getBuiltinModule;
if (typeof getBuiltinModule !== 'function')
throw new Error(
`God's Eye View needs Node 24.14 or newer to read ${url.pathname.split('/').pop()} (running ${globalThis.process?.version ?? 'an unknown runtime'})`,
);
const { readFileSync } = getBuiltinModule('node:fs');
return JSON.parse(readFileSync(url, 'utf8'));
}
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP ${response.status} for ${url.pathname}`);
+57 -10
View File
@@ -2,13 +2,14 @@ import { test } from 'node:test';
import assert from 'node:assert/strict';
import { loadBundledJson } from './bundledJson.js';
const MARINE = new URL('./local_data/natural_earth/marine.json', import.meta.url);
const importMarine = () =>
import('./local_data/natural_earth/marine.json', { with: { type: 'json' } });
const MARINE = new URL(
'./local_data/natural_earth/marine.json',
import.meta.url,
);
test('bundled JSON: a file URL uses the JSON import under Node', async (t) => {
test('bundled JSON: a file URL is read from disk under Node', async (t) => {
const fetchMock = t.mock.method(globalThis, 'fetch');
const pack = await loadBundledJson(MARINE, importMarine);
const pack = await loadBundledJson(MARINE);
assert.ok(pack.features.some((ft) => ft.name === 'Gulf of Mexico'));
assert.equal(fetchMock.mock.callCount(), 0);
});
@@ -21,16 +22,62 @@ test('bundled JSON: a served URL is fetched as plain JSON', async (t) => {
headers: { 'content-type': 'application/json' },
});
});
const url = new URL('http://localhost/src/data/local_data/natural_earth/marine.json');
const importJson = () => assert.fail('the browser path must not import JSON');
assert.deepEqual(await loadBundledJson(url, importJson), { features: [] });
const url = new URL(
'http://localhost/src/data/local_data/natural_earth/marine.json',
);
assert.deepEqual(await loadBundledJson(url), { features: [] });
assert.deepEqual(requests, [url.href]);
});
test('bundled JSON: an HTTP error rejects so the retryable loader can retry', async (t) => {
t.mock.method(globalThis, 'fetch', async () => new Response('', { status: 404 }));
t.mock.method(
globalThis,
'fetch',
async () => new Response('', { status: 404 }),
);
await assert.rejects(
loadBundledJson(new URL('http://localhost/missing.json'), importMarine),
loadBundledJson(new URL('http://localhost/missing.json')),
/HTTP 404/,
);
});
test('bundled JSON: an aborted load rejects before reading', async () => {
const controller = new AbortController();
controller.abort();
await assert.rejects(loadBundledJson(MARINE, { signal: controller.signal }), {
name: 'AbortError',
});
});
test('bundled JSON: no data pack is imported as a module', async () => {
// A literal JSON import in app code makes the production build emit a
// JavaScript copy of the pack beside the fetched JSON, which the browser
// never loads. Tests are not bundled, so they may still import JSON.
const { spawnSync } = await import('node:child_process');
const result = spawnSync(
'git',
[
'grep',
'-nE',
String.raw`import\(\s*['"][^'"]*local_data/[^'"]*\.json`,
'--',
'src',
':!*.test.mjs',
],
{ cwd: new URL('../../', import.meta.url), encoding: 'utf8' },
);
// git grep exits 1 when nothing matches.
assert.equal(result.status, 1, result.stdout);
});
test('bundled JSON: an older Node says which version is needed', async (t) => {
// Node before 20.16 / 22.3 has no process.getBuiltinModule.
const original = process.getBuiltinModule;
t.after(() => {
process.getBuiltinModule = original;
});
process.getBuiltinModule = undefined;
await assert.rejects(loadBundledJson(MARINE), {
message: /needs Node 24\.14 or newer to read marine\.json/,
});
});
+25
View File
@@ -128,6 +128,14 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
sources.slice(0, 3).map((s) => s.id),
['env-0', 'env-1', 'env-2'],
);
assert.equal(sources[0].pack, 'env');
const [trimmed] = getSources.trimmedPacks();
assert.deepEqual(
{ ...trimmed, region: undefined },
{ pack: 'env', available: 12, served: 9, region: undefined },
);
assert.equal(trimmed.region.south, 30);
assert.ok(Math.abs(trimmed.region.north - 30.11) < 1e-9);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
@@ -135,3 +143,20 @@ test('the catalog applies the fair cap to configured packs end to end', async ()
Object.assign(process.env, saved);
}
});
test('a pack trimmed to its nearest cameras reports how many it offered', async () => {
const { prioritizeSources } =
await import('../../server/providers/cctv/normalize.js');
const cameras = Array.from({ length: 5 }, (_, i) => ({
id: `c${i}`,
lat: i,
lon: 0,
}));
const kept = prioritizeSources(cameras, 2, [{ lat: 0, lon: 0 }]);
assert.deepEqual(
kept.map((camera) => camera.id),
['c0', 'c1'],
);
assert.equal(kept.available, 5);
assert.deepEqual(kept.region, { west: 0, south: 0, east: 0, north: 4 });
});
+2 -2
View File
@@ -1,12 +1,12 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationFlights } from '../app/layers/flights.js';
import { createOpenSkySource } from '../sources/live/standalone.js';
import { createFlightSource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const flightsLayer = createApplicationFlights({
surface: defaultSurface,
source: createOpenSkySource(),
source: createFlightSource(),
militaryRegistry,
});
export { TRACKED_MODEL_MAX_PX } from '../layers/flights/policy.js';
+1 -1
View File
@@ -203,7 +203,7 @@ test('flights poll refreshes tracked callsign/FL/kts and marks a missed poll STA
const nowSec = Math.floor(Date.now() / 1000);
let openskyPoll = 0;
globalThis.fetch = async (url) => {
if (!String(url).startsWith('/api/opensky')) {
if (!String(url).startsWith('/api/flights')) {
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
}
const states = openskyPoll++ === 0
+3 -8
View File
@@ -1,12 +1,5 @@
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
// Resolved by Vite in builds and relative to this module in other consumers.
const datacentersUrl = new URL(
'./local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href;
const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
.href;
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.js';
/**
* Create fresh datacenter and dam layers without starting or loading them.
@@ -14,6 +7,8 @@ const damsUrl = new URL('./local_data/dams/dams.geojsonl', import.meta.url)
* @returns {object[]} Datacenters then dams, with stable standalone identities.
*/
export function createInfrastructureLayers(services) {
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
const datacenters = createLocalGeoJsonLayer(
{
id: 'local-datacenters',
+1
View File
@@ -192,6 +192,7 @@ test('consumer build includes only infrastructure code and resolves assets under
const sources = Object.keys(entry.modules).filter((id) => id.endsWith('.js'));
assert.deepEqual(sources.map((id) => id.split('/').at(-1)).sort(), [
'infrastructure.js',
'infrastructureData.js',
'infrastructureOverlayEntry.js',
'localGeojsonCore.js',
'localGeojsonLod.js',
+2 -723
View File
@@ -11,8 +11,6 @@ export const LAYER_STATE_TOKEN_ALPHABET =
'0123456789abcdefghijklmnopqrstuvwxyz';
const LAYER_STATE_TOKEN_PATTERN = /^[a-z0-9]{1,2}$/;
const NEW_SINGLE_CHARACTER_TOKEN_PATTERN = /^[0-9]$/;
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
const PENDING_TRACKING_POLL_MS = 1_000;
/**
* A tracking ID is a transponder address, not free text: 6 hex digits for an
* ICAO24, with slack for TIS-B (`~abc123`) and similar prefixed forms. Bounding
@@ -433,12 +431,6 @@ const OPTION_GROUPS = Object.freeze({
]),
});
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
flights: 'selectedFlightsTrackingId',
military: 'selectedMilitaryTrackingId',
satellites: 'selectedSatTrackingId',
});
export const SHARE_TRACKING_RESTORE_POLICIES = Object.freeze({
flights: Object.freeze({
optionOwner: 'flights',
@@ -740,7 +732,7 @@ export function validateLayerStateAllocations(
return true;
}
const REGISTRY_BY_ID = new Map(
export const REGISTRY_BY_ID = new Map(
LAYER_STATE_REGISTRY.map((entry) => [entry.id, entry]),
);
const REGISTRY_BY_TOKEN = new Map(
@@ -752,7 +744,7 @@ const OPTION_OWNER_IDS = Object.freeze([
),
]);
function optionSpecs(ownerId) {
export function optionSpecs(ownerId) {
return OPTION_GROUPS[ownerId] || [];
}
@@ -1055,716 +1047,3 @@ export function layerOptionsForRestore(state, layerId) {
if (!entry?.optionOwner) return null;
return { ...normalizeLayerState(state).options[entry.optionOwner] };
}
function safeStorage() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}
function currentLayerOutcome(dataManager, layerId) {
const state = dataManager.getLayerLifecycleState?.(layerId);
return {
settledEnabled: Boolean(state?.enabled),
lifecycleState: state?.lifecycleState || 'missing',
lifecycleUncertain: Boolean(state?.uncertain),
};
}
/**
* Owns durable user layer preferences independently from transient runtime
* choreography, and coordinates passive post-registration restoration.
*/
export class LayerStateCoordinator {
constructor(
dataManager,
shareLinkManager,
{
storage = safeStorage(),
restoreGate = null,
onDurableStateChange = null,
onTrackingRestoreStatus = null,
now = () => Date.now(),
// Injectable so the pending-window behavior is deterministically testable
// without sleeping out a 90 s expiry.
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (handle) => clearTimeout(handle),
} = {},
) {
if (!dataManager?.registrationsFinalized) {
throw new Error(
'Layer state requires finalized data-layer registrations',
);
}
this.dataManager = dataManager;
this.shareLinkManager = shareLinkManager || null;
this.storage = storage;
this.restoreGate = restoreGate;
this.onDurableStateChange = onDurableStateChange;
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this._durableState = createDefaultLayerState();
this._source = 'defaults';
this._destroyed = false;
this._restoreControllers = new Map();
this._shareCreatedAtMs = null;
this._trackingRestoreController = null;
this._trackingRestoreGeneration = 0;
this._pendingTrackingTimer = null;
this._pendingTrackingContext = null;
this._unsubscribe = this.dataManager.subscribe((change) =>
this._handleManagerChange(change),
);
this._unsubscribeVisibilityRequests =
this.dataManager.subscribeVisibilityRequests((change) =>
this._handleVisibilityRequest(change),
);
this.restorePromise = Promise.resolve([]);
this.lastRestoreResults = [];
}
start({
shareLayerState = null,
allowLocalState = true,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
if (selected) {
this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
? shareCreatedAtMs
: null;
} else if (allowLocalState) {
let stored = null;
try {
stored = parseStoredLayerState(
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
);
} catch {
/* best effort */
}
if (stored) {
selected = stored;
this._source = 'local';
}
} else {
// A valid historical camera/style share with no v2 layer payload keeps
// the exact legacy default-layer behavior. It must not inherit an
// unrelated recipient's saved local layer preferences.
this._source = 'legacy-share';
}
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.setLayerStateProvider?.(() =>
this.getDurableState(),
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
if (!selected) return this.restorePromise;
this.restorePromise = this._restoreSelectedState(
this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
);
return this.restorePromise;
}
get source() {
return this._source;
}
getDurableState() {
return cloneLayerState(this._durableState);
}
_notifyDurableState() {
try {
this.onDurableStateChange?.(this.getDurableState());
} catch {
/* UI sync is best effort */
}
}
_handleVisibilityRequest(change) {
if (!isExplicitLayerStateOrigin(change?.origin)) return;
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
}
/** Revoke every passive restore before explicit navigation can be reclaimed. */
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
for (const controller of this._restoreControllers.values())
controller.abort(reason);
this._revokePendingTrackingWatch(reason);
}
/**
* Revoke a pending shared Follow. Physical navigation may also clear only
* the exact passive selection, without writing recipient preferences.
*/
cancelPendingShareTracking(
reason = 'superseded-by-explicit-navigation',
{ clearSelection = false } = {},
) {
this._revokePendingTrackingWatch(reason);
if (!clearSelection) return false;
const selected = this._selectedShareTrackingTarget();
return selected ? this._passivelyClearTrackingSelection(selected) : false;
}
_handleManagerChange(change) {
if (!change || this._destroyed) return;
// Parameter and visibility ownership are independent. A newer explicit
// option request may replace passive share options, but it must not abort
// the same layer's visibility lifecycle.
if (change.type === 'params-requested') {
if (
isExplicitLayerStateOrigin(change.origin) &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('superseded-by-explicit-params');
}
return;
}
// A layer that goes away takes its latch with it, at ANY origin — a
// programmatic disable or teardown never reaches the explicit-intent path
// below, so revoke here before that early return.
if (
change.type === 'visibility' &&
change.enabled === false &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
const enabled = new Set(this._durableState.enabledLayerIds);
if (change.enabled) enabled.add(change.layerId);
else enabled.delete(change.layerId);
this._commitExplicit({
...this._durableState,
enabledLayerIds: [...enabled],
});
return;
}
if (change.type !== 'params') return;
const entry = REGISTRY_BY_ID.get(change.layerId);
if (!entry?.optionOwner) return;
const ownerId = entry.optionOwner;
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
const requestedParams = change.requestedParams || {};
const trackingOptionKey =
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
let changed = false;
for (const spec of optionSpecs(ownerId)) {
// Only persist keys present in this explicit request. getLayerParams()
// can return a wider live snapshot containing transient or passively
// changed values that this user action did not own. Tracking is the one
// exception: an unrelated explicit option cancels a pending restoration
// in that family, so its wider live value (active ID or null) must replace
// the formerly durable pending ID instead of allowing reload resurrection.
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
changed = true;
continue;
}
if (spec.defaultValue !== null || change.params[spec.key] !== null)
continue;
}
nextOwnerOptions[spec.key] = value;
changed = true;
}
if (!changed) return;
this._commitExplicit({
...this._durableState,
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
});
}
_commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate);
const serialized = serializeStoredLayerState(this._durableState);
try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
}
} catch {
/* storage can be unavailable or quota-limited */
}
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
}
async _waitForRestoreGate() {
if (!this.restoreGate) return;
await (typeof this.restoreGate === 'function'
? this.restoreGate()
: this.restoreGate);
}
async _restoreSelectedState(origin) {
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
}
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
TRACKING_OPTION_KEY_BY_LAYER,
)) {
delete options[trackingKey];
}
}
if (this._destroyed || controller?.signal.aborted) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'reserved',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
errorClass: 'cancelled',
persistenceWrite: false,
succeeded: false,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
const paramsSucceeded =
!options ||
Object.keys(options).length === 0 ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return this.dataManager
.restoreLayerState(
entry.id,
{
enabled: targetEnabled,
params: null,
},
{ origin, signal: controller.signal },
)
.then((result) => ({
...result,
appliedOptions: paramsSucceeded && options ? options : {},
errorClass: paramsSucceeded
? result.errorClass
: 'ParamsRejected',
succeeded: paramsSucceeded && result.succeeded,
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
layerId: entry.id,
targetEnabled: enabled.has(entry.id),
origin,
phase: 'coordinator',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: null,
errorClass: result.reason?.name || 'Error',
error: String(result.reason?.message || result.reason),
persistenceWrite: false,
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
this._notifyDurableState();
}
}
_selectedShareTrackingTarget() {
if (this._source !== 'share') return null;
for (const [layerId, policy] of Object.entries(
SHARE_TRACKING_RESTORE_POLICIES,
)) {
const targetId =
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
if (targetId !== null && targetId !== undefined && targetId !== '') {
return { layerId, targetId, ...policy };
}
}
return null;
}
_passivelyClearTrackingSelection(selected) {
const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
if (String(current) !== String(selected.targetId)) return false;
const ownerOptions = {
...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null,
};
this._durableState = normalizeLayerState({
...this._durableState,
options: {
...this._durableState.options,
[selected.optionOwner]: ownerOptions,
},
});
this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: null },
{ origin: LAYER_RESTORE_ORIGINS.share },
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
return true;
}
/**
* `atMs` is the moment the subject was first found ABSENT, not the moment the
* verdict is delivered. Waiting out the pending window must not by itself
* push a fresh link into the "expired" wording — that word describes the
* SHARE's age, not how long this client watched for the subject.
*/
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
const copiedAt = this._shareCreatedAtMs;
if (!Number.isFinite(copiedAt)) return 'unavailable';
const ageMs = atMs - copiedAt;
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
}
/** Whether the owning layer currently follows the shared subject. */
_trackingTargetLatched(selected) {
const params = this.dataManager.getLayerParams?.(selected.layerId);
const active = params?.[selected.optionKey];
return (
active !== null &&
active !== undefined &&
String(active) === String(selected.targetId)
);
}
/** Stop watching a pending shared subject without deciding its fate. */
_cancelPendingTrackingWatch() {
if (this._pendingTrackingTimer !== null)
this.clearTimer(this._pendingTrackingTimer);
this._pendingTrackingTimer = null;
const pending = this._pendingTrackingContext;
if (pending?.signal && pending.abortHandler) {
pending.signal.removeEventListener('abort', pending.abortHandler);
pending.abortHandler = null;
}
}
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
_publishTrackingRestoreStatus(status) {
try {
this.onTrackingRestoreStatus?.(status);
} catch {
/* status UI is best effort */
}
}
/**
* Revoke a pending shared Follow wholesale.
*
* The watch and the LAYER's own deferred-restore latch are two halves of one
* mechanism, so they must die together. Aborting only the restore controller
* left the timer alive: the controller has already settled by the time the
* watch exists, so the abort was a no-op and the orphaned timer went on to
* announce "Shared … unavailable" for a subject whose latch had been
* cancelled — a notice about work no longer being attempted.
*/
_revokePendingTrackingWatch(reason) {
this._trackingRestoreController?.abort(reason);
this._trackingRestoreGeneration += 1;
this._cancelPendingTrackingWatch();
const pending = this._pendingTrackingContext;
this._pendingTrackingContext = null;
if (pending) {
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(reason || 'cancelled'),
});
this._publishTrackingRestoreStatus({
...pending.probe,
...pending.selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(reason || 'cancelled'),
cleared: false,
});
}
}
/**
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
*
* A recipient's first authoritative refresh routinely lands without a given
* contact — the feed is polled, coverage is partial, and rendering trails the
* snapshot by a poll. Reload-from-local already survives this: its restore
* arms the layer's own deferred-restore latch, which re-attempts on every
* later poll. The shared path used to decide on that single refresh, clear
* the subject from durable state AND from the URL, then post a failure notice
* seconds into startup — so the same link healed on reload but never on the
* share.
*
* Arm the SAME latch, then watch it in the background: the caller is never
* blocked (startup must not wait out a 90 s window before it may write the
* URL again), and the terminal verdict is deferred until the source-specific
* window has genuinely expired. The existing wordings are unchanged.
*/
async _beginPendingTrackingRestore(selected, probe, signal = null) {
const generation = this._trackingRestoreGeneration;
const absentAtMs = this.now();
// Arm the layer's deferred-restore latch under the passive share origin, so
// it re-attempts each poll and never rewrites recipient preferences.
let armed = false;
let armError = null;
try {
armed =
(await this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: selected.targetId },
{ origin: LAYER_RESTORE_ORIGINS.share },
)) === true;
} catch (error) {
armError = error;
}
if (
this._destroyed ||
generation !== this._trackingRestoreGeneration ||
signal?.aborted
) {
if (armed) {
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(signal?.reason || 'superseded'),
});
}
return {
...probe,
...selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(signal?.reason || 'superseded'),
cleared: false,
};
}
if (!armed) {
const terminal = {
...probe,
...selected,
status: 'source-unavailable',
classification: 'source-unavailable',
reason: String(
armError?.message || armError || 'tracking restore latch rejected',
),
cleared: this._passivelyClearTrackingSelection(selected),
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
const deadline = this.now() + selected.expiryWindowMs;
const settle = (terminal) => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._cancelPendingTrackingWatch();
this._pendingTrackingContext = null;
this._publishTrackingRestoreStatus(terminal);
};
const poll = () => {
this._pendingTrackingTimer = null;
if (this._destroyed || generation !== this._trackingRestoreGeneration)
return;
// The layer that owns the latch may have gone away since the last tick
// (disable, teardown, replacement). There is nothing left attempting this
// restore, so abandon it silently rather than announcing a verdict.
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
this._revokePendingTrackingWatch('owner-layer-disabled');
return;
}
if (this._trackingTargetLatched(selected)) {
settle({
...probe,
...selected,
status: 'found',
classification: 'followed',
cleared: false,
});
return;
}
if (this.now() >= deadline) {
// The window really has elapsed — only now does the verdict apply.
const classification =
probe.status === 'missing'
? this._classifyMissingTrackingTarget(selected, absentAtMs)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
settle({ ...probe, ...selected, classification, cleared });
return;
}
this._pendingTrackingTimer = this.setTimer(
poll,
PENDING_TRACKING_POLL_MS,
);
this._pendingTrackingTimer?.unref?.();
};
this._cancelPendingTrackingWatch();
const pending = {
...probe,
...selected,
status: 'pending',
classification: 'pending',
cleared: false,
};
const pendingContext = {
generation,
selected,
probe,
signal,
abortHandler: null,
};
if (signal) {
pendingContext.abortHandler = () => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._revokePendingTrackingWatch(signal.reason || 'aborted');
};
signal.addEventListener('abort', pendingContext.abortHandler, {
once: true,
});
}
this._pendingTrackingContext = pendingContext;
this._publishTrackingRestoreStatus(pending);
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
this._pendingTrackingTimer?.unref?.();
return pending;
}
/**
* Refresh and restore the one shareable tracked target after destination
* camera and ordinary layer restoration have settled.
*/
async restoreShareTrackingSelection({ signal = null } = {}) {
const selected = this._selectedShareTrackingTarget();
if (!selected || this._destroyed)
return { status: 'skipped', reason: 'no-shared-target' };
this._revokePendingTrackingWatch('superseded-by-newer-restore');
const controller = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
this._trackingRestoreController = controller;
const generation = ++this._trackingRestoreGeneration;
let result;
try {
result = await this.dataManager.resolveLayerTrackingTarget(
selected.layerId,
selected.targetId,
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
);
} catch (error) {
result = combinedSignal.aborted
? {
status: 'cancelled',
reason: String(combinedSignal.reason || 'aborted'),
}
: {
status: 'source-unavailable',
reason: String(error?.message || error),
};
}
if (
generation !== this._trackingRestoreGeneration ||
this._destroyed ||
combinedSignal.aborted
) {
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
return {
...result,
status: 'cancelled',
reason: String(combinedSignal.reason || 'superseded'),
};
}
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
if (result.status === 'found') {
const terminal = {
...result,
...selected,
classification: 'followed',
cleared: false,
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
return result;
// A subject that is simply not here YET is not a subject that is gone. Hold
// it on the layer's own deferred-restore latch for its source-specific
// window before any verdict is reached or shown. `unsupported` layers have
// no latch to arm, so they still decide immediately.
if (result.status === 'missing' || result.status === 'source-unavailable') {
return this._beginPendingTrackingRestore(
selected,
result,
combinedSignal,
);
}
const classification =
result.status === 'missing'
? this._classifyMissingTrackingTarget(selected)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
const terminal = { ...result, ...selected, classification, cleared };
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
for (const controller of this._restoreControllers.values())
controller.abort('coordinator-destroyed');
this._restoreControllers.clear();
this._revokePendingTrackingWatch('coordinator-destroyed');
this._trackingRestoreController = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeVisibilityRequests?.();
this._unsubscribeVisibilityRequests = null;
this.shareLinkManager?.setLayerStateProvider?.(null);
this.onDurableStateChange = null;
this.onTrackingRestoreStatus = null;
}
}
+1 -1
View File
@@ -11,7 +11,6 @@ import {
LAYER_STATE_STORAGE_KEY,
LAYER_STATE_TOKEN_ALPHABET,
LAYER_STATE_TOKEN_RESERVATIONS,
LayerStateCoordinator,
REGISTERED_LAYER_IDS,
SHARE_TRACKING_RESTORE_POLICIES,
createDefaultLayerState,
@@ -24,6 +23,7 @@ import {
validateLayerStateAllocations,
validateLayerStateRegistry,
} from './layerState.js';
import { LayerStateCoordinator } from './layerStateCoordinator.js';
import radioLayer from './radio.js';
import { stampInitialShareGesture } from '../navigationPolicy.js';
+742
View File
@@ -0,0 +1,742 @@
/**
* Applies layer state to the running application: restores shared and stored
* layer choices and persists changes to browser storage.
*/
import {
LAYER_RESTORE_ORIGINS,
LAYER_STATE_REGISTRY,
LAYER_STATE_STORAGE_KEY,
REGISTRY_BY_ID,
SHARE_TRACKING_RESTORE_POLICIES,
cloneLayerState,
createDefaultLayerState,
isExplicitLayerStateOrigin,
layerOptionsForRestore,
normalizeLayerState,
optionSpecs,
parseStoredLayerState,
serializeStoredLayerState,
} from './layerState.js';
/** Re-check cadence while a shared subject waits for its feed row to arrive. */
const PENDING_TRACKING_POLL_MS = 1_000;
const TRACKING_OPTION_KEY_BY_LAYER = Object.freeze({
flights: 'selectedFlightsTrackingId',
military: 'selectedMilitaryTrackingId',
satellites: 'selectedSatTrackingId',
});
function safeStorage() {
try {
return globalThis.localStorage || null;
} catch {
return null;
}
}
function currentLayerOutcome(dataManager, layerId) {
const state = dataManager.getLayerLifecycleState?.(layerId);
return {
settledEnabled: Boolean(state?.enabled),
lifecycleState: state?.lifecycleState || 'missing',
lifecycleUncertain: Boolean(state?.uncertain),
};
}
/**
* Owns durable user layer preferences independently from transient runtime
* choreography, and coordinates passive post-registration restoration.
*/
export class LayerStateCoordinator {
constructor(
dataManager,
shareLinkManager,
{
storage = safeStorage(),
restoreGate = null,
onDurableStateChange = null,
onTrackingRestoreStatus = null,
now = () => Date.now(),
// Injectable so the pending-window behavior is deterministically testable
// without sleeping out a 90 s expiry.
setTimer = (fn, ms) => setTimeout(fn, ms),
clearTimer = (handle) => clearTimeout(handle),
} = {},
) {
if (!dataManager?.registrationsFinalized) {
throw new Error(
'Layer state requires finalized data-layer registrations',
);
}
this.dataManager = dataManager;
this.shareLinkManager = shareLinkManager || null;
this.storage = storage;
this.restoreGate = restoreGate;
this.onDurableStateChange = onDurableStateChange;
this.onTrackingRestoreStatus = onTrackingRestoreStatus;
this.now = now;
this.setTimer = setTimer;
this.clearTimer = clearTimer;
this._durableState = createDefaultLayerState();
this._source = 'defaults';
this._destroyed = false;
this._restoreControllers = new Map();
this._shareCreatedAtMs = null;
this._trackingRestoreController = null;
this._trackingRestoreGeneration = 0;
this._pendingTrackingTimer = null;
this._pendingTrackingContext = null;
this._unsubscribe = this.dataManager.subscribe((change) =>
this._handleManagerChange(change),
);
this._unsubscribeVisibilityRequests =
this.dataManager.subscribeVisibilityRequests((change) =>
this._handleVisibilityRequest(change),
);
this.restorePromise = Promise.resolve([]);
this.lastRestoreResults = [];
}
start({
shareLayerState = null,
allowLocalState = true,
shareCreatedAtMs = null,
} = {}) {
if (this._destroyed)
throw new Error('Layer-state coordinator is destroyed');
let selected = shareLayerState
? normalizeLayerState(shareLayerState)
: null;
if (selected) {
this._source = 'share';
this._shareCreatedAtMs = Number.isFinite(shareCreatedAtMs)
? shareCreatedAtMs
: null;
} else if (allowLocalState) {
let stored = null;
try {
stored = parseStoredLayerState(
this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY),
);
} catch {
/* best effort */
}
if (stored) {
selected = stored;
this._source = 'local';
}
} else {
// A valid historical camera/style share with no v2 layer payload keeps
// the exact legacy default-layer behavior. It must not inherit an
// unrelated recipient's saved local layer preferences.
this._source = 'legacy-share';
}
this._durableState = selected || createDefaultLayerState();
this.shareLinkManager?.setLayerStateProvider?.(() =>
this.getDurableState(),
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
if (!selected) return this.restorePromise;
this.restorePromise = this._restoreSelectedState(
this._source === 'share'
? LAYER_RESTORE_ORIGINS.share
: LAYER_RESTORE_ORIGINS.local,
);
return this.restorePromise;
}
get source() {
return this._source;
}
getDurableState() {
return cloneLayerState(this._durableState);
}
_notifyDurableState() {
try {
this.onDurableStateChange?.(this.getDurableState());
} catch {
/* UI sync is best effort */
}
}
_handleVisibilityRequest(change) {
if (!isExplicitLayerStateOrigin(change?.origin)) return;
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
}
/** Revoke every passive restore before explicit navigation can be reclaimed. */
cancelPendingRestores(reason = 'superseded-by-explicit-navigation') {
for (const controller of this._restoreControllers.values())
controller.abort(reason);
this._revokePendingTrackingWatch(reason);
}
/**
* Revoke a pending shared Follow. Physical navigation may also clear only
* the exact passive selection, without writing recipient preferences.
*/
cancelPendingShareTracking(
reason = 'superseded-by-explicit-navigation',
{ clearSelection = false } = {},
) {
this._revokePendingTrackingWatch(reason);
if (!clearSelection) return false;
const selected = this._selectedShareTrackingTarget();
return selected ? this._passivelyClearTrackingSelection(selected) : false;
}
_handleManagerChange(change) {
if (!change || this._destroyed) return;
// Parameter and visibility ownership are independent. A newer explicit
// option request may replace passive share options, but it must not abort
// the same layer's visibility lifecycle.
if (change.type === 'params-requested') {
if (
isExplicitLayerStateOrigin(change.origin) &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('superseded-by-explicit-params');
}
return;
}
// A layer that goes away takes its latch with it, at ANY origin — a
// programmatic disable or teardown never reaches the explicit-intent path
// below, so revoke here before that early return.
if (
change.type === 'visibility' &&
change.enabled === false &&
SHARE_TRACKING_RESTORE_POLICIES[change.layerId]
) {
this._revokePendingTrackingWatch('owner-layer-disabled');
}
if (!isExplicitLayerStateOrigin(change.origin)) return;
if (change.type === 'visibility') {
this._restoreControllers
.get(change.layerId)
?.abort('superseded-by-explicit-visibility');
if (SHARE_TRACKING_RESTORE_POLICIES[change.layerId]) {
this._revokePendingTrackingWatch('superseded-by-explicit-visibility');
}
const enabled = new Set(this._durableState.enabledLayerIds);
if (change.enabled) enabled.add(change.layerId);
else enabled.delete(change.layerId);
this._commitExplicit({
...this._durableState,
enabledLayerIds: [...enabled],
});
return;
}
if (change.type !== 'params') return;
const entry = REGISTRY_BY_ID.get(change.layerId);
if (!entry?.optionOwner) return;
const ownerId = entry.optionOwner;
const nextOwnerOptions = { ...this._durableState.options[ownerId] };
const requestedParams = change.requestedParams || {};
const trackingOptionKey =
TRACKING_OPTION_KEY_BY_LAYER[change.layerId] || null;
let changed = false;
for (const spec of optionSpecs(ownerId)) {
// Only persist keys present in this explicit request. getLayerParams()
// can return a wider live snapshot containing transient or passively
// changed values that this user action did not own. Tracking is the one
// exception: an unrelated explicit option cancels a pending restoration
// in that family, so its wider live value (active ID or null) must replace
// the formerly durable pending ID instead of allowing reload resurrection.
const explicitlyRequested = Object.hasOwn(requestedParams, spec.key);
const implicitTrackingSync =
!explicitlyRequested && spec.key === trackingOptionKey;
if (!explicitlyRequested && !implicitTrackingSync) continue;
const value = spec.normalize(change.params[spec.key]);
if (value === null) {
if (implicitTrackingSync) {
nextOwnerOptions[spec.key] = null;
changed = true;
continue;
}
if (spec.defaultValue !== null || change.params[spec.key] !== null)
continue;
}
nextOwnerOptions[spec.key] = value;
changed = true;
}
if (!changed) return;
this._commitExplicit({
...this._durableState,
options: { ...this._durableState.options, [ownerId]: nextOwnerOptions },
});
}
_commitExplicit(candidate) {
this._durableState = normalizeLayerState(candidate);
const serialized = serializeStoredLayerState(this._durableState);
try {
if (this.storage?.getItem?.(LAYER_STATE_STORAGE_KEY) !== serialized) {
this.storage?.setItem?.(LAYER_STATE_STORAGE_KEY, serialized);
}
} catch {
/* storage can be unavailable or quota-limited */
}
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
}
async _waitForRestoreGate() {
if (!this.restoreGate) return;
await (typeof this.restoreGate === 'function'
? this.restoreGate()
: this.restoreGate);
}
async _restoreSelectedState(origin) {
for (const entry of LAYER_STATE_REGISTRY) {
this._restoreControllers.set(entry.id, new AbortController());
}
try {
await this._waitForRestoreGate();
const enabled = new Set(this._durableState.enabledLayerIds);
const settled = await Promise.allSettled(
LAYER_STATE_REGISTRY.map(async (entry) => {
const controller = this._restoreControllers.get(entry.id);
const targetEnabled = enabled.has(entry.id);
const options = layerOptionsForRestore(this._durableState, entry.id);
if (origin === LAYER_RESTORE_ORIGINS.share && options) {
for (const trackingKey of Object.values(
TRACKING_OPTION_KEY_BY_LAYER,
)) {
delete options[trackingKey];
}
}
if (this._destroyed || controller?.signal.aborted) {
return {
layerId: entry.id,
targetEnabled,
origin,
phase: 'reserved',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: this._destroyed ? 'destroyed' : 'superseded',
errorClass: 'cancelled',
persistenceWrite: false,
succeeded: false,
};
}
// Reserve passive option state before any asynchronous lifecycle work.
// A later explicit params intent then wins on its own lane without
// cancelling or being overwritten by the visibility restore.
const paramsSucceeded =
!options ||
Object.keys(options).length === 0 ||
this.dataManager.setLayerParams(entry.id, options, { origin });
return this.dataManager
.restoreLayerState(
entry.id,
{
enabled: targetEnabled,
params: null,
},
{ origin, signal: controller.signal },
)
.then((result) => ({
...result,
appliedOptions: paramsSucceeded && options ? options : {},
errorClass: paramsSucceeded
? result.errorClass
: 'ParamsRejected',
succeeded: paramsSucceeded && result.succeeded,
}));
}),
);
this.lastRestoreResults = settled.map((result, index) => {
if (result.status === 'fulfilled') return result.value;
const entry = LAYER_STATE_REGISTRY[index];
return {
layerId: entry.id,
targetEnabled: enabled.has(entry.id),
origin,
phase: 'coordinator',
...currentLayerOutcome(this.dataManager, entry.id),
appliedOptions: {},
cancellationReason: null,
errorClass: result.reason?.name || 'Error',
error: String(result.reason?.message || result.reason),
persistenceWrite: false,
succeeded: false,
};
});
return this.lastRestoreResults.map((result) => ({ ...result }));
} finally {
this._restoreControllers.clear();
this._notifyDurableState();
}
}
_selectedShareTrackingTarget() {
if (this._source !== 'share') return null;
for (const [layerId, policy] of Object.entries(
SHARE_TRACKING_RESTORE_POLICIES,
)) {
const targetId =
this._durableState.options?.[policy.optionOwner]?.[policy.optionKey];
if (targetId !== null && targetId !== undefined && targetId !== '') {
return { layerId, targetId, ...policy };
}
}
return null;
}
_passivelyClearTrackingSelection(selected) {
const current =
this._durableState.options?.[selected.optionOwner]?.[selected.optionKey];
if (String(current) !== String(selected.targetId)) return false;
const ownerOptions = {
...this._durableState.options[selected.optionOwner],
[selected.optionKey]: null,
};
this._durableState = normalizeLayerState({
...this._durableState,
options: {
...this._durableState.options,
[selected.optionOwner]: ownerOptions,
},
});
this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: null },
{ origin: LAYER_RESTORE_ORIGINS.share },
);
this.shareLinkManager?.onLayerStateChange?.();
this._notifyDurableState();
return true;
}
/**
* `atMs` is the moment the subject was first found ABSENT, not the moment the
* verdict is delivered. Waiting out the pending window must not by itself
* push a fresh link into the "expired" wording — that word describes the
* SHARE's age, not how long this client watched for the subject.
*/
_classifyMissingTrackingTarget(selected, atMs = this.now()) {
const copiedAt = this._shareCreatedAtMs;
if (!Number.isFinite(copiedAt)) return 'unavailable';
const ageMs = atMs - copiedAt;
return ageMs > selected.expiryWindowMs ? 'expired' : 'unavailable';
}
/** Whether the owning layer currently follows the shared subject. */
_trackingTargetLatched(selected) {
const params = this.dataManager.getLayerParams?.(selected.layerId);
const active = params?.[selected.optionKey];
return (
active !== null &&
active !== undefined &&
String(active) === String(selected.targetId)
);
}
/** Stop watching a pending shared subject without deciding its fate. */
_cancelPendingTrackingWatch() {
if (this._pendingTrackingTimer !== null)
this.clearTimer(this._pendingTrackingTimer);
this._pendingTrackingTimer = null;
const pending = this._pendingTrackingContext;
if (pending?.signal && pending.abortHandler) {
pending.signal.removeEventListener('abort', pending.abortHandler);
pending.abortHandler = null;
}
}
/** Publish a share-follow lifecycle update without allowing UI errors to own state. */
_publishTrackingRestoreStatus(status) {
try {
this.onTrackingRestoreStatus?.(status);
} catch {
/* status UI is best effort */
}
}
/**
* Revoke a pending shared Follow wholesale.
*
* The watch and the LAYER's own deferred-restore latch are two halves of one
* mechanism, so they must die together. Aborting only the restore controller
* left the timer alive: the controller has already settled by the time the
* watch exists, so the abort was a no-op and the orphaned timer went on to
* announce "Shared … unavailable" for a subject whose latch had been
* cancelled — a notice about work no longer being attempted.
*/
_revokePendingTrackingWatch(reason) {
this._trackingRestoreController?.abort(reason);
this._trackingRestoreGeneration += 1;
this._cancelPendingTrackingWatch();
const pending = this._pendingTrackingContext;
this._pendingTrackingContext = null;
if (pending) {
this.dataManager.cancelPendingLayerRestore?.(pending.selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(reason || 'cancelled'),
});
this._publishTrackingRestoreStatus({
...pending.probe,
...pending.selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(reason || 'cancelled'),
cleared: false,
});
}
}
/**
* Hold a not-yet-arrived shared subject PENDING instead of declaring it gone.
*
* A recipient's first authoritative refresh routinely lands without a given
* contact — the feed is polled, coverage is partial, and rendering trails the
* snapshot by a poll. Reload-from-local already survives this: its restore
* arms the layer's own deferred-restore latch, which re-attempts on every
* later poll. The shared path used to decide on that single refresh, clear
* the subject from durable state AND from the URL, then post a failure notice
* seconds into startup — so the same link healed on reload but never on the
* share.
*
* Arm the SAME latch, then watch it in the background: the caller is never
* blocked (startup must not wait out a 90 s window before it may write the
* URL again), and the terminal verdict is deferred until the source-specific
* window has genuinely expired. The existing wordings are unchanged.
*/
async _beginPendingTrackingRestore(selected, probe, signal = null) {
const generation = this._trackingRestoreGeneration;
const absentAtMs = this.now();
// Arm the layer's deferred-restore latch under the passive share origin, so
// it re-attempts each poll and never rewrites recipient preferences.
let armed = false;
let armError = null;
try {
armed =
(await this.dataManager.setLayerParams?.(
selected.layerId,
{ [selected.optionKey]: selected.targetId },
{ origin: LAYER_RESTORE_ORIGINS.share },
)) === true;
} catch (error) {
armError = error;
}
if (
this._destroyed ||
generation !== this._trackingRestoreGeneration ||
signal?.aborted
) {
if (armed) {
this.dataManager.cancelPendingLayerRestore?.(selected.layerId, {
origin: LAYER_RESTORE_ORIGINS.share,
reason: String(signal?.reason || 'superseded'),
});
}
return {
...probe,
...selected,
status: 'cancelled',
classification: 'cancelled',
reason: String(signal?.reason || 'superseded'),
cleared: false,
};
}
if (!armed) {
const terminal = {
...probe,
...selected,
status: 'source-unavailable',
classification: 'source-unavailable',
reason: String(
armError?.message || armError || 'tracking restore latch rejected',
),
cleared: this._passivelyClearTrackingSelection(selected),
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
const deadline = this.now() + selected.expiryWindowMs;
const settle = (terminal) => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._cancelPendingTrackingWatch();
this._pendingTrackingContext = null;
this._publishTrackingRestoreStatus(terminal);
};
const poll = () => {
this._pendingTrackingTimer = null;
if (this._destroyed || generation !== this._trackingRestoreGeneration)
return;
// The layer that owns the latch may have gone away since the last tick
// (disable, teardown, replacement). There is nothing left attempting this
// restore, so abandon it silently rather than announcing a verdict.
if (this.dataManager.isEffectivelyEnabled?.(selected.layerId) === false) {
this._revokePendingTrackingWatch('owner-layer-disabled');
return;
}
if (this._trackingTargetLatched(selected)) {
settle({
...probe,
...selected,
status: 'found',
classification: 'followed',
cleared: false,
});
return;
}
if (this.now() >= deadline) {
// The window really has elapsed — only now does the verdict apply.
const classification =
probe.status === 'missing'
? this._classifyMissingTrackingTarget(selected, absentAtMs)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
settle({ ...probe, ...selected, classification, cleared });
return;
}
this._pendingTrackingTimer = this.setTimer(
poll,
PENDING_TRACKING_POLL_MS,
);
this._pendingTrackingTimer?.unref?.();
};
this._cancelPendingTrackingWatch();
const pending = {
...probe,
...selected,
status: 'pending',
classification: 'pending',
cleared: false,
};
const pendingContext = {
generation,
selected,
probe,
signal,
abortHandler: null,
};
if (signal) {
pendingContext.abortHandler = () => {
if (this._pendingTrackingContext?.generation !== generation) return;
this._revokePendingTrackingWatch(signal.reason || 'aborted');
};
signal.addEventListener('abort', pendingContext.abortHandler, {
once: true,
});
}
this._pendingTrackingContext = pendingContext;
this._publishTrackingRestoreStatus(pending);
this._pendingTrackingTimer = this.setTimer(poll, PENDING_TRACKING_POLL_MS);
this._pendingTrackingTimer?.unref?.();
return pending;
}
/**
* Refresh and restore the one shareable tracked target after destination
* camera and ordinary layer restoration have settled.
*/
async restoreShareTrackingSelection({ signal = null } = {}) {
const selected = this._selectedShareTrackingTarget();
if (!selected || this._destroyed)
return { status: 'skipped', reason: 'no-shared-target' };
this._revokePendingTrackingWatch('superseded-by-newer-restore');
const controller = new AbortController();
const combinedSignal = signal
? AbortSignal.any([signal, controller.signal])
: controller.signal;
this._trackingRestoreController = controller;
const generation = ++this._trackingRestoreGeneration;
let result;
try {
result = await this.dataManager.resolveLayerTrackingTarget(
selected.layerId,
selected.targetId,
{ signal: combinedSignal, origin: LAYER_RESTORE_ORIGINS.share },
);
} catch (error) {
result = combinedSignal.aborted
? {
status: 'cancelled',
reason: String(combinedSignal.reason || 'aborted'),
}
: {
status: 'source-unavailable',
reason: String(error?.message || error),
};
}
if (
generation !== this._trackingRestoreGeneration ||
this._destroyed ||
combinedSignal.aborted
) {
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
return {
...result,
status: 'cancelled',
reason: String(combinedSignal.reason || 'superseded'),
};
}
if (this._trackingRestoreController === controller)
this._trackingRestoreController = null;
if (result.status === 'found') {
const terminal = {
...result,
...selected,
classification: 'followed',
cleared: false,
};
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
if (['cancelled', 'superseded', 'destroyed'].includes(result.status))
return result;
// A subject that is simply not here YET is not a subject that is gone. Hold
// it on the layer's own deferred-restore latch for its source-specific
// window before any verdict is reached or shown. `unsupported` layers have
// no latch to arm, so they still decide immediately.
if (result.status === 'missing' || result.status === 'source-unavailable') {
return this._beginPendingTrackingRestore(
selected,
result,
combinedSignal,
);
}
const classification =
result.status === 'missing'
? this._classifyMissingTrackingTarget(selected)
: 'source-unavailable';
const cleared = this._passivelyClearTrackingSelection(selected);
const terminal = { ...result, ...selected, classification, cleared };
this._publishTrackingRestoreStatus(terminal);
return terminal;
}
destroy() {
if (this._destroyed) return;
this._destroyed = true;
for (const controller of this._restoreControllers.values())
controller.abort('coordinator-destroyed');
this._restoreControllers.clear();
this._revokePendingTrackingWatch('coordinator-destroyed');
this._trackingRestoreController = null;
this._unsubscribe?.();
this._unsubscribe = null;
this._unsubscribeVisibilityRequests?.();
this._unsubscribeVisibilityRequests = null;
this.shareLinkManager?.setLayerStateProvider?.(null);
this.onDurableStateChange = null;
this.onTrackingRestoreStatus = null;
}
}
+7 -82
View File
@@ -1,4 +1,9 @@
import * as Cesium from 'cesium';
import {
layerTitle,
mapAnalystRecord,
parseGeojsonLines,
} from '../sources/infrastructureData.js';
import { createInfrastructureOverlayEntry } from './infrastructureOverlayEntry.js';
import { isPointerFree } from './inputOwnership.js';
import {
@@ -86,78 +91,6 @@ export function localInfrastructureOverlayCopy(properties, layerId) {
return { title, details };
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/**
* Produce one normalized-contract input owned by a local infrastructure layer.
* The host revalidates the authoritative `source` value while normalizing it.
@@ -664,11 +597,7 @@ export function createLocalGeoJsonLayer(
}
const text = await response.text();
if (_destroyed) return;
const lines = text
.split('\n')
.filter((l) => l.trim().length > 0);
features = lines.map((line) => JSON.parse(line));
features = parseGeojsonLines(text);
_cachedFeatures = features;
}
@@ -1360,8 +1289,4 @@ function clampCardLine(value) {
return text.length > 48 ? `${text.slice(0, 45)}...` : text;
}
function layerTitle(layerId) {
if (layerId === 'local-datacenters') return 'Datacenter';
if (layerId === 'local-dams') return 'Dam';
return 'Feature';
}
export { mapAnalystRecord };
+1 -1
View File
@@ -476,7 +476,7 @@ test('pending mapped installations render as unknown instead of a false zero', a
// the dependencies reach it by different routes:
//
// - ais-live-vessels is its REACHABLE producer. enable()/update() both resolve
// as soon as the first /api/ais-live poll answers, so the manager's
// as soon as the first /api/vessels poll answers, so the manager's
// lifecycle settles to `enabled` — but until the server-side socket delivers
// a position, firstConnectPhase stays 'loading' and the module reports busy,
// no lastUpdate, count 0, and an UNDEFINED status, so the status list alone
+2 -2
View File
@@ -1,12 +1,12 @@
import { defaultSurface } from './surfaceServices.js';
import { createApplicationMilitary } from '../app/layers/militaryFlights.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
import { createMilitarySource } from '../sources/live/standalone.js';
import * as militaryRegistry from './militaryRegistry.js';
const militaryFlightsLayer = createApplicationMilitary({
surface: defaultSurface,
source: createAdsbLolSource(),
source: createMilitarySource(),
militaryRegistry,
});
export { TRACKED_MODEL_MAX_PX } from '../layers/military/policy.js';
+327 -102
View File
@@ -24,8 +24,12 @@ test('share-Follow absence requires an accepted adsb.lol snapshot', async () =>
(await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234')).status,
'source-unavailable',
);
_setMilitaryTrackingRefreshOutcomeForTest({ status: 'accepted', ids: ['different'] });
const missing = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
_setMilitaryTrackingRefreshOutcomeForTest({
status: 'accepted',
ids: ['different'],
});
const missing =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(missing.status, 'missing');
assert.equal(missing.reason, 'target-absent-from-snapshot');
});
@@ -44,9 +48,9 @@ const FULL_INFO = {
operator: 'United States Air Force',
};
test('military stats identify adsb.lol as the primary feed, not a fallback', () => {
test('military stats name the feed until the server names its provider, not a fallback', () => {
const stats = militaryFlightsLayer.getStats();
assert.equal(stats.source, 'adsb.lol');
assert.equal(stats.source, 'Military aircraft');
assert.equal(stats.fallback, false);
});
@@ -81,8 +85,14 @@ test('military analyst record: military is ALWAYS true, routes/country always nu
});
test('military analyst record: no callsign falls back to registration, then icao24', () => {
assert.equal(mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id, '05-8152');
assert.equal(mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id, 'ae01ce');
assert.equal(
mapAnalystRecord('ae01ce', { ...FULL_INFO, callsign: '' }).id,
'05-8152',
);
assert.equal(
mapAnalystRecord('ae01ce', { callsign: '', registration: ' ' }).id,
'ae01ce',
);
});
test('military analyst record: empty info yields nulls, never NaN/undefined', () => {
@@ -91,7 +101,8 @@ test('military analyst record: empty info yields nulls, never NaN/undefined', ()
assert.equal(r.onGround, false);
for (const [key, value] of Object.entries(r)) {
assert.notEqual(value, undefined, `${key} must not be undefined`);
if (typeof value === 'number') assert.ok(Number.isFinite(value), `${key} must not be NaN`);
if (typeof value === 'number')
assert.ok(Number.isFinite(value), `${key} must not be NaN`);
}
});
@@ -103,12 +114,17 @@ test('military analyst record: output is JSON-safe (no Cesium types)', () => {
test('military first update forwards caller cancellation into the feed request', async () => {
const realFetch = globalThis.fetch;
let observedSignal = null;
globalThis.fetch = (_url, options = {}) => new Promise((_resolve, reject) => {
observedSignal = options.signal;
options.signal?.addEventListener('abort', () => {
reject(new DOMException('aborted', 'AbortError'));
}, { once: true });
});
globalThis.fetch = (_url, options = {}) =>
new Promise((_resolve, reject) => {
observedSignal = options.signal;
options.signal?.addEventListener(
'abort',
() => {
reject(new DOMException('aborted', 'AbortError'));
},
{ once: true },
);
});
try {
const controller = new AbortController();
const work = militaryFlightsLayer.update({}, { signal: controller.signal });
@@ -138,11 +154,22 @@ test('nonempty adsb.lol payload with zero usable rows cannot prove share target
json: async () => ({ ac: [null, {}, { hex: 'ae1234' }] }),
});
try {
await militaryFlightsLayer.update({ camera: { positionCartographic: null }, scene: {} });
const resolution = await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
await militaryFlightsLayer.update({
camera: { positionCartographic: null },
scene: {},
});
const resolution =
await militaryFlightsLayer.resolveTrackingRestoreTarget('ae1234');
assert.equal(resolution.status, 'source-unavailable');
assert.match(militaryFlightsLayer.getStats().error, /Malformed adsb\.lol aircraft rows/);
assert.equal(militaryFlightsLayer.getAnalystRecords().length, 1, 'warm aircraft data is preserved');
assert.match(
militaryFlightsLayer.getStats().error,
/Malformed adsb\.lol aircraft rows/,
);
assert.equal(
militaryFlightsLayer.getAnalystRecords().length,
1,
'warm aircraft data is preserved',
);
} finally {
globalThis.fetch = realFetch;
}
@@ -188,21 +215,26 @@ test('military poll refreshes tracked callsign/altitude/kts and marks a missed p
ok: true,
status: 200,
json: async () => ({
ac: poll++ === 0 ? [{
hex: icao24,
lon: -96.9,
lat: 31.1,
alt_baro: 28_000,
alt_geom: 28_100,
track: 95,
gs: 400,
seen: 0,
seen_pos: 0,
flight: 'RCH451 ',
t: 'C17',
r: '05-8152',
ownOp: 'United States Air Force',
}] : [],
ac:
poll++ === 0
? [
{
hex: icao24,
lon: -96.9,
lat: 31.1,
alt_baro: 28_000,
alt_geom: 28_100,
track: 95,
gs: 400,
seen: 0,
seen_pos: 0,
flight: 'RCH451 ',
t: 'C17',
r: '05-8152',
ownOp: 'United States Air Force',
},
]
: [],
}),
});
@@ -227,7 +259,10 @@ test('cached military snapshots retain their observation time and expose stale f
_setTrackedMilitaryRefreshStateForTest({
icao24: 'ae01ce',
entity: { gevLabelModel: { title: 'RCH451', details: [] } },
billboard: { position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000), show: false },
billboard: {
position: Cesium.Cartesian3.fromDegrees(-97, 31, 8000),
show: false,
},
billboardCollection: { show: false, remove() {} },
viewer,
history,
@@ -235,21 +270,48 @@ test('cached military snapshots retain their observation time and expose stale f
});
let cache = 'MISS';
t.mock.method(Date, 'now', () => now);
t.mock.method(globalThis, 'fetch', async () => Response.json({
ac: [{ hex: 'ae01ce', lon: -97, lat: 31, alt_baro: 28000,
track: 95, gs: 400, seen: 1, seen_pos: 2, flight: 'RCH451' }],
}, { headers: {
'X-ADS-B-Cache': cache,
...(cache === 'MISS' ? {} : { 'X-ADS-B-Cache-Age-Ms': String(now - receivedAt) }),
} }));
t.mock.method(globalThis, 'fetch', async () =>
Response.json(
{
ac: [
{
hex: 'ae01ce',
lon: -97,
lat: 31,
alt_baro: 28000,
track: 95,
gs: 400,
seen: 1,
seen_pos: 2,
flight: 'RCH451',
},
],
},
{
headers: {
'X-Feed-Cache': cache,
...(cache === 'MISS'
? {}
: { 'X-Feed-Age-Ms': String(now - receivedAt) }),
},
},
),
);
await militaryFlightsLayer.update(viewer);
assert.equal(history.length, 1);
assert.equal(history[0].epochMs, receivedAt - 2000);
for (const [status, age] of [['HIT', 5000], ['STALE', 45000]]) {
for (const [status, age] of [
['HIT', 5000],
['STALE', 45000],
]) {
cache = status;
now = receivedAt + age;
await militaryFlightsLayer.update(viewer);
assert.equal(history.length, 1, 'replayed positions cannot create a new fix');
assert.equal(
history.length,
1,
'replayed positions cannot create a new fix',
);
assert.equal(militaryFlightsLayer.getStats().lastUpdate, receivedAt);
assert.equal(militaryFlightsLayer.getStats().stale, status === 'STALE');
assert.equal(militaryFlightsLayer.getStats().error, null);
@@ -276,14 +338,18 @@ test('real military track path creates no native label and publishes every cache
trackedEntityChanged,
clock: { currentTime: now },
camera: {
cancelFlight() { cancelledFlights += 1; },
cancelFlight() {
cancelledFlights += 1;
},
position: new Cesium.Cartesian3(0, -10_000, 7_000),
positionWC: new Cesium.Cartesian3(0, -10_000, 7_000),
direction: Cesium.Cartesian3.UNIT_Y,
transform: Cesium.Matrix4.IDENTITY,
viewMatrix: Cesium.Matrix4.IDENTITY,
frustum: { projectionMatrix: Cesium.Matrix4.IDENTITY },
lookAtTransform() { appliedFrames += 1; },
lookAtTransform() {
appliedFrames += 1;
},
},
scene: {
canvas: { clientWidth: 1600, clientHeight: 900 },
@@ -322,9 +388,12 @@ test('real military track path creates no native label and publishes every cache
const realFetch = globalThis.fetch;
globalThis.window = new EventTarget();
const selectionEvents = [];
globalThis.window.addEventListener('gev:awareness-subject-selected', (event) => {
selectionEvents.push(event.detail);
});
globalThis.window.addEventListener(
'gev:awareness-subject-selected',
(event) => {
selectionEvents.push(event.detail);
},
);
globalThis.fetch = async () => ({ ok: false });
_setTrackedOverlayHostForTest(host);
try {
@@ -336,13 +405,15 @@ test('real military track path creates no native label and publishes every cache
billboardCollection: { show: true, remove() {} },
viewer,
tracked: false,
history: [{
time: now,
epochMs: Date.now(),
position,
velocity: 231.5,
track: 92.1,
}],
history: [
{
time: now,
epochMs: Date.now(),
position,
velocity: 231.5,
track: 92.1,
},
],
meta: {
...FULL_INFO,
type: 'C17',
@@ -352,34 +423,72 @@ test('real military track path creates no native label and publishes every cache
},
});
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
true,
);
const entity = viewer.trackedEntity;
assert.ok(entity instanceof Cesium.Entity, 'trackById must create the real Cesium entity');
assert.ok(
entity instanceof Cesium.Entity,
'trackById must create the real Cesium entity',
);
assert.equal(entity.label, undefined);
assert.ok(entities.values.every((candidate) => candidate.label === undefined));
assert.ok(
entities.values.every((candidate) => candidate.label === undefined),
);
assert.deepEqual(entity.gevLabelModel, {
title: 'RCH451',
details: [
'C17 · 05-8152',
'United States Air Force · 28000 ft · 450 kt',
],
details: ['C17 · 05-8152', 'United States Air Force · 28000 ft · 450 kt'],
accent: '#ffd166',
});
viewer.scene.preUpdate.raiseEvent();
const initialAppliedFrames = appliedFrames;
const initialCancelledFlights = cancelledFlights;
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
assert.equal(cancelledFlights, initialCancelledFlights, 'ordinary repeated tracking stays camera-idempotent');
assert.equal(selectionEvents.at(-1)?.origin, 'user', 'same-target selection upgrades durable authority');
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
true,
);
assert.equal(
cancelledFlights,
initialCancelledFlights,
'ordinary repeated tracking stays camera-idempotent',
);
assert.equal(
selectionEvents.at(-1)?.origin,
'user',
'same-target selection upgrades durable authority',
);
assert.equal(entity.gevSelectionOrigin, 'user');
assert.equal(militaryFlightsLayer.refocusTrackedById('different-flight'), false);
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }), true);
assert.equal(selectionEvents.at(-1)?.origin, 'voice', 'same-target refocus forwards explicit authority');
assert.equal(
militaryFlightsLayer.refocusTrackedById('different-flight'),
false,
);
assert.equal(
militaryFlightsLayer.refocusTrackedById(icao24, { origin: 'voice' }),
true,
);
assert.equal(
selectionEvents.at(-1)?.origin,
'voice',
'same-target refocus forwards explicit authority',
);
assert.equal(militaryFlightsLayer.refocusTrackedById(icao24), true);
viewer.scene.preUpdate.raiseEvent();
assert.equal(viewer.trackedEntity, entity, 'refocus must retain the exact tracked entity');
assert.equal(appliedFrames, initialAppliedFrames + 1, 'repeated refocus keeps one camera-frame owner');
assert.equal(cancelledFlights, initialCancelledFlights + 2, 'only explicit refocus requests cancel camera flights');
assert.equal(
viewer.trackedEntity,
entity,
'refocus must retain the exact tracked entity',
);
assert.equal(
appliedFrames,
initialAppliedFrames + 1,
'repeated refocus keeps one camera-frame owner',
);
assert.equal(
cancelledFlights,
initialCancelledFlights + 2,
'only explicit refocus requests cancel camera flights',
);
const publication = publications.at(-1);
assert.equal(publication.sourceId, 'tracked');
const entry = publication.entries[0];
@@ -390,23 +499,40 @@ test('real military track path creates no native label and publishes every cache
const display = entity.position.getValue(now);
assert.ok(display, 'tracked position callback must seed its frame cache');
assert.equal(entry.position(), display, 'host must reuse the exact cached display Cartesian');
assert.equal(
entry.position(),
display,
'host must reuse the exact cached display Cartesian',
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late001' },
{ origin: 'share-restore' },
);
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late001');
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'user' }), true);
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'new user selection cancels stale restore');
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'user' }),
true,
);
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'new user selection cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late001',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.9, 31.1, 8_000),
},
meta: { ...FULL_INFO, callsign: 'LATE1', rawLat: 31.1, rawLon: -96.9 },
history: [],
});
_applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, icao24);
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
icao24,
);
militaryFlightsLayer.stopTracking();
militaryFlightsLayer.setParams(
@@ -415,15 +541,25 @@ test('real military track path creates no native label and publishes every cache
);
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late002');
militaryFlightsLayer.stopTracking({ origin: 'user' });
assert.equal(_pendingMilitaryTrackingRestoreForTest(), null, 'explicit clear cancels stale restore');
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
null,
'explicit clear cancels stale restore',
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late002',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.8, 31.2, 7_000),
},
meta: { ...FULL_INFO, callsign: 'LATE2', rawLat: 31.2, rawLon: -96.8 },
history: [],
});
_applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late003' },
@@ -438,12 +574,18 @@ test('real military track path creates no native label and publishes every cache
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late003',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-97.3, 30.6, 7_000),
},
meta: { ...FULL_INFO, callsign: 'LATE3', rawLat: 30.6, rawLon: -97.3 },
history: [],
});
_applyPendingMilitaryTrackingRestoreForTest();
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, null);
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
null,
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late004' },
@@ -452,18 +594,27 @@ test('real military track path creates no native label and publishes every cache
assert.equal(_pendingMilitaryTrackingRestoreForTest(), 'late004');
_addMilitaryTrackingCandidateForTest({
icao24: 'late004',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-97.2, 30.7, 6_000),
},
meta: { ...FULL_INFO, callsign: 'LATE4', rawLat: 30.7, rawLon: -97.2 },
history: [],
});
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late004');
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late004',
);
militaryFlightsLayer.setParams(
{ selectedMilitaryTrackingId: 'late005' },
{ origin: 'local-restore' },
);
assert.equal(militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }), true);
assert.equal(
militaryFlightsLayer.trackById(icao24, { origin: 'programmatic' }),
true,
);
assert.equal(
_pendingMilitaryTrackingRestoreForTest(),
'late005',
@@ -471,12 +622,18 @@ test('real military track path creates no native label and publishes every cache
);
_addMilitaryTrackingCandidateForTest({
icao24: 'late005',
billboard: { ...billboard, position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000) },
billboard: {
...billboard,
position: Cesium.Cartesian3.fromDegrees(-96.6, 31.4, 5_000),
},
meta: { ...FULL_INFO, callsign: 'LATE5', rawLat: 31.4, rawLon: -96.6 },
history: [],
});
assert.equal(_applyPendingMilitaryTrackingRestoreForTest(), true);
assert.equal(militaryFlightsLayer.getParams().selectedMilitaryTrackingId, 'late005');
assert.equal(
militaryFlightsLayer.getParams().selectedMilitaryTrackingId,
'late005',
);
} finally {
militaryFlightsLayer.stopTracking();
viewer.scene.preUpdate.raiseEvent();
@@ -521,21 +678,47 @@ test('a callsign-less contact is findable by the tail number the app displays',
// The identity analyst_query hands back for this contact.
const analystId = mapAnalystRecord(icao24, {
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
callsign: null,
registration: '6606',
rawLat: 40.71,
rawLon: -74.01,
}).id;
assert.equal(analystId, '6606', 'the analyst names a callsign-less contact by its tail');
assert.equal(
analystId,
'6606',
'the analyst names a callsign-less contact by its tail',
);
const found = militaryFlightsLayer.findByQuery(analystId);
assert.equal(found?.icao24, icao24, 'and the tracker must resolve that same identity');
assert.equal(militaryFlightsLayer.findByQuery('660')?.icao24, icao24, 'prefix match too');
assert.equal(militaryFlightsLayer.findByQuery(icao24)?.icao24, icao24, 'hex still wins outright');
assert.equal(
found?.icao24,
icao24,
'and the tracker must resolve that same identity',
);
assert.equal(
militaryFlightsLayer.findByQuery('660')?.icao24,
icao24,
'prefix match too',
);
assert.equal(
militaryFlightsLayer.findByQuery(icao24)?.icao24,
icao24,
'hex still wins outright',
);
});
test('the analyst record carries the hex key the tracker keys on', () => {
const record = mapAnalystRecord('ae7f01', {
callsign: null, registration: '6606', rawLat: 40.71, rawLon: -74.01,
callsign: null,
registration: '6606',
rawLat: 40.71,
rawLon: -74.01,
});
assert.equal(record.icao24, 'ae7f01', 'a resolvable key must ride along with the display label');
assert.equal(
record.icao24,
'ae7f01',
'a resolvable key must ride along with the display label',
);
});
test('a registration never outranks another contact’s exact callsign', () => {
@@ -548,18 +731,35 @@ test('a registration never outranks another contact’s exact callsign', () => {
show: true,
});
const base = {
type: 'C17', operator: 'USAF', altitudeFt: 25000, speedMps: 180,
track: 90, klass: 'widebody', onGround: false,
type: 'C17',
operator: 'USAF',
altitudeFt: 25000,
speedMps: 180,
track: 90,
klass: 'widebody',
onGround: false,
};
// Registered FIRST, so insertion order favours it.
_addMilitaryTrackingCandidateForTest({
icao24: 'ae0aa1',
meta: { ...base, callsign: null, registration: 'ZULU777', rawLat: 31.0, rawLon: -97.0 },
meta: {
...base,
callsign: null,
registration: 'ZULU777',
rawLat: 31.0,
rawLon: -97.0,
},
billboard: billboardAt(-97.0, 31.0),
});
_addMilitaryTrackingCandidateForTest({
icao24: 'ae0bb2',
meta: { ...base, callsign: 'ZULU777', registration: '05-9999', rawLat: 31.2, rawLon: -97.2 },
meta: {
...base,
callsign: 'ZULU777',
registration: '05-9999',
rawLat: 31.2,
rawLon: -97.2,
},
billboard: billboardAt(-97.2, 31.2),
});
@@ -582,24 +782,49 @@ test('two contacts matching at the same strength resolve deterministically', ()
show: true,
});
const base = {
type: 'C130', operator: 'USAF', altitudeFt: 21000, speedMps: 150,
track: 45, klass: 'turboprop', onGround: false,
type: 'C130',
operator: 'USAF',
altitudeFt: 21000,
speedMps: 150,
track: 45,
klass: 'turboprop',
onGround: false,
};
// Both registrations begin with QQ7 — a same-tier prefix tie. Registered
// highest-hex first so insertion order would pick the wrong one.
_addMilitaryTrackingCandidateForTest({
icao24: 'aef002',
meta: { ...base, callsign: null, registration: 'QQ7-222', rawLat: 32.0, rawLon: -98.0 },
meta: {
...base,
callsign: null,
registration: 'QQ7-222',
rawLat: 32.0,
rawLon: -98.0,
},
billboard: billboardAt(-98.0, 32.0),
});
_addMilitaryTrackingCandidateForTest({
icao24: 'aef001',
meta: { ...base, callsign: null, registration: 'QQ7-111', rawLat: 32.1, rawLon: -98.1 },
meta: {
...base,
callsign: null,
registration: 'QQ7-111',
rawLat: 32.1,
rawLon: -98.1,
},
billboard: billboardAt(-98.1, 32.1),
});
const first = militaryFlightsLayer.findByQuery('QQ7')?.icao24;
const second = militaryFlightsLayer.findByQuery('qq7')?.icao24;
assert.equal(first, 'aef001', 'the stable key (lowest hex) wins, not the feed order');
assert.equal(second, first, 'and the same query resolves the same way every time');
assert.equal(
first,
'aef001',
'the stable key (lowest hex) wins, not the feed order',
);
assert.equal(
second,
first,
'and the same query resolves the same way every time',
);
});
+11 -7
View File
@@ -14,10 +14,6 @@ export function createMilitaryNamesLoader({
loadPack = (signal) =>
loadBundledJson(
new URL('./local_data/osm_military_names/names.json', import.meta.url),
() =>
import('./local_data/osm_military_names/names.json', {
with: { type: 'json' },
}),
{ signal },
),
timeoutMs = MILITARY_NAMES_TIMEOUT_MS,
@@ -93,8 +89,16 @@ export function nameMilitaryFragment(fragment, names) {
};
}
/** Area-ranked spatial thinning of visible label points, including dateline views. */
export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
/**
* Area-ranked spatial thinning of visible label points, including dateline
* views. With `thinned` false every point in the box is kept, up to `cap`.
*/
export function militaryNamesInView(
names,
box,
cap = MILITARY_POINT_CAP,
thinned = true,
) {
const width =
box.east >= box.west ? box.east - box.west : 360 + box.east - box.west;
const height = box.north - box.south;
@@ -107,7 +111,7 @@ export function militaryNamesInView(names, box, cap = MILITARY_POINT_CAP) {
continue;
count++;
const cell = `${Math.min(31, Math.floor((x / width) * 32))}:${Math.min(15, Math.floor(((record.latitude - box.south) / height) * 16))}`;
if (cells.has(cell) || records.length >= cap) continue;
if ((thinned && cells.has(cell)) || records.length >= cap) continue;
cells.add(cell);
records.push(record);
}
+10
View File
@@ -217,3 +217,13 @@ test('a close view keeps the parent name inside its polygon when the usual label
hole,
);
});
test('queries can take every named site in a box instead of one per display cell', async () => {
const names = await loadMilitaryNames();
const box = { west: -2, south: 50, east: 2, north: 54 };
const thinned = militaryNamesInView(names, box);
const all = militaryNamesInView(names, box, Infinity, false);
assert.equal(all.count, thinned.count);
assert.equal(all.records.length, all.count);
assert.ok(all.records.length > thinned.records.length);
});
+2 -2
View File
@@ -1,7 +1,7 @@
import { createMilitaryRegistry } from '../layers/aircraft/classification.js';
import { createAdsbLolSource } from '../sources/live/standalone.js';
import { createMilitarySource } from '../sources/live/standalone.js';
const registry = createMilitaryRegistry({ source: createAdsbLolSource() });
const registry = createMilitaryRegistry({ source: createMilitarySource() });
export const isMilitaryLayerActive = registry.isMilitaryLayerActive;
export const setMilitaryLayerActive = registry.setMilitaryLayerActive;
export const onMilitaryLayerActiveChange = registry.onMilitaryLayerActiveChange;
+2 -10
View File
@@ -124,17 +124,9 @@ let _entries = null;
const PACKS = {
regions: {
url: new URL('./local_data/natural_earth/regions.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/regions.json', {
with: { type: 'json' },
}),
},
marine: {
url: new URL('./local_data/natural_earth/marine.json', import.meta.url),
importJson: () =>
import('./local_data/natural_earth/marine.json', {
with: { type: 'json' },
}),
},
};
@@ -178,8 +170,8 @@ function buildEntries(pack, kind) {
*/
const loadIndex = createRetryableLoader(async () => {
const [regions, marine] = await Promise.all([
loadBundledJson(PACKS.regions.url, PACKS.regions.importJson),
loadBundledJson(PACKS.marine.url, PACKS.marine.importJson),
loadBundledJson(PACKS.regions.url),
loadBundledJson(PACKS.marine.url),
]);
_entries = [
...buildEntries(regions, 'natural'),
+1 -5
View File
@@ -22,10 +22,6 @@ const CITY_FILES = [
'./local_data/neighborhoods/san-francisco.json',
import.meta.url,
),
importJson: () =>
import('./local_data/neighborhoods/san-francisco.json', {
with: { type: 'json' },
}),
},
];
@@ -112,7 +108,7 @@ function cityLoader(city) {
let loader = _cityLoaders.get(city.id);
if (!loader) {
loader = createRetryableLoader(async () => {
const fc = await loadBundledJson(city.url, city.importJson);
const fc = await loadBundledJson(city.url);
return Array.isArray(fc.features) ? fc.features : [];
});
_cityLoaders.set(city.id, loader);
+2 -2
View File
@@ -189,7 +189,7 @@ test('a conversion survives a poll refresh, in both the billboard and the tracke
const realFetch = globalThis.fetch;
const nowSec = Math.floor(Date.now() / 1000);
globalThis.fetch = async (url) => {
if (!String(url).startsWith('/api/opensky')) {
if (!String(url).startsWith('/api/flights')) {
return { ok: true, status: 200, json: async () => ({ ac: [] }) };
}
return {
@@ -309,7 +309,7 @@ test('conversions are session-scoped and no lifecycle path clears them', async (
meta: { callsign: 'OLD1', altitude: 9_000, klass: 'airliner', rawLat: 30.2, rawLon: -97.7 },
});
const realFetch = globalThis.fetch;
globalThis.fetch = async (url) => (String(url).startsWith('/api/opensky')
globalThis.fetch = async (url) => (String(url).startsWith('/api/flights')
? { ok: true, status: 200, headers: { get: () => null }, json: async () => ({ time: 0, states: [] }) }
: { ok: true, status: 200, json: async () => ({ ac: [] }) });
try {
+3
View File
@@ -211,6 +211,9 @@ export function shouldShowFirstRun({
if (hasShareState) return false;
const params = new URLSearchParams(location?.search || '');
if (params.get('welcome') === '0') return false;
// An embedded globe never shows the welcome launcher.
if (params.get('embed') === '1' || globalThis.GEV_EMBED_INLINE === true)
return false;
// The demo/support escape hatch outranks both suppressions on purpose.
if (params.get('welcome') === '1') return true;
if (readStored('local', storage, FIRST_RUN_STORAGE_KEY) === 'suppressed')
+5 -1
View File
@@ -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.
}
+24 -22
View File
@@ -1,7 +1,8 @@
/**
* Key setup ("POWER UP") — the pure core.
*
* One registry, three pure functions, zero dependencies. The dev server's
* One registry and pure functions; its only import is the proxy-header check
* shared with the other local gates (localRequestGate.mjs). The dev server's
* /api/setup endpoints (vite.config.js) and the in-app panel (keySetup.js)
* are both thin shells over this module, so what a key is called, what it
* unlocks, and how a .env line is written each live in exactly one place.
@@ -11,6 +12,8 @@
* behavior below unit-testable.
*/
import { hasProxySignals } from './localRequestGate.mjs';
/** Longest accepted key/token value. Real provider keys are all far shorter. */
export const KEY_SETUP_VALUE_LIMIT = 512;
@@ -184,6 +187,24 @@ export function parseWindowsUserSid(stdout) {
: null;
}
/**
* Whether the launcher has sharing on. Every sharing signal the launcher
* recognizes (scripts/pinokio-preflight.mjs) counts, so the two sets cannot
* drift apart. One DELIBERATE divergence: preflight is a boot check that
* treats an empty PINOKIO_SHARE_VAR as sharing-on (fail closed before Start),
* but here an empty/unset value is the NORMAL git-clone and Pinokio state, so
* a bare/sentinel value is not sharing; only a real tunnel var is.
*/
export function isSharingEnabled(env = {}) {
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
return (
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
/^(1|true)$/i.test(String(env[name] || '').trim()),
) ||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__')
);
}
/**
* The admission gate for the Provider Settings endpoints — pure, exported so
* every refusal below is pinned by a unit assertion rather than a review note.
@@ -219,20 +240,7 @@ export function admitKeySetupRequest({
// on this machine, whatever its socket says. Refuse them outright as defense
// in depth — the shipped tunnel (Pinokio) is force-closed at boot, so these
// only appear when someone has deliberately fronted the dev server.
const PROXY_SIGNALS = [
'forwarded',
'via',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-port',
'x-forwarded-proto',
'x-real-ip',
'cf-connecting-ip',
'cf-ray',
];
if (
PROXY_SIGNALS.some((name) => String(proxyHeaders[name] || '').trim() !== '')
) {
if (hasProxySignals(proxyHeaders)) {
return {
ok: false,
status: 403,
@@ -249,13 +257,7 @@ export function admitKeySetupRequest({
// tunnel var is. This is defense in depth regardless: the loopback+Host checks
// below independently refuse LAN/tunnel traffic, and under Pinokio the launcher
// refuses to boot at all when sharing is genuinely on.
const shareVar = String(env.PINOKIO_SHARE_VAR ?? '').trim();
const sharingEnabled =
['PINOKIO_SHARE_CLOUDFLARE', 'PINOKIO_SHARE_LOCAL'].some((name) =>
/^(1|true)$/i.test(String(env[name] || '').trim()),
) ||
(shareVar !== '' && shareVar !== '__gev_sharing_disabled__');
if (sharingEnabled) {
if (isSharingEnabled(env)) {
return {
ok: false,
status: 403,
+1
View File
@@ -9,6 +9,7 @@ export * from '../../data/cockpitContactDot.js';
export * from '../../data/contactMatch.js';
export * from '../../data/iconOrientation.js';
export * from '../../data/layerState.js';
export * from '../../data/layerStateCoordinator.js';
export * from '../../data/modelVisualAnchor.js';
export * from '../../data/motionModel.js';
export * from '../../data/renderAltitude.js';
+1 -1
View File
@@ -35,7 +35,7 @@ export function createQueries({ state: layerState, services, parts, source }) {
// setInstallationStatus is only ever called with
// loading/zoom-in/ready/stale/empty/unavailable.
// - ais-live-vessels is what the predicate below is FOR. Its enable() and
// update() both resolve as soon as the first /api/ais-live poll answers,
// update() both resolve as soon as the first /api/vessels poll answers,
// so the lifecycle settles to `enabled` — but until the server-side socket
// delivers a position, firstConnectPhase is 'loading' and getStats()
// reports loading: true, lastUpdate: null, count 0, and an UNDEFINED
+8 -119
View File
@@ -1,36 +1,12 @@
import {
extractStationsArray,
normalizeGbfsBool,
parseStationInformation,
parseStationStatus,
toNonNegativeInteger,
} from '../../sources/gbfsStations.js';
export function createModel({ state: layerState, services, parts, source }) {
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Build a globally unique render key for a station.
* @param {string} cityId - City identifier.
@@ -63,93 +39,6 @@ export function createModel({ state: layerState, services, parts, source }) {
return 6371 * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon))
continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
return {
normalizeGbfsBool,
toNonNegativeInteger,
+13
View File
@@ -51,5 +51,18 @@ export function createCctvSource({
},
getFrameUrl: frameUrlFor,
getMediaUrl: mediaUrlFor,
/** Read one current frame through the registered frame endpoint. */
async getFrame(camera, { signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(frameUrlFor(camera), {
cache: 'no-store',
signal,
});
if (!response.ok) throw new Error('Camera frame HTTP ' + response.status);
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
},
};
}
+25
View File
@@ -148,3 +148,28 @@ test('camera construction is inert and destruction cancels a pending catalog and
assert.equal(a.getStats().count, 0);
assert.equal(b.getStats().count, 0);
});
test('frames are read through the registered frame endpoint', async () => {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const requested = [];
const source = createCctvSource({
fetchImpl: async (url, init) => {
requested.push({ url, cache: init.cache });
return new Response(png, {
headers: { 'Content-Type': 'Image/PNG; charset=binary' },
});
},
});
const frame = await source.getFrame(camera);
assert.equal(frame.contentType, 'image/png');
assert.deepEqual([...frame.bytes], [...png]);
assert.equal(
requested[0].url.split('?')[0],
'/api/cctv/frame/pack%2Fcamera%20%3Fx',
);
assert.equal(requested[0].cache, 'no-store');
const failing = createCctvSource({
fetchImpl: async () => new Response(null, { status: 502 }),
});
await assert.rejects(failing.getFrame(camera), /Camera frame HTTP 502/);
});
+110 -52
View File
@@ -4,22 +4,47 @@ import { readFileSync } from 'node:fs';
import { createInstallationSource } from './source.js';
import { createIngestion } from './ingestion.js';
import { decodeOpenFreeMapMilitaryTile } from '../../sources/openFreeMap.js';
import { createMilitaryNamesLoader, loadMilitaryNames } from '../../data/militaryNames.js';
import {
createMilitaryNamesLoader,
loadMilitaryNames,
} from '../../data/militaryNames.js';
import { loadBundledJson } from '../../data/bundledJson.js';
const box = { south: 30.3, west: -97.8, north: 30.33, east: -97.74 };
const wide = { south: 20, west: -130, north: 60, east: -60 };
const tile = decodeOpenFreeMapMilitaryTile(readFileSync(new URL('../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf', import.meta.url)), 12, 935, 1685);
const flush = async () => { for (let i = 0; i < 30; i++) await Promise.resolve(); };
function deferred() { let resolve; const promise = new Promise((r) => { resolve = r; }); return { promise, resolve }; }
function sourceWith(job) { return createInstallationSource({
loadNames: () => job.promise,
fetchImpl: async () => Response.json({ unavailable: true }),
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
}); }
const tile = decodeOpenFreeMapMilitaryTile(
readFileSync(
new URL(
'../../data/fixtures/ofm-camp-mabry-12-935-1685.pbf',
import.meta.url,
),
),
12,
935,
1685,
);
const flush = async () => {
for (let i = 0; i < 30; i++) await Promise.resolve();
};
function deferred() {
let resolve;
const promise = new Promise((r) => {
resolve = r;
});
return { promise, resolve };
}
function sourceWith(job) {
return createInstallationSource({
loadNames: () => job.promise,
fetchImpl: async () => Response.json({ unavailable: true }),
mapTiles: { fetchBounds: async () => ({ tiles: [tile] }), clear() {} },
});
}
test('close polygons publish immediately while a wide consumer can cancel its independent names wait', async () => {
const job = deferred(), source = sourceWith(job), abort = new AbortController();
const job = deferred(),
source = sourceWith(job),
abort = new AbortController();
const waiting = source.getMappedSites(wide, { signal: abort.signal });
const rejection = assert.rejects(waiting, { name: 'AbortError' });
const payload = await source.getMappedSites(box);
@@ -32,48 +57,81 @@ test('close polygons publish immediately while a wide consumer can cancel its in
assert.ok(enriched.records.some((r) => r.name === 'Camp Mabry'));
});
for (const stale of [false, true]) test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
const job = deferred(), source = sourceWith(job), publications = [];
let currentBox = box;
const state = { enabled: true, viewer: {}, records: [], googleSearchRequested: false };
const ingestion = createIngestion({ state, source,
services: { render: { governorRequestRender() {} }, ground: { resolveGroundFloorCellsBounded: async () => {} } },
parts: {
viewport: { loadArea: () => ({ box: currentBox, coverage: {} }), clearUnavailableRetry() {}, scheduleUnavailableRetry() {} },
model: { installationWithinViewport: () => true },
rendering: { renderRecords: () => publications.push(state.records), warmInstallationFloors() {} },
},
for (const stale of [false, true])
test(`late names ${stale ? 'cannot publish to a superseded view' : 'enrich the current published polygons'}`, async () => {
const job = deferred(),
source = sourceWith(job),
publications = [];
let currentBox = box;
const state = {
enabled: true,
viewer: {},
records: [],
googleSearchRequested: false,
};
const ingestion = createIngestion({
state,
source,
services: {
render: { governorRequestRender() {} },
ground: { resolveGroundFloorCellsBounded: async () => {} },
},
parts: {
viewport: {
loadArea: () => ({ box: currentBox, coverage: {} }),
clearUnavailableRetry() {},
scheduleUnavailableRetry() {},
},
model: { installationWithinViewport: () => true },
rendering: {
renderRecords: () => publications.push(state.records),
warmInstallationFloors() {},
},
},
});
await ingestion.loadInstallations();
assert.equal(state.loading, false);
assert.ok(publications[0].length);
if (stale) {
currentBox = wide;
state.enabled = false;
state.abort.abort();
}
job.resolve(await loadMilitaryNames());
await flush();
assert.equal(publications.length, stale ? 1 : 2);
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
state.abort.abort();
});
await ingestion.loadInstallations();
assert.equal(state.loading, false);
assert.ok(publications[0].length);
if (stale) { currentBox = wide; state.enabled = false; state.abort.abort(); }
job.resolve(await loadMilitaryNames());
await flush();
assert.equal(publications.length, stale ? 1 : 2);
if (!stale) assert.ok(state.records.some((r) => r.name === 'Camp Mabry'));
state.abort.abort();
});
for (const stage of ['response', 'body']) test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
let calls = 0, signal;
t.mock.method(globalThis, 'fetch', async (_url, options) => {
calls++; signal = options.signal;
if (calls > 1) return Response.json({ records: [], classes: [] });
if (stage === 'response') return new Promise(() => {});
return { ok: true, json: () => new Promise(() => {}) };
for (const stage of ['response', 'body'])
test(`shared names ${stage} deadline aborts and releases the retryable loader`, async (t) => {
t.mock.timers.enable({ apis: ['setTimeout', 'Date'] });
let calls = 0,
signal;
t.mock.method(globalThis, 'fetch', async (_url, options) => {
calls++;
signal = options.signal;
if (calls > 1) return Response.json({ records: [], classes: [] });
if (stage === 'response') return new Promise(() => {});
return { ok: true, json: () => new Promise(() => {}) };
});
const load = createMilitaryNamesLoader({
timeoutMs: 50,
cooldownMs: 100,
loadPack: (signal) =>
loadBundledJson(new URL('https://example.test/names.json'), { signal }),
});
const first = load();
assert.equal(load(), first, 'shared acquisition');
const rejected = assert.rejects(first, { name: 'TimeoutError' });
await flush();
t.mock.timers.tick(51);
await rejected;
assert.equal(signal.aborted, true);
await assert.rejects(load(), { name: 'TimeoutError' });
assert.equal(calls, 1);
t.mock.timers.tick(101);
assert.deepEqual((await load()).records, []);
assert.equal(calls, 2);
});
const load = createMilitaryNamesLoader({ timeoutMs: 50, cooldownMs: 100,
loadPack: (signal) => loadBundledJson(new URL('https://example.test/names.json'), null, { signal }) });
const first = load();
assert.equal(load(), first, 'shared acquisition');
const rejected = assert.rejects(first, { name: 'TimeoutError' });
await flush(); t.mock.timers.tick(51); await rejected;
assert.equal(signal.aborted, true);
await assert.rejects(load(), { name: 'TimeoutError' });
assert.equal(calls, 1);
t.mock.timers.tick(101);
assert.deepEqual((await load()).records, []);
assert.equal(calls, 2);
});
+14 -8
View File
@@ -101,7 +101,7 @@ export function createInstallationSource({
} = {}) {
let overpassUnavailable = false;
const installationIds = new Map();
async function getNamedSites(box, signal) {
async function getNamedSites(box, signal, thinned = true) {
const names = await waitForSignal(loadNames(), signal).catch((cause) => {
signal?.throwIfAborted();
throw Object.assign(
@@ -112,7 +112,13 @@ export function createInstallationSource({
);
});
signal?.throwIfAborted();
const { records, count } = militaryNamesInView(names, box);
// The map keeps one site per display cell; queries can ask for all.
const { records, count } = militaryNamesInView(
names,
box,
thinned ? undefined : Infinity,
thinned,
);
return {
records,
namedInView: count,
@@ -124,9 +130,9 @@ export function createInstallationSource({
source: 'OpenStreetMap',
};
}
async function getTileSites(box, signal) {
async function getTileSites(box, signal, thinned) {
const zoom = installationTileZoom(box);
if (zoom === null) return getNamedSites(box, signal);
if (zoom === null) return getNamedSites(box, signal, thinned);
let names = null;
const namesJob = waitForSignal(loadNames(), signal)
.then((loaded) => {
@@ -181,7 +187,7 @@ export function createInstallationSource({
mapTiles.clear();
installationIds.clear();
},
async getMappedSites(box, { exact = false, signal } = {}) {
async getMappedSites(box, { exact = false, thinned = true, signal } = {}) {
const { south, west, north, east } = box || {};
if (
![south, west, north, east].every(Number.isFinite) ||
@@ -197,8 +203,8 @@ export function createInstallationSource({
throw new TypeError('A bounded installation viewport is required');
signal?.throwIfAborted();
if (east < west || north - south > 10 || east - west > 10)
return getNamedSites(box, signal);
if (overpassUnavailable) return getTileSites(box, signal);
return getNamedSites(box, signal, thinned);
if (overpassUnavailable) return getTileSites(box, signal, thinned);
const query = new URLSearchParams(
Object.entries({ south, west, north, east }).map(([key, value]) => [
key,
@@ -213,7 +219,7 @@ export function createInstallationSource({
signal?.throwIfAborted();
if (isUnavailableCapability(body)) {
overpassUnavailable = true;
return getTileSites(box, signal);
return getTileSites(box, signal, thinned);
}
if (!response.ok)
throw Object.assign(
+17 -9
View File
@@ -2,16 +2,24 @@
export function createLaunchSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
/** The launch payload, and whether the proxy served it from a stale cache. */
async function getLaunchSnapshot({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/launches', { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
return {
payload,
stale: response.headers?.get?.('x-gev-cache') === 'STALE-ERROR',
};
}
return {
async getLaunches({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/launches', { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const payload = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(payload) && !Array.isArray(payload?.results))
throw new Error('Malformed launch snapshot');
return payload;
getLaunchSnapshot,
async getLaunches(options) {
return (await getLaunchSnapshot(options)).payload;
},
async getActiveTle({ signal } = {}) {
signal?.throwIfAborted();
+2 -18
View File
@@ -8,6 +8,7 @@ import {
twoline2satrec,
} from 'satellite.js';
import { findNextSatellitePass } from '../../data/satellitePass.js';
import { parseTleText } from '../../sources/tle.js';
import { ORBIT_PATH_STEPS, ISS_NORAD } from './policy.js';
export function createOrbits({ state: layerState, services, parts, source }) {
@@ -40,24 +41,7 @@ export function createOrbits({ state: layerState, services, parts, source }) {
/**
* Parse TLE text into array of { name, line1, line2 } objects.
*/
function parseTLE(text) {
const lines = text
.trim()
.split('\n')
.map((l) => l.trim())
.filter((l) => l.length > 0);
const result = [];
for (let i = 0; i < lines.length - 2; i += 3) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
result.push({ name, line1, line2 });
}
}
return result;
}
const parseTLE = parseTleText;
/**
* Propagate satellite position at a given JS Date.
+4 -1
View File
@@ -19,7 +19,10 @@ export function createSatelliteSource({
const response = await fetchImpl(`/api/celestrak/${group}`, { signal });
const text = response.ok ? await response.text() : '';
signal?.throwIfAborted();
return { ok: response.ok, status: response.status, text };
// The proxy serves its last copy, marked STALE-ERROR, when CelesTrak
// is down.
const stale = response.headers?.get?.('x-tle-cache') === 'STALE-ERROR';
return { ok: response.ok, status: response.status, text, stale };
},
};
}
+15 -10
View File
@@ -1,14 +1,19 @@
// This dataset is CC BY-NC-SA 3.0, not the project's MIT license.
// Commercial users must remove it or obtain a TeleGeography license.
// See DATA_SOURCES.md and the bundled dataset's source.json.
const cableUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url,
).href;
const landingPointUrl = new URL(
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
import.meta.url,
).href;
// Resolved when read, so importing this module never needs a module URL.
function cableUrl() {
return new URL(
'../../data/local_data/telegeography_submarine_cables/cable-geo.json',
import.meta.url,
).href;
}
function landingPointUrl() {
return new URL(
'../../data/local_data/telegeography_submarine_cables/landing-point-geo.json',
import.meta.url,
).href;
}
/** Supply GeoJSON collections without coupling the renderer to asset URLs. */
export function createBundledCableSource({
@@ -33,8 +38,8 @@ export function createBundledCableSource({
label: 'TeleGeography',
async fetch(signal) {
const [cables, landingPoints] = await Promise.all([
read(cableUrl, signal),
read(landingPointUrl, signal),
read(cableUrl(), signal),
read(landingPointUrl(), signal),
]);
return { cables, landingPoints };
},
+24 -14
View File
@@ -25,21 +25,31 @@ export function createFlowTileSource({
maxTiles: 16,
});
let partial = false;
/**
* Flow segments for bounds, with whether any covering tile failed to load
* for this request.
*/
async function fetchFlowDetail(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
zoom,
signal,
tiles: bounds.coverage?.coarse,
});
partial = result.partial;
const segments = bounds.coverage
? result.tiles.flat()
: result.tiles.flat().flatMap((segment) =>
clipTileLine(segment.coords, bounds).map((coords) => ({
...segment,
coords,
})),
);
return { segments, partial: result.partial === true };
}
return {
async fetchFlowForBounds(bounds, { signal, zoom = 12 } = {}) {
const result = await tiles.fetchBounds(bounds, {
zoom,
signal,
tiles: bounds.coverage?.coarse,
});
partial = result.partial;
if (bounds.coverage) return result.tiles.flat();
return result.tiles.flat().flatMap((segment) =>
clipTileLine(segment.coords, bounds).map((coords) => ({
...segment,
coords,
})),
);
fetchFlowDetail,
async fetchFlowForBounds(bounds, options) {
return (await fetchFlowDetail(bounds, options)).segments;
},
getFlowSessionStats: () => ({ ...tiles.getStats(), partial }),
resetFlowTileCache: () => tiles.clear(),
+15
View File
@@ -5,6 +5,21 @@ export function createTransitSource({
fetchImpl = (...args) => fetch(...args),
} = {}) {
return {
/** Read the public catalog of enabled feeds. */
async getFeeds({ signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl('/api/transit/feeds', {
signal,
headers: { Accept: 'application/json' },
});
if (!response.ok)
throw new Error('Transit feeds HTTP ' + response.status);
const body = await response.json();
signal?.throwIfAborted();
if (!Array.isArray(body?.feeds))
throw new Error('Malformed transit feed catalog');
return body.feeds;
},
getHistory(feedId, vehicleId, { signal } = {}) {
signal?.throwIfAborted();
return fetchTransitHistory(feedId, vehicleId, signal, fetchImpl);
+46
View File
@@ -0,0 +1,46 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { createTransitSource } from './source.js';
const json = (body, status = 200) =>
new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
test('the feed catalog is read from the public catalog route', async () => {
const requested = [];
const feeds = [
{
id: 'mbta',
name: 'MBTA',
center: { lat: 42.36, lon: -71.06 },
loadRadiusKm: 60,
},
];
const source = createTransitSource({
fetchImpl: async (url) => {
requested.push(url);
return json({ feeds });
},
});
assert.deepEqual(await source.getFeeds(), feeds);
assert.deepEqual(requested, ['/api/transit/feeds']);
});
test('feed catalog failures are reported', async () => {
const failing = createTransitSource({ fetchImpl: async () => json({}, 503) });
await assert.rejects(failing.getFeeds(), /Transit feeds HTTP 503/);
const malformed = createTransitSource({
fetchImpl: async () => json({ feeds: {} }),
});
await assert.rejects(malformed.getFeeds(), /Malformed transit feed catalog/);
const aborted = new AbortController();
aborted.abort();
await assert.rejects(
createTransitSource({
fetchImpl: async () => json({ feeds: [] }),
}).getFeeds({ signal: aborted.signal }),
(error) => error.name === 'AbortError',
);
});
+33
View File
@@ -0,0 +1,33 @@
import * as Cesium from 'cesium';
import { viewAreaRadiusKm } from './viewArea.js';
/**
* The area a viewer is looking at, as `{ lat, lon, radiusKm }`: centered on
* the point in the middle of the screen (or below the camera when the middle
* shows sky), sized by the camera's height. Null for a view that wants every
* vessel.
*/
export function vesselViewArea(viewer) {
const camera = viewer?.camera;
const scene = viewer?.scene;
const height = camera?.positionCartographic?.height;
const radiusKm = viewAreaRadiusKm(height);
if (radiusKm == null) return null;
let center = camera.positionCartographic;
const canvas = scene?.canvas;
if (canvas?.clientWidth && canvas?.clientHeight) {
try {
const hit = camera.pickEllipsoid(
new Cesium.Cartesian2(canvas.clientWidth / 2, canvas.clientHeight / 2),
scene.globe?.ellipsoid,
);
if (hit) center = Cesium.Cartographic.fromCartesian(hit);
} catch {
/* the camera position stands in */
}
}
const lat = Cesium.Math.toDegrees(center.latitude);
const lon = Cesium.Math.toDegrees(center.longitude);
if (!Number.isFinite(lat) || !Number.isFinite(lon)) return null;
return { lat, lon, radiusKm };
}
+2
View File
@@ -1,6 +1,7 @@
import { createVesselState } from './state.js';
import { createLifecycle } from './lifecycle.js';
import { createIngestion } from './ingestion.js';
import { vesselViewArea } from './cameraArea.js';
import { createVesselSnapshotRenderer } from './snapshotRenderer.js';
import { createRendering } from './rendering.js';
import { createSelection } from './selection.js';
@@ -36,6 +37,7 @@ export function createVesselLayer({ source, services, options = {} } = {}) {
feed: vesselState.state.feed,
readSource: () => vesselState._source,
readViewer: () => vesselState.state.viewer,
readArea: vesselViewArea,
getRowLimit: parts.rendering.renderRowLimit,
readCount: () => vesselState.state.records.all.length,
applyRows: parts.snapshots.reconcileVessels,
+20 -1
View File
@@ -1,10 +1,12 @@
import { AIS_FIRST_CONNECT_LABEL } from './recordPolicy.js';
import { viewAreaMovedEnough } from './viewArea.js';
/** Own source requests and classified feed state through explicit operations. */
export function createIngestion({
feed,
readSource,
readViewer,
readArea = () => null,
getRowLimit,
readCount,
applyRows,
@@ -34,8 +36,11 @@ export function createIngestion({
AbortSignal.timeout(10000),
])
: requestController.signal;
// Ask for the vessels in view; a wide view asks for every vessel.
const area = readArea(viewer);
feed.lastArea = area;
const snapshot = await readSource().getSnapshot(
{ maxRows: getRowLimit() },
{ maxRows: getRowLimit(), ...(area ? { area } : {}) },
{ signal },
);
if (!ownsAisRequest(requestController, requestSessionId)) return;
@@ -169,6 +174,15 @@ export function createIngestion({
: new Date(record.observedAtMs).toISOString(),
};
}
/** Ask again when the view moved away from the last area asked for. */
function refreshIfMoved(viewer) {
if (!feed.enabled || feed.loading) return Promise.resolve();
const active = viewer || readViewer();
if (!viewAreaMovedEnough(feed.lastArea ?? null, readArea(active)))
return Promise.resolve();
return loadLivePositions(active);
}
const methods = {
update(viewer) {
if (!feed.enabled) return Promise.resolve();
@@ -178,6 +192,7 @@ export function createIngestion({
return {
loadLivePositions,
refreshIfMoved,
ownsAisRequest,
applyAisFeedSnapshot,
vesselDisplayRow,
@@ -209,5 +224,9 @@ export function createVesselFeed() {
firstConnectDeadline: null,
firstConnectTimer: null,
abort: null,
/** The area last asked for, null for every vessel. */
lastArea: null,
/** Stops listening for camera moves. */
removeViewListener: null,
};
}
+43
View File
@@ -104,3 +104,46 @@ test('vessel ingestion converts source units once and retains warm records on a
assert.equal(probe.feed.loading, false);
assert.equal(probe.feed.abort, null);
});
test('vessels are asked for by the area in view, and again only when the view moves away', async () => {
const asked = [];
let area = { lat: 37.8, lon: -122.4, radiusKm: 40 };
const feed = createVesselFeed();
feed.enabled = true;
feed.sessionId = 1;
const ingestion = createIngestion({
feed,
readSource: () => ({
getSnapshot: async (query) => {
asked.push(query);
return { records: [], source: 'Feed' };
},
}),
readViewer: () => ({}),
readArea: () => area,
getRowLimit: () => 500,
readCount: () => 0,
now: () => 0,
setSourceLabel: () => {},
applyRows: () => {},
classifySnapshot: () => ({
acceptedRows: [],
acceptedRowCount: 0,
rawRowCount: 0,
}),
isDefinitiveTransportFailure: () => false,
isGraceEligibleTransport: () => false,
markUnavailable: () => {},
settleFirstConnect: () => {},
});
await ingestion.methods.update();
assert.deepEqual(asked, [{ maxRows: 500, area }]);
await ingestion.refreshIfMoved();
assert.equal(asked.length, 1);
area = { ...area, lat: 38.2 };
await ingestion.refreshIfMoved();
assert.deepEqual(asked.at(-1), { maxRows: 500, area });
area = null;
await ingestion.refreshIfMoved();
assert.deepEqual(asked.at(-1), { maxRows: 500 });
});
+15
View File
@@ -196,10 +196,18 @@ export function createLifecycle({
state.records.byMmsi.has(pickedId),
);
restoreSpriteOrderOnEnable('ais', activeViewer);
// Vessels are asked for by view, so ask again when the view moves away.
removeViewListener();
const moveEnd = activeViewer?.camera?.moveEnd;
if (typeof moveEnd?.addEventListener === 'function')
state.feed.removeViewListener = moveEnd.addEventListener(() => {
components.ingestion.refreshIfMoved(activeViewer);
});
return components.ingestion.loadLivePositions(activeViewer);
},
disable() {
removeViewListener();
state.feed.enabled = false;
invalidateAisSession();
releaseContinuousRender('ais-vessels');
@@ -218,6 +226,7 @@ export function createLifecycle({
},
destroy(viewer) {
removeViewListener();
const activeViewer = viewer || state.viewer;
invalidateAisSession();
releaseContinuousRender('ais-vessels'); // direct-destroy path (perf wave 2 fix)
@@ -241,6 +250,12 @@ export function createLifecycle({
},
};
function removeViewListener() {
const remove = state.feed.removeViewListener;
state.feed.removeViewListener = null;
if (typeof remove === 'function') remove();
}
return {
clearFirstConnectTimer,
invalidateAisSession,
+5 -5
View File
@@ -36,7 +36,7 @@ export function createQueries({
/**
* Chip text for a feed the server has reported as not delivering.
* @param {string} status - 'stale' | 'reconnecting' | 'down'
* @param {Object} payload - Parsed /api/ais-live JSON.
* @param {Object} payload - Parsed /api/vessels JSON.
* @returns {string}
*/
@@ -61,12 +61,12 @@ export function createQueries({
}
/**
* Derive a surfaced error string from an /api/ais-live payload, or null when the
* Derive a surfaced error string from an /api/vessels payload, or null when the
* feed has accepted product data. Socket transport, message receipt, and usable
* vessel positions are separate health stages: an open socket with no message
* or no accepted positions must not read as a fresh successful update.
*
* @param {Object|null|undefined} payload - Parsed /api/ais-live JSON.
* @param {Object|null|undefined} payload - Parsed /api/vessels JSON.
* @param {number} acceptedRowCount - Number of rows accepted by vessel normalization.
* @returns {string|null} A short reason for the chip, or null if healthy.
*/
@@ -106,7 +106,7 @@ export function createQueries({
/**
* Classify one server snapshot before any destructive reconciliation.
* @param {Object|null|undefined} payload - Parsed /api/ais-live payload.
* @param {Object|null|undefined} payload - Parsed /api/vessels payload.
* @returns {{transportStatus: string|null, lastMessageAt: number|string|null,
* rawRows: Array<Object>, acceptedRows: Array<Object>, rawRowCount: number,
* acceptedRowCount: number, error: string|null}}
@@ -140,7 +140,7 @@ export function createQueries({
* Map one internal vessel record to a plain JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. navStatus is always null: the
* /api/ais-live proxy does not surface AIS NavigationalStatus, so it
* /api/vessels proxy does not surface AIS NavigationalStatus, so it
* cannot be derived client-side.
* @param {Object|null|undefined} record - `state.records.byMmsi`/`state.records.all` entry.
* @returns {{id: string|null, mmsi: string|null, name: string|null,
+31
View File
@@ -0,0 +1,31 @@
/** Smallest area asked for around the view, in kilometres. */
export const VIEW_AREA_MIN_RADIUS_KM = 10;
/** Largest area asked for; a wider view asks for every vessel instead. */
export const VIEW_AREA_MAX_RADIUS_KM = 450;
/**
* The radius worth asking for from a camera this high, in kilometres: about
* the height, so the area covers what a camera looking down shows. Null when
* the view is wide enough that every vessel is wanted.
*/
export function viewAreaRadiusKm(heightM) {
if (!Number.isFinite(heightM)) return null;
const radiusKm = heightM / 1000;
if (radiusKm > VIEW_AREA_MAX_RADIUS_KM) return null;
return Math.max(VIEW_AREA_MIN_RADIUS_KM, radiusKm);
}
/**
* Whether a view moved far enough from the last area asked for to ask
* again: its radius changed by a quarter, or its center moved a quarter of
* the radius. Switching between an area and every vessel always counts.
*/
export function viewAreaMovedEnough(previous, next) {
if (!previous || !next) return previous !== next;
if (Math.abs(next.radiusKm - previous.radiusKm) >= previous.radiusKm * 0.25)
return true;
const lat = ((previous.lat + next.lat) / 2) * (Math.PI / 180);
const dLat = (next.lat - previous.lat) * 111.32;
const dLon = (next.lon - previous.lon) * 111.32 * Math.cos(lat);
return Math.hypot(dLat, dLon) >= previous.radiusKm * 0.25;
}
+30
View File
@@ -0,0 +1,30 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
VIEW_AREA_MAX_RADIUS_KM,
VIEW_AREA_MIN_RADIUS_KM,
viewAreaMovedEnough,
viewAreaRadiusKm,
} from './viewArea.js';
test('the area asked for follows the camera height, and a wide view asks for every vessel', () => {
assert.equal(viewAreaRadiusKm(2_000), VIEW_AREA_MIN_RADIUS_KM);
assert.equal(viewAreaRadiusKm(80_000), 80);
assert.equal(
viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000),
VIEW_AREA_MAX_RADIUS_KM,
);
assert.equal(viewAreaRadiusKm(VIEW_AREA_MAX_RADIUS_KM * 1000 + 1), null);
assert.equal(viewAreaRadiusKm(Number.NaN), null);
});
test('a view asks again only after moving or zooming a quarter of its radius', () => {
const here = { lat: 37.8, lon: -122.4, radiusKm: 40 };
assert.equal(viewAreaMovedEnough(null, here), true);
assert.equal(viewAreaMovedEnough(here, null), true);
assert.equal(viewAreaMovedEnough(null, null), false);
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.85 }), false);
assert.equal(viewAreaMovedEnough(here, { ...here, lat: 37.9 }), true);
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 45 }), false);
assert.equal(viewAreaMovedEnough(here, { ...here, radiusKm: 55 }), true);
});
+14
View File
@@ -132,5 +132,19 @@ export function createWeatherSource({
signal?.removeEventListener('abort', abort);
}
},
/** Read one image frame through the bounded image route. */
async getImage({ product, time, size, bbox = null, signal } = {}) {
signal?.throwIfAborted();
const response = await fetchImpl(
weatherImageUrl(product, time, size, bbox),
{ signal, redirect: 'error' },
);
if (!response.ok)
throw new Error(`Weather image HTTP ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
signal?.throwIfAborted();
const type = response.headers.get('content-type') || '';
return { contentType: type.split(';')[0].trim().toLowerCase(), bytes };
},
};
}
+37
View File
@@ -162,3 +162,40 @@ test('detail-window image URLs carry the bbox and omit the default detail size',
/size/,
);
});
test('image frames are read through the bounded image route', async () => {
const png = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
const requested = [];
const source = createWeatherSource({
fetchImpl: async (url, init) => {
requested.push({ url, redirect: init.redirect });
return new Response(png, { headers: { 'Content-Type': 'image/PNG' } });
},
});
const frame = await source.getImage({
product: 'radar',
time,
size: { width: 1024, height: 512 },
bbox: { west: -100, south: 30, east: -96, north: 32 },
});
assert.equal(frame.contentType, 'image/png');
assert.deepEqual([...frame.bytes], [...png]);
assert.deepEqual(requested, [
{
url: weatherImageUrl(
'radar',
time,
{ width: 1024, height: 512 },
{ west: -100, south: 30, east: -96, north: 32 },
),
redirect: 'error',
},
]);
const failing = createWeatherSource({
fetchImpl: async () => new Response(null, { status: 409 }),
});
await assert.rejects(
failing.getImage({ product: 'radar', time }),
/Weather image HTTP 409/,
);
});
+154
View File
@@ -0,0 +1,154 @@
/**
* Same-site request gate for the cost-bearing and log endpoints.
*
* The credential panel has its own stricter gate (`admitKeySetupRequest` in
* keySetupCore.mjs): loopback socket + local Host + exact Origin + JSON
* Content-Type. The four endpoints here — `/api/realtime/token` (mints an
* OpenAI Realtime token = spends money), `/api/openai/hud-summary` (OpenAI
* spend), `/api/google/nearby-places` (Google spend), and
* `/api/realtime/debug-log` (appends to a local JSONL) — were previously
* ungated, so a hostile web page open in the same browser could reach them
* cross-site: a `fetch` carries a foreign `Origin`, and an `<img>`/navigation
* carries NO `Origin` at all but does carry `Sec-Fetch-Site: cross-site`.
*
* This module is the pure core that refuses those shapes while keeping the
* documented `HOST=0.0.0.0` LAN opt-in and non-browser loopback tools (the two
* repo QA harnesses POST the token endpoint from Node with no Origin) working.
* It deliberately does NOT require a loopback remote address (that belongs only
* to the credential panel), the presence of an `Origin`, or a JSON
* Content-Type. It touches nothing but its arguments, so every refusal below is
* pinned by a unit assertion.
*/
/**
* Reverse-proxy / CDN forwarding headers. Their presence means the request did
* not originate on this machine, whatever its socket says. This module owns
* the list for every local gate (the credential panel and /mcp import
* `hasProxySignals`), and has no imports so it can sit in any package boundary.
*/
export const PROXY_SIGNALS = Object.freeze([
'forwarded',
'via',
'x-forwarded-for',
'x-forwarded-host',
'x-forwarded-port',
'x-forwarded-proto',
'x-real-ip',
'cf-connecting-ip',
'cf-ray',
]);
/**
* Whether a request carries reverse-proxy or CDN forwarding headers. `headers`
* is keyed by lower-case header name.
*/
export function hasProxySignals(headers = {}) {
return PROXY_SIGNALS.some(
(name) => String(headers[name] || '').trim() !== '',
);
}
/**
* Compute a request's own authority (an origin string) from its protocol and
* Host header. Unlike the credential-panel gate's localhost-restricted
* `localAuthority`, this returns whatever Host the request actually carries —
* the LAN opt-in serves a non-loopback Host to LAN browsers, and a same-origin
* fetch from such a page must still match. Malformed/missing input yields null.
* @param {string} hostHeader e.g. `req.headers.host`
* @param {string} protocol `http:` or `https:`
* @returns {string|null}
*/
function requestAuthority(hostHeader, protocol) {
const raw = String(hostHeader || '')
.trim()
.toLowerCase();
const scheme = String(protocol || '').toLowerCase();
if (!raw || !['http:', 'https:'].includes(scheme) || /[\s/?#@]/.test(raw))
return null;
try {
return new URL(`${scheme}//${raw}`).origin;
} catch {
return null;
}
}
/**
* Decide whether a request to a cost-bearing/log endpoint is same-site enough
* to admit. Pure: no I/O, no globals.
*
* Policy, in order:
* 1. any reverse-proxy / CDN signal header present (PROXY_SIGNALS) → 403;
* 2. `Sec-Fetch-Site` present and not `same-origin` / `none` → 403 (this is
* what blocks `<img src=...>` and cross-site navigation, which carry no
* Origin but do carry `Sec-Fetch-Site: cross-site`);
* 3. `Origin` present must exactly equal the request's own authority computed
* from protocol + Host — the same exact-origin comparison the credential
* gate uses (no userinfo, no path/search/hash). The literal string `null`
* is an opaque origin (sandboxed iframe / `data:` URL) and is refused;
* 4. otherwise ok. Non-browser loopback tools and the LAN opt-in (which may
* carry neither `Origin` nor `Sec-Fetch-Site`) pass here.
*
* @param {{hostHeader?: string, protocol?: string, origin?: string, secFetchSite?: string, proxyHeaders?: Record<string,string>}} req
* @returns {{ok: true} | {ok: false, status: 403, error: string}}
*/
export function admitSameSiteRequest({
hostHeader,
protocol = 'http:',
origin,
secFetchSite,
proxyHeaders = {},
} = {}) {
// (1) A request carrying reverse-proxy / CDN forwarding headers did not
// originate on this machine, whatever its socket says.
if (hasProxySignals(proxyHeaders)) {
return {
ok: false,
status: 403,
error: 'Proxied requests are not accepted',
};
}
// (2) The browser tells us when a request is cross-site. `none` is a typed
// URL / bookmark; `same-origin` is the app itself. Anything else (cross-site,
// same-site but cross-origin) is refused.
const site = String(secFetchSite || '')
.trim()
.toLowerCase();
if (site !== '' && site !== 'same-origin' && site !== 'none') {
return {
ok: false,
status: 403,
error: 'Cross-site requests are not accepted',
};
}
// (3) If Origin is present it must exactly equal the request's own authority.
if (origin !== undefined && origin !== null && origin !== '') {
if (origin === 'null') {
return {
ok: false,
status: 403,
error: 'Opaque origins are not accepted',
};
}
const authority = requestAuthority(hostHeader, protocol);
let parsedOrigin;
try {
parsedOrigin = new URL(String(origin));
} catch {
return { ok: false, status: 403, error: 'Unrecognized Origin refused' };
}
const exactOrigin =
parsedOrigin.username === '' &&
parsedOrigin.password === '' &&
parsedOrigin.pathname === '/' &&
parsedOrigin.search === '' &&
parsedOrigin.hash === '' &&
parsedOrigin.origin === authority;
if (!exactOrigin) {
return {
ok: false,
status: 403,
error: 'Cross-origin requests are not accepted',
};
}
}
return { ok: true };
}
+107
View File
@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { admitSameSiteRequest } from './localRequestGate.mjs';
const same = {
hostHeader: 'localhost:4173',
protocol: 'http:',
origin: 'http://localhost:4173',
secFetchSite: 'same-origin',
};
test('the honest same-origin browser request is admitted', () => {
assert.equal(admitSameSiteRequest(same).ok, true);
});
test('a cross-site Origin is refused', () => {
const r = admitSameSiteRequest({ ...same, origin: 'https://evil.example' });
assert.equal(r.ok, false);
assert.equal(r.status, 403);
});
test('an opaque Origin ("null") is refused even when Host would match', () => {
const r = admitSameSiteRequest({ ...same, origin: 'null' });
assert.equal(r.ok, false);
assert.equal(r.status, 403);
});
test('Sec-Fetch-Site cross-site without an Origin is refused (the <img> case)', () => {
const r = admitSameSiteRequest({
hostHeader: 'localhost:4173',
protocol: 'http:',
secFetchSite: 'cross-site',
});
assert.equal(r.ok, false);
assert.equal(r.status, 403);
});
test('Sec-Fetch-Site same-site (cross-origin sibling) is refused', () => {
const r = admitSameSiteRequest({
...same,
origin: 'http://localhost:4173',
secFetchSite: 'same-site',
});
assert.equal(r.ok, false, 'same-site is not same-origin');
assert.equal(r.status, 403);
});
test('a reverse-proxy signal header is refused', () => {
for (const header of ['x-forwarded-for', 'forwarded', 'via', 'cf-connecting-ip', 'cf-ray', 'x-real-ip', 'x-forwarded-host', 'x-forwarded-port', 'x-forwarded-proto']) {
const r = admitSameSiteRequest({ ...same, proxyHeaders: { [header]: 'anything' } });
assert.equal(r.ok, false, `${header} present → refused`);
}
// An empty forwarding header is not a proxy signal.
assert.equal(admitSameSiteRequest({ ...same, proxyHeaders: { 'x-forwarded-for': '' } }).ok, true);
});
test('same-origin Origin with Sec-Fetch-Site same-origin is admitted', () => {
assert.equal(admitSameSiteRequest(same).ok, true);
});
test('no Origin and no Sec-Fetch headers (curl / Node harness) is admitted', () => {
const r = admitSameSiteRequest({
hostHeader: 'localhost:4173',
protocol: 'http:',
});
assert.equal(r.ok, true, 'non-browser loopback tools pass');
});
test('Sec-Fetch-Site none (typed URL / bookmark) is admitted', () => {
const r = admitSameSiteRequest({
hostHeader: 'localhost:4173',
protocol: 'http:',
secFetchSite: 'none',
});
assert.equal(r.ok, true);
});
test('a LAN remote address is irrelevant: the function takes no remoteAddress', () => {
// The credential-panel gate requires a loopback socket; this gate does not.
// Passing a LAN-shaped Host/Origin pair must still match (LAN opt-in works).
const lan = admitSameSiteRequest({
hostHeader: '192.168.1.5:4173',
protocol: 'http:',
origin: 'http://192.168.1.5:4173',
secFetchSite: 'same-origin',
});
assert.equal(lan.ok, true, 'a same-origin LAN browser request is admitted');
});
test('a cross-port or cross-scheme Origin against the same Host is refused', () => {
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://localhost:4174' }).ok, false, 'cross-port');
assert.equal(admitSameSiteRequest({ ...same, origin: 'https://localhost:4173' }).ok, false, 'cross-scheme');
assert.equal(admitSameSiteRequest({ ...same, origin: 'http://127.0.0.1:4173' }).ok, false, 'different loopback host');
});
test('an unparseable Origin is refused', () => {
assert.equal(admitSameSiteRequest({ ...same, origin: 'not a url' }).ok, false);
});
test('a missing Host with a present Origin is refused (no authority to match)', () => {
assert.equal(admitSameSiteRequest({ ...same, hostHeader: '' }).ok, false, 'empty Host → null authority');
assert.equal(admitSameSiteRequest({ ...same, hostHeader: undefined }).ok, false, 'absent Host → null authority');
});
test('a foreign Host is refused when an Origin is present', () => {
assert.equal(admitSameSiteRequest({ ...same, hostHeader: 'evil.example:4173' }).ok, false, 'foreign Host does not match local Origin');
});
+77 -1
View File
@@ -15,7 +15,16 @@ function fakeCesium(outcomes = []) {
return {
calls,
Ion: { defaultAccessToken: undefined },
GoogleMaps: { defaultApiKey: undefined },
GoogleMaps: {
defaultApiKey: undefined,
mapTilesApiEndpoint: 'https://tile.googleapis.com/',
getDefaultCredit: () => undefined,
},
Resource: class {
constructor(options) {
Object.assign(this, options);
}
},
async createGooglePhotorealistic3DTileset(options) {
calls.push({ options, googleKey: options.key });
return next();
@@ -29,6 +38,7 @@ function fakeCesium(outcomes = []) {
async fromUrl(resource, options) {
calls.push({
options,
resource,
ionToken: resource.accessToken,
assetId: resource.assetId,
});
@@ -140,3 +150,69 @@ test('independent source configurations never mutate shared SDK credentials', as
assert.equal(Cesium.Ion.defaultAccessToken, 'untouched-ion');
assert.equal(Cesium.GoogleMaps.defaultApiKey, 'untouched-google');
});
function tokenSource(...issued) {
const requests = [];
return {
requests,
async token(options = {}) {
requests.push(options);
return issued.shift() ?? null;
},
};
}
test('without a key, server tokens load Google 3D directly', async () => {
const tileset = { id: 'token' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, {
googleTokens: tokenSource('short-lived'),
cesiumToken: 'ion-secret',
});
assert.equal(result.tileset, tileset);
assert.equal(result.route, 'google-token');
const { resource, options } = Cesium.calls[0];
assert.equal(
resource.url,
'https://tile.googleapis.com/v1/3dtiles/root.json',
);
assert.equal(resource.headers.Authorization, 'Bearer short-lived');
assert.equal(resource.queryParameters, undefined);
assert.equal(options.enableCollision, true);
});
test('a tile refused for an expired token retries once with a renewed one', async () => {
const Cesium = fakeCesium([{ id: 'token' }]);
const tokens = tokenSource('first', 'second');
await loadPhotorealisticTileset(Cesium, { googleTokens: tokens });
const { resource } = Cesium.calls[0];
const tile = { headers: { ...resource.headers } };
assert.equal(await resource.retryCallback(tile, { statusCode: 401 }), true);
assert.equal(tile.headers.Authorization, 'Bearer second');
assert.deepEqual(tokens.requests.at(-1), { replacing: 'first' });
assert.equal(resource.retryAttempts, 1);
assert.equal(await resource.retryCallback(tile, { statusCode: 500 }), false);
});
test('a key takes precedence over server tokens', async () => {
const Cesium = fakeCesium([{ id: 'direct' }]);
const tokens = tokenSource('short-lived');
const result = await loadPhotorealisticTileset(Cesium, {
googleApiKey: 'google-secret',
googleTokens: tokens,
});
assert.equal(result.route, 'google-direct');
assert.equal(tokens.requests.length, 0);
});
test('a server without tokens falls back to ion', async () => {
const tileset = { id: 'ion' };
const Cesium = fakeCesium([tileset]);
const result = await loadPhotorealisticTileset(Cesium, {
googleTokens: tokenSource(),
cesiumToken: 'ion-secret',
});
assert.equal(result.route, 'google-ion');
assert.equal(result.errors.length, 1);
assert.equal(Cesium.calls.length, 1);
});
+56 -10
View File
@@ -16,30 +16,43 @@ export function selectMapStartupRoute({
/**
* Load Google Photorealistic 3D Tiles through direct Google access when
* configured, otherwise through Cesium ion's hosted Google asset. If the
* direct request fails and an ion token is available, ion is the recovery path.
* configured, otherwise through Cesium ion's hosted Google asset. Direct
* access uses a browser key, or else short-lived tokens from the app's
* server (see googleTokens.js). If direct access fails and an ion token is
* available, ion is the recovery path.
*
* @param {object} Cesium
* @param {{googleApiKey?: string, cesiumToken?: string}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-ion'|'osm', errors: Error[]}>}
* @param {{googleApiKey?: string, cesiumToken?: string, googleTokens?: {token: Function}|null}} credentials
* @returns {Promise<{tileset: object|null, route: 'google-direct'|'google-token'|'google-ion'|'osm', errors: Error[]}>}
*/
export async function loadPhotorealisticTileset(
Cesium,
{ googleApiKey = '', cesiumToken = '' } = {},
{ googleApiKey = '', cesiumToken = '', googleTokens = null } = {},
) {
const googleKey = clean(googleApiKey);
const ionToken = clean(cesiumToken);
const errors = [];
const attempts = [];
if (googleKey) attempts.push({ route: 'google-direct', googleKey });
if (ionToken) attempts.push({ route: 'google-ion', googleKey: undefined });
if (googleKey)
attempts.push({
route: 'google-direct',
create: () => createGoogleDirectTileset(Cesium, googleKey),
});
else if (googleTokens)
attempts.push({
route: 'google-token',
create: () => createGoogleTokenTileset(Cesium, googleTokens),
});
if (ionToken)
attempts.push({
route: 'google-ion',
create: () => createGoogleIonTileset(Cesium, ionToken),
});
for (const attempt of attempts) {
try {
const tileset = attempt.googleKey
? await createGoogleDirectTileset(Cesium, attempt.googleKey)
: await createGoogleIonTileset(Cesium, ionToken);
const tileset = await attempt.create();
return { tileset, route: attempt.route, errors };
} catch (error) {
errors.push(error instanceof Error ? error : new Error(String(error)));
@@ -60,6 +73,39 @@ export function createGoogleDirectTileset(Cesium, key) {
);
}
/**
* Google 3D with short-lived tokens instead of a key. Every tile request
* carries the token as a header; a tile refused for an expired token gets
* the renewed one and is tried once more.
*/
export async function createGoogleTokenTileset(Cesium, tokens) {
const token = await tokens.token();
if (!token) throw new Error('Google 3D tokens are not offered');
const credit = Cesium.GoogleMaps.getDefaultCredit?.();
const resource = new Cesium.Resource({
url: `${Cesium.GoogleMaps.mapTilesApiEndpoint}v1/3dtiles/root.json`,
headers: { Authorization: `Bearer ${token}` },
credits: credit ? [credit] : undefined,
retryAttempts: 1,
async retryCallback(failed, error) {
if (![401, 403].includes(error?.statusCode)) return false;
const used = failed.headers.Authorization?.replace(/^Bearer /, '');
const renewed = await tokens.token({ replacing: used });
if (!renewed || renewed === used) return false;
failed.headers.Authorization = `Bearer ${renewed}`;
return true;
},
});
// The settings Cesium's Google helper applies to the key route.
return Cesium.Cesium3DTileset.fromUrl(resource, {
cacheBytes: 1536 * 1024 * 1024,
maximumCacheOverflowBytes: 1024 * 1024 * 1024,
enableCollision: true,
// Tiles keep drawing their own texture while draped weather loads.
asynchronouslyLoadImagery: true,
});
}
export async function createGoogleIonTileset(
Cesium,
accessToken,
+62
View File
@@ -0,0 +1,62 @@
/**
* Short-lived Google access tokens the app's server may offer in place of a
* browser key. A server that offers them answers GET GOOGLE_TOKEN_PATH with
* `{ accessToken, expiresAt }` (expiresAt in epoch milliseconds); any other
* answer means it does not, and the app carries on without them.
*/
export const GOOGLE_TOKEN_PATH = '/api/google/tiles-token';
/** Renew a token this long before it expires. */
const RENEW_EARLY_MS = 60_000;
function parseToken(data, now) {
const accessToken = data?.accessToken;
const expiresAt = Number(data?.expiresAt);
if (typeof accessToken !== 'string' || accessToken.length === 0) return null;
if (!Number.isFinite(expiresAt) || expiresAt <= now) return null;
return { accessToken, expiresAt };
}
/**
* A source of tokens from the app's server. `token()` resolves the current
* token, renewing it shortly before it expires; `token({ replacing })`
* renews only if `replacing` is still the current token, so many tile
* requests rejected for the same expired token renew it once. Resolves null
* when the server does not offer tokens.
*/
export function createGoogleTokenSource({
fetchImpl = (...args) => fetch(...args),
url = GOOGLE_TOKEN_PATH,
now = () => Date.now(),
} = {}) {
let current = null;
let pending = null;
async function request() {
try {
const response = await fetchImpl(url, {
headers: { Accept: 'application/json' },
cache: 'no-store',
});
if (!response.ok) return null;
return parseToken(await response.json(), now());
} catch {
return null;
}
}
return {
async token({ replacing } = {}) {
const usable =
current &&
current.expiresAt - RENEW_EARLY_MS > now() &&
(replacing === undefined || current.accessToken !== replacing);
if (usable) return current.accessToken;
pending ??= request().finally(() => {
pending = null;
});
current = await pending;
return current?.accessToken ?? null;
},
};
}
+64
View File
@@ -0,0 +1,64 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { createGoogleTokenSource, GOOGLE_TOKEN_PATH } from './googleTokens.js';
function server(answers) {
const calls = [];
return {
calls,
fetchImpl: async (url) => {
calls.push(url);
const answer = answers.shift();
if (answer instanceof Error) throw answer;
return answer;
},
};
}
const offer = (accessToken, expiresAt) =>
Response.json({ accessToken, expiresAt });
test('a token is reused until shortly before it expires', async () => {
let now = 1_000_000;
const { calls, fetchImpl } = server([
offer('first', now + 900_000),
offer('second', now + 1_800_000),
]);
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
assert.equal(await tokens.token(), 'first');
assert.equal(await tokens.token(), 'first');
assert.deepEqual(calls, [GOOGLE_TOKEN_PATH]);
now += 850_000;
assert.equal(await tokens.token(), 'second');
assert.equal(calls.length, 2);
});
test('requests refused for the same token renew it once', async () => {
const now = 1_000_000;
const { calls, fetchImpl } = server([
offer('first', now + 900_000),
offer('second', now + 900_000),
]);
const tokens = createGoogleTokenSource({ fetchImpl, now: () => now });
await tokens.token();
const renewed = await Promise.all([
tokens.token({ replacing: 'first' }),
tokens.token({ replacing: 'first' }),
]);
assert.deepEqual(renewed, ['second', 'second']);
assert.equal(await tokens.token({ replacing: 'first' }), 'second');
assert.equal(calls.length, 2);
});
test('a server that does not offer tokens yields none', async () => {
for (const answer of [
new Response('Not found', { status: 404 }),
new Response('<!doctype html>', { status: 200 }),
Response.json({ accessToken: '', expiresAt: Date.now() + 60_000 }),
Response.json({ accessToken: 'stale', expiresAt: 1 }),
new TypeError('offline'),
]) {
const { fetchImpl } = server([answer]);
assert.equal(await createGoogleTokenSource({ fetchImpl }).token(), null);
}
});
+1 -1
View File
@@ -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() {} },
+2 -2
View File
@@ -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 = {
+10 -11
View File
@@ -9,6 +9,7 @@ import {
decodeLayerStateParams,
encodeLayerStateParams,
} from './data/layerState.js';
import { STYLE_URL_NAMES } from './view/index.js';
/**
* Share Links — URL Hash State Management
@@ -20,16 +21,8 @@ import {
const DEBOUNCE_MS = 500;
const LEGACY_BLOOM_FALLBACK = 50;
// Style name mapping: internal → URL-friendly
const STYLE_TO_URL = {
normal: 'normal',
retro: 'crt',
surveillance: 'nvg',
thermal: 'flir',
anime: 'anime',
noir: 'noir',
snow: 'snow',
};
// Style name mapping: internal → URL-friendly, shared with views.
const STYLE_TO_URL = STYLE_URL_NAMES;
const SHARE_UI_STATE_PARAM = 'ui';
const SHARE_STYLE_PARAMS_PARAM = 'sp';
@@ -536,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. */
+24
View File
@@ -0,0 +1,24 @@
/** Event packs the app serves under `/events/<id>/`, read once per id. */
const EVENT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
export function createEventPackSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const cache = new Map();
return {
async getEvent(id, { signal } = {}) {
if (!EVENT_ID.test(id)) throw new TypeError(`Invalid event id: ${id}`);
if (!cache.has(id)) {
signal?.throwIfAborted();
const response = await fetchImpl(`/events/${id}/event.json`, {
signal,
});
if (!response.ok)
throw new Error(`Event pack ${id} unavailable (${response.status})`);
cache.set(id, await response.json());
}
return cache.get(id);
},
};
}
+115
View File
@@ -0,0 +1,115 @@
/** Parse GBFS station documents, tolerating field-name variations across providers. */
/**
* Coerce a GBFS boolean field to a native boolean.
* GBFS feeds are inconsistent — some use booleans, others use 0/1 or strings.
* @param {*} value - Raw field value from GBFS JSON.
* @param {boolean} [fallback=true] - Default when value is null/undefined/unrecognized.
* @returns {boolean}
*/
export function normalizeGbfsBool(value, fallback = true) {
if (value == null) return fallback;
if (typeof value === 'boolean') return value;
const num = Number(value);
if (Number.isFinite(num)) return num !== 0;
const text = String(value).trim().toLowerCase();
if (text === 'true' || text === 'yes') return true;
if (text === 'false' || text === 'no') return false;
return fallback;
}
/**
* Parse a value as a non-negative integer, returning fallback on failure.
* @param {*} value - Raw numeric value.
* @param {number|null} [fallback=null] - Returned when value is not a valid non-negative number.
* @returns {number|null}
*/
export function toNonNegativeInteger(value, fallback = null) {
const n = Number(value);
if (!Number.isFinite(n) || n < 0) return fallback;
return Math.round(n);
}
/**
* Extract the stations array from a GBFS JSON payload.
* Handles multiple response shapes: { data: { stations: [...] } },
* { data: [...] }, and nested objects with a stations sub-key.
* @param {Object} payload - Parsed GBFS JSON response.
* @returns {Object[]} Array of raw station objects (may be empty).
*/
export function extractStationsArray(payload) {
const data = payload?.data;
if (Array.isArray(data?.stations)) return data.stations;
if (Array.isArray(data)) return data;
// Some feeds nest stations under an additional key (e.g. locale wrappers)
if (data && typeof data === 'object') {
for (const value of Object.values(data)) {
if (Array.isArray(value?.stations)) return value.stations;
}
}
return [];
}
/**
* Parse a GBFS station_information payload into a Map of station metadata.
* Handles field-name variations across different GBFS providers
* (station_id vs id, lat vs latitude, etc.). Stations with missing or
* invalid coordinates are silently skipped.
* @param {Object} payload - Parsed station_information.json response.
* @returns {Map<string, Object>} Map of stationId to station info objects.
*/
export function parseStationInformation(payload) {
const stations = extractStationsArray(payload);
const stationMap = new Map();
for (const raw of stations) {
// Accept both GBFS 2.x (station_id) and alternate (id) field names
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
const lat = Number(raw?.lat ?? raw?.latitude);
const lon = Number(raw?.lon ?? raw?.longitude);
if (!stationId || !Number.isFinite(lat) || !Number.isFinite(lon)) continue;
stationMap.set(stationId, {
stationId,
name: String(raw?.name || raw?.short_name || '').trim(),
lat,
lon,
capacity: toNonNegativeInteger(raw?.capacity),
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
});
}
return stationMap;
}
/**
* Parse a GBFS station_status payload into a Map of real-time availability.
* @param {Object} payload - Parsed station_status.json response.
* @returns {Map<string, Object>} Map of stationId to status objects containing
* bikesAvailable, docksAvailable, and operational flags.
*/
export function parseStationStatus(payload) {
const stations = extractStationsArray(payload);
const statusMap = new Map();
for (const raw of stations) {
const stationId = String(raw?.station_id ?? raw?.id ?? '').trim();
if (!stationId) continue;
const bikes = toNonNegativeInteger(raw?.num_bikes_available);
const docks = toNonNegativeInteger(raw?.num_docks_available);
statusMap.set(stationId, {
stationId,
bikesAvailable: bikes,
docksAvailable: docks,
isInstalled: normalizeGbfsBool(raw?.is_installed, true),
isRenting: normalizeGbfsBool(raw?.is_renting, true),
isReturning: normalizeGbfsBool(raw?.is_returning, true),
lastReported: toNonNegativeInteger(raw?.last_reported),
});
}
return statusMap;
}
+166
View File
@@ -0,0 +1,166 @@
/**
* Bundled OpenStreetMap infrastructure layers (datacenters and dams): their
* data files, line-delimited GeoJSON parsing and analyst records.
*/
// Resolved by Vite in builds and relative to this module in other consumers,
// when read, so importing this module never needs a module URL.
export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
get 'local-datacenters'() {
return new URL(
'../data/local_data/datacenters/datacenters.geojsonl',
import.meta.url,
).href;
},
get 'local-dams'() {
return new URL('../data/local_data/dams/dams.geojsonl', import.meta.url)
.href;
},
});
/** Parse GeoJSON Lines: one Feature per non-empty line. */
export function parseGeojsonLines(text) {
return String(text)
.split('\n')
.filter((line) => line.trim().length > 0)
.map((line) => JSON.parse(line));
}
/** The singular label for a local infrastructure layer. */
export function layerTitle(layerId) {
if (layerId === 'local-datacenters') return 'Datacenter';
if (layerId === 'local-dams') return 'Dam';
return 'Feature';
}
/**
* Map one local infrastructure feature to a JSON-safe analyst record
* (analyst query engine seam). Pure — no Cesium types. Missing/unknown
* fields are null, never NaN/undefined. Layer-specific fields that do not
* apply (river/output on datacenters, capacity on dams) stay null so a
* shared compact payload can copy them without inventing values.
* Names are unclamped — overlay cards shorten for paint; queries need the
* full source string ("Usina Hidrelétrica de Itaipu").
* @param {Object|null|undefined} raw - {id, lat, lon, properties}.
* @param {string} [layerId] Local layer id (`local-datacenters` / `local-dams`).
* @returns {{id: string, name: string|null, lat: number|null, lon: number|null,
* operator: string|null, capacity: string|null, river: string|null,
* output: string|null}}
*/
export function mapAnalystRecord(raw, layerId = '') {
const num = (v) => (Number.isFinite(v) ? v : null);
const text = (v) => {
const t = String(v ?? '').trim();
return t && t !== 'undefined' && t !== 'null' ? t : null;
};
const props =
raw?.properties &&
typeof raw.properties === 'object' &&
!Array.isArray(raw.properties)
? raw.properties
: {};
const tags =
props.tags && typeof props.tags === 'object' && !Array.isArray(props.tags)
? props.tags
: {};
const name =
text(props.name) ||
text(tags.name) ||
text(tags['name:en']) ||
text(tags.official_name) ||
null;
const operator =
text(tags.operator) ||
text(props.operator) ||
text(tags['operator:short']) ||
null;
const capacity =
layerId === 'local-datacenters'
? text(tags['capacity:it_load']) ||
text(tags.it_load) ||
text(tags.capacity) ||
text(props.capacity)
: null;
const river =
layerId === 'local-dams'
? text(tags.associated_river) ||
text(props.associated_river) ||
text(tags.river) ||
text(props.river) ||
text(tags['river:name'])
: null;
const output =
layerId === 'local-dams'
? text(props.output) || text(tags['plant:output:electricity'])
: null;
return {
id: name || text(raw?.id) || layerTitle(layerId),
name,
lat: num(raw?.lat),
lon: num(raw?.lon),
operator,
capacity,
river,
output,
};
}
/** A representative point for a feature: its point, or its outline's average vertex. */
export function featurePoint(feature) {
const geometry = feature?.geometry;
const outline =
geometry?.type === 'Point'
? [geometry.coordinates]
: geometry?.type === 'LineString'
? geometry.coordinates
: geometry?.type === 'Polygon'
? geometry.coordinates?.[0]
: geometry?.type === 'MultiPolygon'
? geometry.coordinates?.[0]?.[0]
: null;
const points = (outline || []).filter(
(point) => Number.isFinite(point?.[0]) && Number.isFinite(point?.[1]),
);
if (!points.length) return null;
return {
lon: points.reduce((sum, point) => sum + point[0], 0) / points.length,
lat: points.reduce((sum, point) => sum + point[1], 0) / points.length,
};
}
/** Read the bundled layers' analyst records through a supplied transport. */
export function createInfrastructureSource({
fetchImpl = (...args) => globalThis.fetch(...args),
} = {}) {
const cache = new Map();
return {
async getRecords(layerId, { signal } = {}) {
const url = INFRASTRUCTURE_DATA_URLS[layerId];
if (!url) throw new TypeError(`Unknown infrastructure layer: ${layerId}`);
if (!cache.has(layerId)) {
signal?.throwIfAborted();
const response = await fetchImpl(url, { signal });
if (!response.ok) throw new Error(`HTTP ${response.status}`);
const records = parseGeojsonLines(await response.text()).flatMap(
(feature) => {
const point = featurePoint(feature);
return point
? [
mapAnalystRecord(
{
id: feature.properties?.osm_id ?? feature.id,
...point,
properties: feature.properties,
},
layerId,
),
]
: [];
},
);
cache.set(layerId, records);
}
return cache.get(layerId);
},
};
}
+18 -18
View File
@@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
normalizeReadsbAircraft,
normalizeVesselObservation,
openSkySnapshot,
@@ -133,7 +133,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
const requests = [];
const fetchImpl = async (url, init) => {
requests.push({ url, init });
if (url.startsWith('/api/opensky?'))
if (url.startsWith('/api/flights?'))
return response(
{ time: now / 1000, states: [aircraft] },
{
@@ -141,27 +141,27 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
'x-flight-coverage': '250 nm regional',
},
);
if (url.startsWith('/api/opensky-track'))
if (url.startsWith('/api/flights/track'))
return response({ path: [[now / 1000 - 30, 30, -97, 1000, 45, false]] });
if (url === '/api/adsblol/mil')
if (url === '/api/military')
return response(
{ ac: [{ hex: 'abc123', lat: 30, lon: -97, seen_pos: 15 }] },
{ 'x-ads-b-cache-age-ms': '60000', 'x-ads-b-cache': 'STALE' },
{ 'x-feed-age-ms': '60000', 'x-feed-cache': 'STALE' },
);
return response({
timestamp: now / 1000 - 60,
trace: [[10, 30, -97, 1000]],
});
};
const civil = createOpenSkySource({ fetchImpl, now: () => now });
const military = createAdsbLolSource({ fetchImpl, now: () => now });
const civil = createFlightSource({ fetchImpl, now: () => now });
const military = createMilitarySource({ fetchImpl, now: () => now });
assert.equal(requests.length, 0);
const signal = new AbortController().signal;
const snapshot = await civil.getSnapshot(
{ latitude: 30.123456, longitude: -97 },
{ signal },
);
assert.equal(requests[0].url, '/api/opensky?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].url, '/api/flights?lat=30.1235&lon=-97.0000');
assert.equal(requests[0].init.signal, signal);
assert.equal(snapshot.source, 'adsb.lol');
assert.equal(snapshot.coverage, '250 nm regional');
@@ -180,7 +180,7 @@ test('construction is inert; adapters preserve routes, viewport query, cache epo
test('cancellation after slow body parsing rejects even with a transport that ignores abort', async () => {
let release;
const controller = new AbortController();
const source = createOpenSkySource({
const source = createFlightSource({
fetchImpl: async () => ({
ok: true,
json: () =>
@@ -199,7 +199,7 @@ test('cancellation after slow body parsing rejects even with a transport that ig
test('denials and outages never start another source and do not echo arbitrary response bodies', async () => {
for (const status of [401, 403, 429, 502]) {
let calls = 0;
const source = createOpenSkySource({
const source = createFlightSource({
fetchImpl: async () => {
calls++;
return response({ error: 'sensitive upstream detail' }, {}, status);
@@ -216,12 +216,12 @@ test('denials and outages never start another source and do not echo arbitrary r
});
test('AIS reports limited received coverage and keeps connection state separate from positions', async () => {
const source = createAisStreamSource({
const source = createVesselSource({
origin: () => 'http://example.test',
fetchImpl: async (url) => {
if (url.includes('/track?'))
return response({ samples: [{ lat: 30, lon: -97, t: now / 1000 }] });
assert.equal(url, 'http://example.test/api/ais-live?maxRows=500');
assert.equal(url, 'http://example.test/api/vessels?maxRows=500');
return response({
status: 'reconnecting',
refreshing: true,
@@ -249,7 +249,7 @@ test('AIS reports limited received coverage and keeps connection state separate
});
test('a malformed vessel row cannot prevent admission of valid positions', async () => {
const source = createAisStreamSource({
const source = createVesselSource({
fetchImpl: async () =>
response({
status: 'live',
@@ -273,7 +273,7 @@ test('out-of-range source epochs do not reach Date or globe time constructors',
});
test('classification identities include positionless aircraft without admitting them to rendering', async () => {
const source = createAdsbLolSource({
const source = createMilitarySource({
fetchImpl: async () =>
response({
ac: [
@@ -293,12 +293,12 @@ test('classification identities include positionless aircraft without admitting
});
test('identity lookup validates its response and honors body-parse cancellation', async () => {
const malformed = createAdsbLolSource({
const malformed = createMilitarySource({
fetchImpl: async () => response({ ac: [{}] }),
});
await assert.rejects(malformed.getIdentities(), /Malformed/);
const abort = new AbortController();
const source = createAdsbLolSource({
const source = createMilitarySource({
fetchImpl: async () => ({
ok: true,
status: 200,
+3 -3
View File
@@ -13,7 +13,7 @@ export {
vesselSnapshot,
} from './vessels.js';
export {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
} from './standalone.js';
+62 -33
View File
@@ -17,7 +17,7 @@ const defaultFetch = (...args) => globalThis.fetch(...args);
const header = (response, name) => response.headers?.get?.(name);
function openSkyError(response) {
const error = httpError(response, 'OpenSky');
const error = httpError(response, 'Flights');
const mode = String(
header(response, 'x-opensky-auth-mode-used') ||
header(response, 'x-opensky-auth') ||
@@ -49,13 +49,24 @@ function openSkyError(response) {
return error;
}
/** Existing same-origin aircraft routes; no request starts during construction. */
export function createOpenSkySource({
/**
* The provider a feed response names in `X-Feed-Source`, so a server that
* answers a feed route from another provider is reported as that provider.
*/
function feedSource(response, fallback) {
const named = header(response, 'x-feed-source');
return typeof named === 'string' && named.trim()
? named.trim().slice(0, 80)
: fallback;
}
/** The flights feed (/api/flights); no request starts during construction. */
export function createFlightSource({
fetchImpl = defaultFetch,
now = () => Date.now(),
} = {}) {
return {
label: 'OpenSky Network',
label: 'Flights',
async getSnapshot(query = {}, { signal } = {}) {
const params = new URLSearchParams();
if (Number.isFinite(query.latitude) && Number.isFinite(query.longitude)) {
@@ -64,14 +75,17 @@ export function createOpenSkySource({
}
const { response, payload } = await readResponse(
fetchImpl,
`/api/opensky${params.size ? '?' + params : ''}`,
`/api/flights${params.size ? '?' + params : ''}`,
{ signal },
'OpenSky',
'Flights',
);
if (!response.ok) throw openSkyError(response);
return {
...openSkySnapshot(payload, {
source: header(response, 'x-flight-source') || 'OpenSky Network',
// A fallback provider names itself in X-Flight-Source.
source:
header(response, 'x-flight-source') ||
feedSource(response, 'Flights'),
coverage:
header(response, 'x-flight-coverage') ||
'worldwide upstream snapshot',
@@ -83,11 +97,11 @@ export function createOpenSkySource({
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/opensky-track?icao24=' + encodeURIComponent(reference),
'/api/flights/track?icao24=' + encodeURIComponent(reference),
{ signal },
'OpenSky',
'Flights',
);
if (!response.ok) throw httpError(response, 'OpenSky');
if (!response.ok) throw httpError(response, 'Flights');
return {
records: normalizeAircraftTrack(payload?.path),
complete: false,
@@ -108,36 +122,37 @@ export function createOpenSkySource({
};
}
export function createAdsbLolSource({
export function createMilitarySource({
fetchImpl = defaultFetch,
now = () => Date.now(),
} = {}) {
return {
label: 'adsb.lol',
label: 'Military aircraft',
async getIdentities(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/mil',
'/api/military',
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
if (!response.ok) throw httpError(response, 'Military aircraft');
return readsbIdentities(payload);
},
async getSnapshot(_query = {}, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/mil',
'/api/military',
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
const age = finite(header(response, 'x-ads-b-cache-age-ms'));
if (!response.ok) throw httpError(response, 'Military aircraft');
const age = finite(header(response, 'x-feed-age-ms'));
return {
...readsbSnapshot(payload, {
source: feedSource(response, 'Military aircraft'),
observedAtMs: now() - (age != null && age > 0 ? age : 0),
now: now(),
stale: header(response, 'x-ads-b-cache') === 'STALE',
stale: header(response, 'x-feed-cache') === 'STALE',
}),
status: response.status,
};
@@ -145,11 +160,11 @@ export function createAdsbLolSource({
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/adsblol/trace?hex=' + encodeURIComponent(reference),
'/api/military/track?hex=' + encodeURIComponent(reference),
{ signal },
'adsb.lol',
'Military aircraft',
);
if (!response.ok) throw httpError(response, 'adsb.lol');
if (!response.ok) throw httpError(response, 'Military aircraft');
const baseTimeMs = epoch(payload?.timestamp, 1000);
return {
records:
@@ -165,24 +180,35 @@ export function createAdsbLolSource({
};
}
export function createAisStreamSource({
export function createVesselSource({
fetchImpl = defaultFetch,
apiUrl = '/api/ais-live',
apiUrl = '/api/vessels',
origin = () => globalThis.location?.origin || 'http://localhost',
} = {}) {
return {
label: 'AISStream',
async getSnapshot({ maxRows = 12000 } = {}, { signal } = {}) {
label: 'Vessels',
async getSnapshot({ maxRows = 12000, area } = {}, { signal } = {}) {
const url = new URL(apiUrl, origin());
url.searchParams.set('maxRows', String(maxRows));
// An area asks for the vessels around a point; servers that hold every
// vessel may ignore it, since callers still keep only what they need.
if (
Number.isFinite(area?.lat) &&
Number.isFinite(area?.lon) &&
Number.isFinite(area?.radiusKm)
) {
url.searchParams.set('lat', area.lat.toFixed(5));
url.searchParams.set('lon', area.lon.toFixed(5));
url.searchParams.set('radius_km', area.radiusKm.toFixed(1));
}
const { response, payload } = await readResponse(
fetchImpl,
url.toString(),
{ signal, cache: 'no-store' },
'AIS live',
'Vessels',
);
if (!response.ok) {
const error = httpError(response, 'AIS live');
const error = httpError(response, 'Vessels');
const reasons = {
'missing-key': 'AISSTREAM_API_KEY not set',
'auth-failed': 'API key rejected — check AISSTREAM_API_KEY',
@@ -193,16 +219,19 @@ export function createAisStreamSource({
error.message = reasons[payload?.status] || error.message;
throw error;
}
return { ...vesselSnapshot(payload), status: response.status };
return {
...vesselSnapshot(payload, { source: feedSource(response, 'Vessels') }),
status: response.status,
};
},
async getTrack(reference, { signal } = {}) {
const { response, payload } = await readResponse(
fetchImpl,
'/api/ais-live/track?mmsi=' + encodeURIComponent(reference),
'/api/vessels/track?mmsi=' + encodeURIComponent(reference),
{ signal },
'AIS live',
'Vessels',
);
if (!response.ok) throw httpError(response, 'AIS live');
if (!response.ok) throw httpError(response, 'Vessels');
return {
records: normalizeVesselTrack(payload?.samples),
complete: false,
+27
View File
@@ -0,0 +1,27 @@
/**
* Parse three-line TLE catalog text into `{ name, line1, line2 }` entries.
* Blocks whose second and third lines are not TLE lines 1 and 2 are skipped.
*/
export function parseTleText(text) {
const lines = String(text)
.trim()
.split('\n')
.map((line) => line.trim())
.filter((line) => line.length > 0);
const result = [];
for (let i = 0; i < lines.length - 2; i += 3) {
const name = lines[i];
const line1 = lines[i + 1];
const line2 = lines[i + 2];
if (line1.startsWith('1 ') && line2.startsWith('2 ')) {
result.push({ name, line1, line2 });
}
}
return result;
}
/** The NORAD catalog number from TLE line 1, or null. */
export function tleCatalogNumber(line1) {
const number = Number.parseInt(String(line1).slice(2, 7), 10);
return Number.isInteger(number) ? number : null;
}
+4
View File
@@ -6,6 +6,7 @@ import { createStandaloneScene } from './scene.js';
import { createStandaloneControls } from './controls.js';
import { createStandaloneData } from './data.js';
import { createStandaloneTools } from './tools.js';
import { createGoogleTokenSource } from '../maps/googleTokens.js';
// The existing controls and layer catalog contain page-scoped state.
let constructed = false;
@@ -39,6 +40,9 @@ export function createStandaloneApplication({
const scene = await createStandaloneScene({
...context,
googleApiKey,
// Without a key, Google 3D can still load with tokens from the
// app's server when it offers them.
googleTokens: googleApiKey ? null : createGoogleTokenSource(),
cesiumToken,
loaderStatus,
});
+10 -7
View File
@@ -1,8 +1,8 @@
import { createOpenFreeMapSource } from '../sources/openFreeMap.js';
import {
createOpenSkySource,
createAdsbLolSource,
createAisStreamSource,
createFlightSource,
createMilitarySource,
createVesselSource,
} from '../sources/live/standalone.js';
import { createCctvSource } from '../layers/cctv/source.js';
import { createRadioSource } from '../layers/radio/source.js';
@@ -25,10 +25,13 @@ export function createStandaloneLayerSources() {
const mapTiles = createOpenFreeMapSource();
return {
...createReferenceSources(),
flights: createOpenSkySource(),
military: createAdsbLolSource(),
vessels: createAisStreamSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
flights: createFlightSource(),
military: createMilitarySource(),
vessels: createVesselSource({
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/vessels',
// Resolved against the document's address, which a panel host may
// serve from its own scheme.
origin: () => globalThis.document?.baseURI ?? 'http://localhost',
}),
cctv: createCctvSource(),
radio: createRadioSource(),
+29
View File
@@ -0,0 +1,29 @@
/** The standalone tool catalog for voice, loaded the first time voice needs it. */
let pending = null;
export function loadToolCatalog() {
pending ??= Promise.all([
import('../tools/index.js'),
import('../tools/services.js'),
]).then(
([
{ composeCatalog, coreTools, catalogForSurface },
{ createToolServices },
]) =>
catalogForSurface(
composeCatalog({
tools: coreTools,
services: createToolServices({
fetchImpl: (...args) => globalThis.fetch(...args),
appUrl: new URL(globalThis.document.baseURI).origin,
}),
}),
'voice',
),
);
pending.catch(() => {
pending = null;
});
return pending;
}
+7 -1
View File
@@ -1,16 +1,22 @@
import { createAssetDirectorySource } from '../director/packs/source.js';
import { createApplicationTools } from '../app/tools.js';
import { startStandaloneChrome } from './startupChrome.js';
import { loadToolCatalog } from './toolCatalog.js';
export function createStandaloneTools(options) {
return createApplicationTools({
startChrome: startStandaloneChrome,
sceneDataPacks: {
sources: {
assets: createAssetDirectorySource({
baseUrl: new URL('/scene-assets/', window.location.href).href,
// A panel names the app's address; see src/tools/globePanel.js.
baseUrl: new URL(
'/scene-assets/',
globalThis.GEV_APP_BASE_URL ?? document.baseURI,
).href,
}),
},
},
...options,
voice: { toolCatalog: loadToolCatalog, ...options?.voice },
});
}
+153
View File
@@ -0,0 +1,153 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { request as httpRequest } from 'node:http';
import { fileURLToPath } from 'node:url';
import {
DEFAULT_ALLOWED_HOSTS,
hostCheckPlugin,
isAllowedHost,
resolveAllowedHosts,
} from '../../build/allowedHosts.js';
test('uses the restricted local host allowlist by default', () => {
assert.deepEqual(resolveAllowedHosts(), DEFAULT_ALLOWED_HOSTS);
assert.equal(resolveAllowedHosts().includes(true), false);
});
test('adds explicitly configured LAN hostnames without widening the allowlist', () => {
assert.deepEqual(
resolveAllowedHosts('globe.lan, globe.internal ,globe.lan'),
['localhost', '127.0.0.1', 'globe.lan', 'globe.internal'],
);
});
test('ignores empty configured host entries', () => {
assert.deepEqual(resolveAllowedHosts(' , , globe.lan, '), [
'localhost',
'127.0.0.1',
'globe.lan',
]);
});
test('rejects suffix and wildcard entries so every LAN hostname is explicit', () => {
assert.deepEqual(resolveAllowedHosts('.local,*.example,globe.lan'), [
'localhost',
'127.0.0.1',
'globe.lan',
]);
});
test('a Host is allowed by the same rule Vite applies', () => {
const allowed = ['localhost', '127.0.0.1', 'globe.lan', '.corp.example'];
for (const host of [
'localhost:4173',
'LOCALHOST:4173',
'app.localhost',
'127.0.0.1:4173',
'192.168.1.20:4173',
'[::1]:4173',
'globe.lan:4173',
'corp.example',
'a.corp.example:8080',
'office:4173',
])
assert.equal(isAllowedHost(host, allowed, ['office']), true, host);
for (const host of [
undefined,
'',
'evil.example:4173',
'globe.lan.evil.example',
'localhost.evil.example',
'mybox.local:4173',
'[not-an-ip]:4173',
])
assert.equal(isAllowedHost(host, allowed, ['office']), false, String(host));
assert.equal(isAllowedHost('anything.example', true), true);
});
test('the Host check is installed first on the dev and preview servers', () => {
const plugin = hostCheckPlugin();
assert.equal(plugin.enforce, 'pre');
for (const hook of ['configureServer', 'configurePreviewServer']) {
let middleware;
plugin[hook]({
config: {
server: { allowedHosts: ['localhost'], host: 'localhost' },
preview: { allowedHosts: ['localhost'], host: undefined },
},
middlewares: { use: (fn) => (middleware = fn) },
});
const respond = (host) => {
const res = {
status: null,
writeHead(status) {
this.status = status;
},
end() {},
};
let passed = false;
middleware({ headers: { host } }, res, () => (passed = true));
return passed ? 'next' : res.status;
};
assert.equal(respond('localhost:4173'), 'next', hook);
assert.equal(respond('evil.example:4173'), 403, hook);
}
});
test('an unlisted Host cannot reach a route a plugin mounts', async (t) => {
const { createServer } = await import('vite');
const { createBrowserViteConfig } = await import('../../build/vite.js');
const provider = {
name: 'fixture-provider',
configureServer(server) {
server.middlewares.use('/api/fixture', (req, res) => {
res.end('provider answered');
});
},
};
const base = createBrowserViteConfig({ plugins: [provider] });
const vite = await createServer({
...base,
root: fileURLToPath(new URL('../../', import.meta.url)),
configFile: false,
envFile: false,
publicDir: false,
logLevel: 'silent',
// Only this test's plugins: the Host check and the fixture provider.
plugins: base.plugins.filter((plugin) =>
['host-check', 'fixture-provider'].includes(plugin?.name),
),
optimizeDeps: { noDiscovery: true, include: [] },
server: {
...base.server,
host: '127.0.0.1',
port: 0,
hmr: false,
watch: null,
},
});
await vite.listen();
t.after(() => vite.close());
const { port } = vite.httpServer.address();
const get = (host) =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
host: '127.0.0.1',
port,
path: '/api/fixture',
headers: { Host: host },
},
(response) => {
response.resume();
resolve(response.statusCode);
},
);
request.on('error', reject);
request.end();
});
assert.equal(await get(`localhost:${port}`), 200);
assert.equal(await get(`127.0.0.1:${port}`), 200);
assert.equal(await get(`evil.example:${port}`), 403);
assert.equal(await get(`mybox.local:${port}`), 403);
});
+87 -33
View File
@@ -93,11 +93,11 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
});
const states = install(providers.openSkyProxy());
assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).statusCode,
(await states('/api/flights', '?lat=30&lon=-97')).statusCode,
200,
);
assert.equal(
(await states('/api/opensky', '?lat=30&lon=-97')).headers[
(await states('/api/flights', '?lat=30&lon=-97')).headers[
'x-opensky-cache'
],
'HIT',
@@ -105,14 +105,14 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(calls.length, 2);
const tracks = install(providers.trackBackfillProxies(), true);
assert.equal(
(await tracks('/api/opensky-track', '?icao24=ABC123')).statusCode,
(await tracks('/api/flights/track', '?icao24=ABC123')).statusCode,
200,
);
await tracks('/api/opensky-track', '?icao24=abc123');
await tracks('/api/flights/track', '?icao24=abc123');
assert.equal(calls.filter((call) => call.url.includes('/token')).length, 1);
assert.equal(calls.filter((call) => call.url.includes('/tracks/')).length, 1);
assert.equal(
(await tracks('/api/adsblol/trace', '?hex=invalid')).statusCode,
(await tracks('/api/military/track', '?hex=invalid')).statusCode,
400,
);
now += 130_000;
@@ -131,7 +131,7 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
);
process.env.OPENSKY_AUTH_MODE = 'anon';
const fallback = await install(fresh.openSkyProxy())(
'/api/opensky',
'/api/flights',
'?lat=30&lon=-97',
);
assert.equal(fallback.statusCode, 200);
@@ -139,6 +139,60 @@ test('OpenSky state and track routes share tokens, retain cache and use regional
assert.equal(JSON.parse(fallback.body).states[0][0], 'abc123');
});
test('OpenSky global snapshot attempts are time-limited, retried once, then fall back', async (t) => {
environment(t, {
OPENSKY_CLIENT_ID: undefined,
OPENSKY_CLIENT_SECRET: undefined,
OPENSKY_AUTH_MODE: 'anon',
OPENSKY_USERNAME: undefined,
OPENSKY_PASSWORD: undefined,
});
t.mock.method(console, 'warn', () => {});
const timedOut = () =>
Promise.reject(
new DOMException(
'The operation was aborted due to timeout',
'TimeoutError',
),
);
const load = async (respond) => {
const attempts = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
if (url.includes('/states/')) {
assert.ok(options.signal instanceof AbortSignal);
attempts.push(url);
return respond(attempts.length);
}
if (url.includes('/lat/'))
return Response.json({
now: Date.now() / 1000,
ac: [{ hex: 'abc123', lat: 30, lon: -97, alt_baro: 10000 }],
});
throw Error(`Unexpected URL: ${url}`);
});
const fresh = await import(
`../../server/providers/aircraft/opensky.js?timeout=${Math.random()}`
);
const response = await install(fresh.openSkyProxy())(
'/api/flights',
'?lat=30&lon=-97',
);
return { response, attempts };
};
const recovered = await load((attempt) =>
attempt === 1
? timedOut()
: Response.json({ time: Math.floor(Date.now() / 1000), states: [] }),
);
assert.equal(recovered.attempts.length, 2);
assert.equal(recovered.response.statusCode, 200);
assert.equal(recovered.response.headers['x-flight-source'], undefined);
const stalled = await load(() => timedOut());
assert.equal(stalled.attempts.length, 2);
assert.equal(stalled.response.statusCode, 200);
assert.equal(stalled.response.headers['x-flight-source'], 'adsb.lol');
});
test('military aircraft route preserves fresh cache and stale response after upstream failure', async (t) => {
let now = Date.now();
let calls = 0;
@@ -149,11 +203,11 @@ test('military aircraft route preserves fresh cache and stale response after ups
return Response.json({ ac: [{ hex: 'abc123' }] });
});
const request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil');
assert.equal((await request('/api/adsblol/mil')).body, first.body);
const first = await request('/api/military');
assert.equal((await request('/api/military')).body, first.body);
assert.equal(calls, 1);
now += 13_000;
assert.equal((await request('/api/adsblol/mil')).body, first.body);
assert.equal((await request('/api/military')).body, first.body);
assert.equal(calls, 2);
});
@@ -207,7 +261,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
const request = install(plugin, true);
let res;
for (let i = 0; i < 100; i++) {
res = await request('/api/ais-live');
res = await request('/api/vessels');
if (JSON.parse(res.body).rows.length) break;
await delay(10);
}
@@ -215,10 +269,10 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
assert.equal(data.status, 'live');
assert.equal(data.rows[0].mmsi, '123456789');
assert.equal(data.rows[0].heading, null);
const history = await request('/api/ais-live', '/track?mmsi=123456789');
const history = await request('/api/vessels', '/track?mmsi=123456789');
assert.equal(JSON.parse(history.body).samples.length, 2);
assert.equal(
(await request('/api/ais-live', '/track?mmsi=bad')).statusCode,
(await request('/api/vessels', '/track?mmsi=bad')).statusCode,
400,
);
assert.equal(sockets.length, 1);
@@ -226,7 +280,7 @@ test('AIS preview route ingests through the socket, returns tracks and disposes
for (let i = 0; i < 100 && sockets[0].readyState !== 3; i++) await delay(10);
assert.equal(sockets[0].readyState, 3);
const restarted = install(plugin);
await restarted('/api/ais-live');
await restarted('/api/vessels');
for (let i = 0; i < 100 && sockets.length < 2; i++) await delay(10);
assert.equal(sockets.length, 2);
});
@@ -245,10 +299,10 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
});
});
const request = install(providers.adsbLolProxy());
const first = await request('/api/adsblol/mil');
const first = await request('/api/military');
assert.equal(first.statusCode, 200);
now += 13_000;
const limited = await request('/api/adsblol/mil');
const limited = await request('/api/military');
assert.equal(calls, 2);
assert.equal(
limited.statusCode,
@@ -256,16 +310,16 @@ test('military aircraft route serves stale cache on an upstream 429 and cools do
'a 429 with a cached body is never relayed',
);
assert.equal(limited.body, first.body);
assert.equal(limited.headers['x-ads-b-cache'], 'STALE');
assert.equal(limited.headers['x-feed-cache'], 'STALE');
assert.equal(limited.headers['x-ads-b-upstream-status'], '429');
assert.equal(limited.headers['x-ads-b-cache-age-ms'], '13000');
assert.equal(limited.headers['x-feed-age-ms'], '13000');
now += 5_000;
const cooling = await request('/api/adsblol/mil');
const cooling = await request('/api/military');
assert.equal(calls, 2, 'no upstream call inside the Retry-After window');
assert.equal(cooling.headers['x-ads-b-cache'], 'STALE');
assert.equal(cooling.headers['x-ads-b-cache-age-ms'], '18000');
assert.equal(cooling.headers['x-feed-cache'], 'STALE');
assert.equal(cooling.headers['x-feed-age-ms'], '18000');
now += 16_000;
await request('/api/adsblol/mil');
await request('/api/military');
assert.equal(calls, 3, 'upstream is retried once Retry-After elapses');
});
@@ -277,10 +331,10 @@ test('military aircraft route relays an upstream 429 when nothing is cached', as
return new Response('{"error":"rate limited"}', { status: 429 });
});
const request = install(providers.adsbLolProxy());
const limited = await request('/api/adsblol/mil');
const limited = await request('/api/military');
assert.equal(limited.statusCode, 429);
assert.ok(limited.headers['retry-after']);
const again = await request('/api/adsblol/mil');
const again = await request('/api/military');
assert.equal(again.statusCode, 429);
assert.equal(
calls,
@@ -315,21 +369,21 @@ test('military fallback cancels a stalled 5xx body and starts cooldown at receip
);
});
const request = install(providers.adsbLolProxy());
await request('/api/adsblol/mil');
await request('/api/military');
now += 1000;
const hit = await request('/api/adsblol/mil');
assert.equal(hit.headers['x-ads-b-cache-age-ms'], '1000');
const hit = await request('/api/military');
assert.equal(hit.headers['x-feed-age-ms'], '1000');
now += 12000;
const fallback = await request('/api/adsblol/mil');
const fallback = await request('/api/military');
assert.equal(fallback.statusCode, 200);
assert.equal(cancelled, true);
assert.equal(fallback.headers['x-ads-b-cache-age-ms'], '23000');
assert.equal(fallback.headers['x-feed-age-ms'], '23000');
assert.equal(fallback.headers['x-ads-b-retry-after-seconds'], '20');
now += 19000;
await request('/api/adsblol/mil');
await request('/api/military');
assert.equal(calls, 2);
now += 2000;
await request('/api/adsblol/mil');
await request('/api/military');
assert.equal(calls, 3);
});
@@ -349,7 +403,7 @@ test('military cooldown bounds untrusted Retry-After and defaults server errors'
headers: { 'Retry-After': raw },
}),
);
const result = await install(providers.adsbLolProxy())('/api/adsblol/mil');
const result = await install(providers.adsbLolProxy())('/api/military');
assert.equal(Number(result.headers['retry-after']), seconds);
}
});
@@ -369,7 +423,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
};
});
const res1 = await tracks('/api/opensky-track', '?icao24=def456');
const res1 = await tracks('/api/flights/track', '?icao24=def456');
assert.equal(res1.statusCode, 502);
assert.deepEqual(JSON.parse(res1.body), {
error: 'Upstream track response too large',
@@ -377,7 +431,7 @@ test('track backfill proxy returns 502 on an oversized upstream body and caches
// Cached as a 502 (never a 200): a retry inside the window neither
// reads as an empty track nor spends OpenSky credits on another download.
const res2 = await tracks('/api/opensky-track', '?icao24=def456');
const res2 = await tracks('/api/flights/track', '?icao24=def456');
assert.equal(res2.statusCode, 502);
assert.equal(callCount, 1);
});
+77 -2
View File
@@ -21,6 +21,7 @@ import { openAiRealtimeProxy } from 'gods-eye-view/server/providers/openai';
import { keySetupEndpoint } from 'gods-eye-view/server/standalone/key-setup';
import { realtimeInstructions } from '../../server/providers/openai/instructions.js';
import { GEV_REALTIME_TOOLS } from '../../server/providers/openai/tools.js';
import { standaloneVoiceTools } from '../../server/standalone/voiceTools.js';
function install(plugin, preview = false) {
const routes = new Map();
@@ -195,6 +196,40 @@ test('Realtime handler preserves tools and default instructions, isolates suppli
assert.equal(sent[0].session.instructions, sent[2].session.instructions);
});
test('Realtime sessions carry supplied tools, and the standalone voice adds the catalog queries', async (t) => {
env(t, 'OPENAI_API_KEY', 'fixture-upstream-secret');
env(t, 'GEV_RATELIMIT_OPENAI_PER_MIN', undefined);
const sent = [];
t.mock.method(globalThis, 'fetch', async (url, options) => {
sent.push(JSON.parse(options.body));
return Response.json({ value: 'fixture-ephemeral' });
});
const tools = standaloneVoiceTools();
const response = await request(
install(openAiRealtimeProxy({ realtime: { tools } })).get(
'/api/realtime/token',
),
);
assert.equal(response.status, 200);
assert.deepEqual(sent.at(-1).session.tools, tools);
const names = tools.map((tool) => tool.name);
assert.equal(new Set(names).size, names.length);
assert.deepEqual(
tools.slice(0, GEV_REALTIME_TOOLS.length),
GEV_REALTIME_TOOLS,
);
assert.ok(names.includes('get_weather'));
assert.ok(names.includes('military_awareness'));
assert.ok(!names.includes('show_in_gods_eye_view'));
assert.ok(!names.includes('aircraft_in_area'));
assert.ok(!names.includes('get_weather_map'));
// The action of the same name answers satellite passes.
assert.equal(
tools.findLast((tool) => tool.name === 'next_satellite_pass'),
GEV_REALTIME_TOOLS.find((tool) => tool.name === 'next_satellite_pass'),
);
});
test('debug logging resolves each supplied application directory independently', async (t) => {
const first = root(t),
second = root(t);
@@ -356,8 +391,8 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures',
error: 'Failed to write Realtime debug log',
});
// The limiter is always on, unlike the opt-in one the cost-bearing routes
// share: 120/min per IP, far above what a voice session writes.
// This limiter has no opt-out, unlike the one the cost-bearing routes share:
// 120/min per IP, far above what a voice session writes.
let limited = null;
let accepted = 0;
for (let n = 0; n < 130 && !limited; n += 1) {
@@ -380,6 +415,24 @@ test('the debug-log sink stays bounded, rate limited, and quiet about failures',
assert.ok(lines.every((line) => JSON.parse(line).loggedAt));
});
test('a debug-log record cannot supply its own timestamp', async (t) => {
const sourceRoot = root(t);
const handler = install(openAiRealtimeProxy({ sourceRoot })).get(
'/api/realtime/debug-log',
);
const before = Date.now();
const response = await request(handler, {
method: 'POST',
body: JSON.stringify({ loggedAt: '1999-01-01T00:00:00.000Z', note: 'x' }),
});
assert.equal(response.status, 204);
const file = path.join(sourceRoot, '.gev-logs/realtime-conversations.jsonl');
const [line] = readFileSync(file, 'utf8').split('\n').filter(Boolean);
const record = JSON.parse(line);
assert.equal(record.note, 'x');
assert.ok(Date.parse(record.loggedAt) >= before - 1000);
});
test('an oversized debug-log request receives the fixed error response', async (t) => {
const handler = install(openAiRealtimeProxy({ sourceRoot: root(t) })).get(
'/api/realtime/debug-log',
@@ -434,3 +487,25 @@ test('the debug log rotates instead of growing without bound', async (t) => {
assert.ok(live + previous < 64 * 1024 * 1024);
assert.ok(!existsSync(`${file}.2`), 'exactly one generation is retained');
});
test('the voice instructions name only tools the voice session offers', async () => {
const { coreTools } = await import('../tools/index.js');
const tools = new Set(standaloneVoiceTools().map((tool) => tool.name));
const known = new Set([
...coreTools.map((tool) => tool.name),
...GEV_REALTIME_TOOLS.map((tool) => tool.name),
]);
const named = new Set(
realtimeInstructions().match(/\b[a-z]+(?:_[a-z]+)+\b/g),
);
const missing = [...named].filter(
(name) => known.has(name) && !tools.has(name),
);
assert.deepEqual(missing, []);
});
test('the voice instructions send vessels, however heard, to the ships layer', () => {
const text = realtimeInstructions();
assert.match(text, /"vessels" is easily heard as "visuals"/);
assert.match(text, /set_layer_visibility\{layerId:"ais-live-vessels"\}/);
});
+116
View File
@@ -0,0 +1,116 @@
import assert from 'node:assert/strict';
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import test from 'node:test';
import * as PANEL_PATHS from '../tools/globePanel.js';
import {
PANEL_BASE,
PANEL_WORKER_PRELUDE_PATH,
panelBuildConfig,
panelBuildPlugin,
workerFilesPrelude,
} from '../../build/panel.js';
test('the panel loads the paths the panel build writes', () => {
assert.equal(PANEL_BASE, PANEL_PATHS.PANEL_BASE);
assert.equal(
PANEL_WORKER_PRELUDE_PATH,
PANEL_PATHS.PANEL_WORKER_PRELUDE_PATH,
);
});
test('the panel build is one script and one stylesheet under /panel/', () => {
const config = panelBuildConfig({
plugins: [],
build: { chunkSizeWarningLimit: 1500 },
});
assert.equal(config.base, PANEL_BASE);
assert.equal(config.build.outDir, 'dist/panel');
assert.equal(config.build.cssCodeSplit, false);
assert.equal(config.build.rollupOptions.output.inlineDynamicImports, true);
assert.equal(config.build.chunkSizeWarningLimit, 1500);
});
test('the dev server serves the panel build and nothing outside it', async () => {
const root = await mkdtemp(join(tmpdir(), 'panel-build-'));
try {
await mkdir(join(root, 'out', 'assets'), { recursive: true });
await writeFile(join(root, 'out', 'index.html'), '<p>panel</p>');
await writeFile(join(root, 'out', 'assets', 'a.js'), 'x');
await writeFile(join(root, 'secret.txt'), 'secret');
let middleware;
panelBuildPlugin({ outDir: 'out' }).configureServer({
config: { root },
middlewares: { use: (fn) => (middleware = fn) },
});
const get = (url) =>
new Promise((resolve) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name] = value;
},
end(body) {
resolve({ status: this.statusCode, headers: this.headers, body });
},
};
middleware({ url }, res, () => resolve({ passed: true }));
});
const page = await get('/panel/');
assert.equal(String(page.body), '<p>panel</p>');
assert.equal(page.headers['Content-Type'], 'text/html');
const script = await get('/panel/assets/a.js');
assert.equal(script.headers['Content-Type'], 'text/javascript');
assert.equal((await get('/panel/missing.js')).status, 404);
assert.equal((await get('/panel/%ZZ')).status, 400);
assert.deepEqual(await get('/panel/..%2Fsecret.txt'), { passed: true });
assert.deepEqual(await get('/other'), { passed: true });
} finally {
await rm(root, { recursive: true, force: true });
}
});
test("Cesium's workers get embedded files from memory and other requests from the network", async () => {
const sent = [];
class FakeRequest extends EventTarget {
open(method, url) {
this.url = url;
}
setRequestHeader() {}
send() {
sent.push(this.url);
}
}
const saved = {
XMLHttpRequest: globalThis.XMLHttpRequest,
ProgressEvent: globalThis.ProgressEvent,
};
globalThis.XMLHttpRequest = FakeRequest;
globalThis.ProgressEvent ??= class extends Event {};
try {
new Function(workerFilesPrelude({ 'Assets/heights.json': '{"a":1}' }))();
const embedded = new XMLHttpRequest();
embedded.open(
'GET',
'https://panel.example/panel/cesium/Assets/heights.json?v=1',
);
embedded.setRequestHeader('Accept', 'application/json');
embedded.responseType = 'text';
const loaded = new Promise((resolve) =>
embedded.addEventListener('load', resolve),
);
embedded.send();
await loaded;
assert.equal(embedded.status, 200);
assert.equal(embedded.response, '{"a":1}');
const other = new XMLHttpRequest();
other.open('GET', 'https://tiles.example/1.png');
other.send();
assert.deepEqual(sent, ['https://tiles.example/1.png']);
} finally {
globalThis.XMLHttpRequest = saved.XMLHttpRequest;
globalThis.ProgressEvent = saved.ProgressEvent;
}
});
+61
View File
@@ -482,3 +482,64 @@ test('the upstream host is pinned: a redirect is refused, not followed', async (
await request('/api/route', '?profile=car&coords=-97,30;-97.01,30.01');
assert.deepEqual(seen, ['error'], 'a redirect would escape the pinned host');
});
test('both paid Google routes refuse cross-site requests before calling Google', async (t) => {
const plugin = googlePlacesContextProxy({ resolveApiKey: () => 'key' });
const routes = new Map();
plugin.configureServer({
middlewares: { use: (route, handler) => routes.set(route, handler) },
});
let upstream = 0;
t.mock.method(globalThis, 'fetch', async () => {
upstream += 1;
return Response.json({ places: [] });
});
const call = async (route, url, headers) => {
const res = {
statusCode: 200,
headers: {},
setHeader(name, value) {
this.headers[name.toLowerCase()] = value;
},
writeHead(status) {
this.statusCode = status;
},
end() {},
};
await routes.get(route)(
{
url,
method: 'GET',
headers: { host: 'localhost:4173', ...headers },
socket: { remoteAddress: '127.0.0.1' },
},
res,
);
return res.statusCode;
};
const crossSite = {
origin: 'https://attacker.example',
'sec-fetch-site': 'cross-site',
};
for (const [route, url] of [
['/api/google/nearby-places', '?lat=30&lon=-97'],
['/api/google/text-search', '?q=museum&lat=30&lon=-97'],
]) {
assert.equal(await call(route, url, crossSite), 403, route);
assert.equal(
await call(route, url, { 'sec-fetch-site': 'cross-site' }),
403,
route,
);
}
assert.equal(upstream, 0, 'a refused request never reaches Google');
// The app's own page still gets answers.
assert.equal(
await call('/api/google/text-search', '?q=museum&lat=30&lon=-97', {
origin: 'http://localhost:4173',
'sec-fetch-site': 'same-origin',
}),
200,
);
assert.equal(upstream, 1);
});
+2 -2
View File
@@ -97,8 +97,8 @@ test('real dev and built-preview servers serve provider JSON and terminate unkno
const origin = `http://127.0.0.1:${server.httpServer.address().port}`;
try {
for (const [route, status] of [
['/api/opensky', 200],
['/api/adsblol/mil', 200],
['/api/flights', 200],
['/api/military', 200],
['/api/adsbdb/type/invalid', 400],
['/api/firms/status', 200],
['/api/fire-perimeters/inciweb/publication/invalid', 400],
+160 -18
View File
@@ -1,7 +1,7 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createBrowserViteConfig } from '../../build/vite.js';
import { BROWSER_CSP, createBrowserViteConfig } from '../../build/vite.js';
import standaloneConfig, * as compatibility from '../../vite.config.js';
import * as providers from '../../server/providers/local.js';
@@ -12,29 +12,45 @@ test('explicit build inputs preserve browser-only defines, plugin order and loop
googleApiKey: 'browser-fixture',
cesiumToken: 'ion-fixture',
});
assert.equal(config.plugins[2], plugin);
// The Host check runs before every other plugin's middleware.
assert.equal(config.plugins[0].name, 'host-check');
assert.equal(config.plugins[0].enforce, 'pre');
assert.equal(config.plugins[3], plugin);
assert.equal(config.server.host, 'localhost');
assert.equal(config.server.port, 4173);
assert.deepEqual(config.server.allowedHosts, [
'localhost',
'127.0.0.1',
'.local',
]);
assert.deepEqual(config.server.allowedHosts, ['localhost', '127.0.0.1']);
assert.ok(config.server.fs.deny.includes('**/ENVIRONMENT'));
assert.ok(config.server.fs.deny.includes('.env.*'));
assert.equal(config.server.headers['X-Frame-Options'], 'DENY');
assert.equal(
config.server.headers['Content-Security-Policy'],
assert.equal(config.server.headers['X-Content-Type-Options'], 'nosniff');
const csp = config.server.headers['Content-Security-Policy'];
assert.equal(csp, BROWSER_CSP);
for (const directive of [
"script-src 'self' 'unsafe-eval' blob:",
"object-src 'none'",
"base-uri 'self'",
"frame-ancestors 'none'",
);
]) {
assert.ok(csp.includes(directive), directive);
}
assert.ok(!csp.includes("script-src 'self' 'unsafe-inline'"));
assert.deepEqual(config.preview.headers, config.server.headers);
assert.deepEqual(config.define, {
'import.meta.env.GOOGLE_MAPS_API_KEY': '"browser-fixture"',
'import.meta.env.CESIUM_ION_TOKEN': '"ion-fixture"',
});
assert.equal(
createBrowserViteConfig({ host: '0.0.0.0', port: '4800' }).server
.allowedHosts,
true,
// A wildcard bind keeps the Host-header check; LAN names are explicit.
for (const host of ['0.0.0.0', '::'])
assert.deepEqual(
createBrowserViteConfig({ host, port: '4800' }).server.allowedHosts,
['localhost', '127.0.0.1'],
);
assert.deepEqual(
createBrowserViteConfig({
host: '0.0.0.0',
allowedHosts: ['localhost', '127.0.0.1', 'globe.lan'],
}).server.allowedHosts,
['localhost', '127.0.0.1', 'globe.lan'],
);
assert.equal(
createBrowserViteConfig({ host: '::', port: '4800' }).server.port,
@@ -51,7 +67,10 @@ test('build helper does not discover environment values or construct local provi
config.define['import.meta.env.GOOGLE_MAPS_API_KEY'],
undefined,
);
assert.equal(config.plugins.length, 2);
assert.deepEqual(
config.plugins.slice(3).map((plugin) => plugin.name),
['embed-framing', 'panel-build'],
);
} finally {
if (before === undefined) delete process.env.GOOGLE_MAPS_API_KEY;
else process.env.GOOGLE_MAPS_API_KEY = before;
@@ -63,11 +82,15 @@ 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, -1).map((plugin) => plugin.name),
config.plugins.slice(3, -4).map((plugin) => plugin.name),
providers.localProviderPlugins().map((plugin) => plugin.name),
);
assert.equal(config.plugins.at(-2).name, 'gev-key-setup');
assert.equal(config.plugins.at(-1).name, 'api-not-found');
assert.equal(config.plugins.at(-5).name, 'gev-key-setup');
// The local MCP route follows every provider and precedes the API fallback.
assert.equal(config.plugins.at(-4).name, 'local-mcp');
assert.equal(config.plugins.at(-3).name, 'api-not-found');
assert.equal(config.plugins.at(-2).name, 'embed-framing');
assert.equal(config.plugins.at(-1).name, 'panel-build');
});
test('build export resolves in Node and has no browser fallback', async () => {
@@ -78,3 +101,122 @@ test('build export resolves in Node and has no browser fallback', async () => {
);
assert.deepEqual(pkg.exports['./build/vite'], { node: './build/vite.js' });
});
test('only embed-mode documents may be framed, and only by the allowed ancestors', async () => {
const { embedFramingPlugin, isEmbedDocumentRequest } =
await import('../../build/embed-framing.js');
assert.equal(isEmbedDocumentRequest('/?embed=1'), true);
assert.equal(isEmbedDocumentRequest('/index.html?embed=1#v=2'), true);
assert.equal(isEmbedDocumentRequest('/?embed=0'), false);
assert.equal(isEmbedDocumentRequest('/api/x?embed=1'), false);
assert.equal(isEmbedDocumentRequest('/src/main.js?embed=1'), false);
let middleware;
embedFramingPlugin({ ancestors: 'https://a.example' }).configureServer({
middlewares: { use: (handler) => (middleware = handler) },
});
const response = () => {
const headers = new Map();
return {
headers,
setHeader(name, value) {
headers.set(name.toLowerCase(), value);
return this;
},
};
};
const embedded = response();
middleware({ url: '/?embed=1' }, embedded, () => {});
// The server's protections, written later at send time.
embedded.setHeader('X-Frame-Options', 'DENY');
embedded.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
embedded.setHeader('Content-Type', 'text/html');
assert.deepEqual(Object.fromEntries(embedded.headers), {
'content-security-policy': 'frame-ancestors https://a.example',
'content-type': 'text/html',
});
let open;
embedFramingPlugin({ ancestors: '*' }).configureServer({
middlewares: { use: (handler) => (open = handler) },
});
const anywhere = response();
open({ url: '/?embed=1' }, anywhere, () => {});
anywhere.setHeader('X-Frame-Options', 'DENY');
anywhere.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
assert.deepEqual(Object.fromEntries(anywhere.headers), {});
// A full policy keeps its other directives; only the framing rule changes.
const full = "default-src 'self'; script-src 'self'; frame-ancestors 'none'";
const embeddedFull = response();
middleware({ url: '/?embed=1' }, embeddedFull, () => {});
embeddedFull.setHeader('Content-Security-Policy', full);
assert.equal(
embeddedFull.headers.get('content-security-policy'),
"default-src 'self'; script-src 'self'; frame-ancestors https://a.example",
);
const anywhereFull = response();
open({ url: '/?embed=1' }, anywhereFull, () => {});
anywhereFull.setHeader('Content-Security-Policy', full);
assert.equal(
anywhereFull.headers.get('content-security-policy'),
"default-src 'self'; script-src 'self'",
);
const normal = response();
middleware({ url: '/' }, normal, () => {});
normal.setHeader('X-Frame-Options', 'DENY');
normal.setHeader('Content-Security-Policy', "frame-ancestors 'none'");
assert.deepEqual(Object.fromEntries(normal.headers), {
'x-frame-options': 'DENY',
'content-security-policy': "frame-ancestors 'none'",
});
// Unset, no page may frame even embed-mode documents.
for (const ancestors of ['', ' ']) {
const plugin = embedFramingPlugin({ ancestors });
assert.equal(plugin.name, 'embed-framing');
assert.equal(plugin.configureServer, undefined);
assert.equal(plugin.configurePreviewServer, undefined);
}
});
test('the CSP admits the event media embeds, and no other script origin', async () => {
const { embeddedMediaFrameUrl, resolveEmbeddedMediaSource } =
await import('../../src/data/bhoteKoshiEmbeddedMedia.js');
const directive = (name) =>
BROWSER_CSP.split(';')
.map((part) => part.trim())
.find((part) => part.startsWith(`${name} `))
?.split(/\s+/)
.slice(1) ?? [];
const scripts = directive('script-src');
const frames = directive('frame-src');
// The player API, the Facebook SDK and X's widget script.
for (const origin of [
'https://www.youtube.com',
'https://connect.facebook.net',
'https://platform.twitter.com',
])
assert.ok(scripts.includes(origin), origin);
// Only those three, and never a wildcard or a scheme.
assert.deepEqual(
scripts.filter(
(source) => source.startsWith('http') || source === 'https:',
),
[
'https://www.youtube.com',
'https://connect.facebook.net',
'https://platform.twitter.com',
],
);
assert.ok(!scripts.some((source) => source.includes('*')));
// Every frame the event builds for a YouTube or Facebook source is allowed.
for (const link of [
'https://www.youtube.com/watch?v=dQw4w9WgXcQ',
'https://youtu.be/dQw4w9WgXcQ',
'https://www.facebook.com/facebook/videos/10153231379946729/',
]) {
const source = resolveEmbeddedMediaSource(link);
assert.ok(source, link);
const origin = new URL(embeddedMediaFrameUrl(source, {})).origin;
assert.ok(frames.includes(origin), `${link} -> ${origin}`);
}
// X posts load in X's own frames.
assert.ok(frames.includes('https://platform.twitter.com'));
});
+357
View File
@@ -0,0 +1,357 @@
/**
* The `area` argument shared by location-scoped tools: a bounding box, a
* center point with radius, or a place name resolved through the `places`
* service.
*/
import { ToolError } from './catalog.js';
const EARTH_RADIUS_KM = 6371.0088;
const MAX_RADIUS_KM = 2000;
/** JSON Schema for the `area` argument. */
export const AREA_SCHEMA = Object.freeze({
type: 'object',
description:
'Where to look. Give exactly one of: place (a name such as "Taiwan" or ' +
'"San Francisco Bay"), bbox ([west, south, east, north] in degrees), or ' +
'lat and lon with radius_km.',
properties: {
place: { type: 'string', minLength: 1, maxLength: 200 },
bbox: {
type: 'array',
items: { type: 'number' },
minItems: 4,
maxItems: 4,
},
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
radius_km: { type: 'number', minimum: 0.1, maximum: MAX_RADIUS_KM },
},
additionalProperties: false,
});
/**
* Resolve an `area` argument to `{ label, west, south, east, north, center? }`.
* A box whose west edge exceeds its east edge crosses the antimeridian.
*/
export async function resolveArea(area, { services, signal } = {}) {
const given = ['place', 'bbox', 'lat'].filter((key) => area?.[key] != null);
if (given.length !== 1)
throw new ToolError(
'invalid_arguments',
'area needs exactly one of place, bbox, or lat/lon with radius_km',
);
if (area.bbox) {
const [west, south, east, north] = area.bbox;
if (
[west, east].some((value) => Math.abs(value) > 180) ||
[south, north].some((value) => Math.abs(value) > 90) ||
south >= north
)
throw new ToolError(
'invalid_arguments',
'area.bbox must be [west, south, east, north] with south below north',
);
return { label: 'the requested box', west, south, east, north };
}
if (area.lat != null) {
if (area.lon == null || area.radius_km == null)
throw new ToolError(
'invalid_arguments',
'area with lat also needs lon and radius_km',
);
return circleArea(area.lat, area.lon, area.radius_km);
}
const places = services?.places;
if (!places)
throw new ToolError(
'unsupported',
'Place names cannot be resolved here; give a bbox or lat/lon instead',
);
const place = await places.resolve(area.place, { signal });
if (!place)
throw new ToolError(
'invalid_arguments',
`No place matched "${area.place}"; try a more specific name or a bbox`,
);
return {
label: place.name || area.place,
...place.bounds,
...(place.point ? { point: place.point } : {}),
};
}
/**
* The bounding box of a spherical cap. When the cap reaches a pole it spans
* every longitude; otherwise the longitude span is the exact tangent bound,
* asin(sin(d) / cos(lat)) for angular radius d.
*/
function circleArea(lat, lon, radiusKm) {
const angular = radiusKm / EARTH_RADIUS_KM;
const latSpan = angular * (180 / Math.PI);
const reachesPole = lat + latSpan >= 90 || lat - latSpan <= -90;
const ratio = Math.sin(angular) / Math.cos((lat * Math.PI) / 180);
const lonSpan =
reachesPole || ratio >= 1 ? 180 : Math.asin(ratio) * (180 / Math.PI);
const wrap = (value) => ((((value + 180) % 360) + 360) % 360) - 180;
return {
label: `${radiusKm} km around ${lat.toFixed(3)}, ${lon.toFixed(3)}`,
west: lonSpan >= 180 ? -180 : wrap(lon - lonSpan),
east: lonSpan >= 180 ? 180 : wrap(lon + lonSpan),
south: Math.max(-90, lat - latSpan),
north: Math.min(90, lat + latSpan),
center: { lat, lon, radiusKm },
};
}
/** JSON Schema for a single location: a place name, or lat and lon. */
export const POINT_SCHEMA = Object.freeze({
type: 'object',
description: 'A place name, or lat and lon.',
properties: {
place: { type: 'string', minLength: 1, maxLength: 200 },
lat: { type: 'number', minimum: -90, maximum: 90 },
lon: { type: 'number', minimum: -180, maximum: 180 },
},
additionalProperties: false,
});
/** Resolve a point argument to `{ label, lat, lon }`. */
export async function resolvePoint(point, { services, signal } = {}) {
if (point?.place != null && point.lat == null && point.lon == null) {
const area = await resolveArea(
{ place: point.place },
{ services, signal },
);
return { label: area.label, ...areaCenter(area) };
}
if (point?.place == null && point?.lat != null && point?.lon != null)
return {
label: `${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`,
lat: point.lat,
lon: point.lon,
};
throw new ToolError(
'invalid_arguments',
'A location needs either place, or lat and lon',
);
}
/** Distance in kilometers from an area's center to its farthest edge point. */
export function areaRadiusKm(area) {
if (area.center) return area.center.radiusKm;
const center = areaCenter(area);
// A box holding the point opposite its center reaches half the globe.
const antipode = {
lat: -center.lat,
lon: center.lon > 0 ? center.lon - 180 : center.lon + 180,
};
if (areaContains(area, antipode)) return Math.PI * EARTH_RADIUS_KM;
// Otherwise the farthest point is on the boundary: sample each edge, and
// include where each parallel edge comes nearest the antipode's longitude.
const width =
area.west <= area.east
? area.east - area.west
: 360 - (area.west - area.east);
const lonAt = (t) => area.west + width * t;
const steps = 64;
const points = [];
for (let step = 0; step <= steps; step += 1) {
const t = step / steps;
const lat = area.south + (area.north - area.south) * t;
points.push(
{ lat, lon: area.west },
{ lat, lon: area.east },
{ lat: area.north, lon: lonAt(t) },
{ lat: area.south, lon: lonAt(t) },
);
}
const offset = (((antipode.lon - area.west) % 360) + 360) % 360;
if (offset <= width)
points.push(
{ lat: area.north, lon: antipode.lon },
{ lat: area.south, lon: antipode.lon },
);
return Math.max(...points.map((point) => distanceKm(center, point)));
}
/** Great-circle distance in kilometers. */
export function distanceKm(a, b) {
const toRad = Math.PI / 180;
const dLat = (b.lat - a.lat) * toRad;
const dLon = (b.lon - a.lon) * toRad;
const h =
Math.sin(dLat / 2) ** 2 +
Math.cos(a.lat * toRad) * Math.cos(b.lat * toRad) * Math.sin(dLon / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
/**
* The center of a resolved area: a radius area's center, a named place's own
* point, or the middle of a box (handling boxes across the antimeridian).
*/
export function areaCenter(area) {
if (area.center) return { lat: area.center.lat, lon: area.center.lon };
if (area.point) return { lat: area.point.lat, lon: area.point.lon };
const width =
area.west <= area.east
? area.east - area.west
: area.east + 360 - area.west;
const lon = area.west + width / 2;
return {
lat: (area.south + area.north) / 2,
lon: lon > 180 ? lon - 360 : lon,
};
}
/** Whether a point lies inside a resolved area, including radius areas. */
export function areaContains(area, point) {
if (!Number.isFinite(point?.lat) || !Number.isFinite(point?.lon))
return false;
if (point.lat < area.south || point.lat > area.north) return false;
const inLongitude =
area.west <= area.east
? point.lon >= area.west && point.lon <= area.east
: point.lon >= area.west || point.lon <= area.east;
if (!inLongitude) return false;
return area.center
? distanceKm(area.center, point) <= area.center.radiusKm
: true;
}
/** Whether a straight lon/lat segment meets a plain box (Liang–Barsky). */
function segmentMeetsBox(a, b, west, south, east, north) {
const dx = b.lon - a.lon;
const dy = b.lat - a.lat;
let low = 0;
let high = 1;
for (const [p, q] of [
[-dx, a.lon - west],
[dx, east - a.lon],
[-dy, a.lat - south],
[dy, north - a.lat],
]) {
if (p === 0) {
if (q < 0) return false;
} else {
const t = q / p;
if (p < 0) low = Math.max(low, t);
else high = Math.min(high, t);
if (low > high) return false;
}
}
return true;
}
/** Whether a segment meets an area's bounding box, across the antimeridian too. */
function segmentMeetsBounds(a, b, area) {
// A segment longer than half the globe in longitude crosses the antimeridian.
const end =
Math.abs(b.lon - a.lon) > 180
? { lat: b.lat, lon: b.lon + (b.lon < a.lon ? 360 : -360) }
: b;
const boxes =
area.west <= area.east
? [[area.west, area.east]]
: [
[area.west, 180],
[-180, area.east],
];
return boxes.some(([west, east]) =>
[-360, 0, 360].some((shift) =>
segmentMeetsBox(
a,
end,
west + shift,
area.south,
east + shift,
area.north,
),
),
);
}
/** Closest great-circle distance in km from `point` to the segment a–b. */
function segmentDistanceKm(point, a, b) {
const toRad = Math.PI / 180;
const bearing = (from, to) => {
const lat1 = from.lat * toRad;
const lat2 = to.lat * toRad;
const dLon = (to.lon - from.lon) * toRad;
return Math.atan2(
Math.sin(dLon) * Math.cos(lat2),
Math.cos(lat1) * Math.sin(lat2) -
Math.sin(lat1) * Math.cos(lat2) * Math.cos(dLon),
);
};
const toPoint = distanceKm(a, point) / EARTH_RADIUS_KM;
const length = distanceKm(a, b) / EARTH_RADIUS_KM;
const angle = bearing(a, point) - bearing(a, b);
if (length === 0 || Math.cos(angle) <= 0) return distanceKm(a, point);
const cross = Math.asin(Math.sin(toPoint) * Math.sin(angle));
const along = Math.acos(
Math.min(1, Math.cos(toPoint) / Math.max(1e-12, Math.cos(cross))),
);
if (along >= length) return distanceKm(b, point);
return Math.abs(cross) * EARTH_RADIUS_KM;
}
/**
* Whether a line of `[lon, lat]` vertices passes through an area, including
* segments that cross it with no vertex inside.
*/
export function lineTouchesArea(coords, area) {
const points = coords
.map(([lon, lat]) => ({ lat, lon }))
.filter(
(point) => Number.isFinite(point.lat) && Number.isFinite(point.lon),
);
if (points.some((point) => areaContains(area, point))) return true;
for (let index = 1; index < points.length; index += 1) {
const a = points[index - 1];
const b = points[index];
if (!segmentMeetsBounds(a, b, area)) continue;
if (
!area.center ||
segmentDistanceKm(area.center, a, b) <= area.center.radiusKm
)
return true;
}
return false;
}
/** Whether a point lies inside a ring of `[lon, lat]` vertices (ray casting). */
function ringContains(ring, point) {
let inside = false;
for (let i = 0, j = ring.length - 1; i < ring.length; j = i, i += 1) {
const [xi, yi] = ring[i];
const [xj, yj] = ring[j];
if (
yi > point.lat !== yj > point.lat &&
point.lon < ((xj - xi) * (point.lat - yi)) / (yj - yi) + xi
)
inside = !inside;
}
return inside;
}
/**
* Whether polygons (each a list of `[lon, lat]` rings, outer ring first)
* overlap an area: a vertex inside it, an edge crossing it, or the area's
* center inside a polygon and outside its holes.
*/
export function polygonsTouchArea(polygons, area) {
const center = areaCenter(area);
return (polygons || []).some((rings) => {
if (!Array.isArray(rings) || !rings.length) return false;
const closed = rings.map((ring) =>
ring.length && ring[0] !== ring.at(-1) ? [...ring, ring[0]] : ring,
);
if (closed.some((ring) => lineTouchesArea(ring, area))) return true;
return (
ringContains(rings[0], center) &&
!rings.slice(1).some((hole) => ringContains(hole, center))
);
});
}
+264
View File
@@ -0,0 +1,264 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
areaCenter,
areaContains,
areaRadiusKm,
distanceKm,
lineTouchesArea,
polygonsTouchArea,
resolveArea,
} from './area.js';
import { placeFromGeocodeResult } from './places.js';
test('an area needs exactly one form', async () => {
for (const area of [
{},
{ place: 'Oslo', bbox: [0, 0, 1, 1] },
{ lat: 1, lon: 2 },
]) {
await assert.rejects(
resolveArea(area),
(error) => error.code === 'invalid_arguments',
);
}
await assert.rejects(
resolveArea({ bbox: [0, 10, 1, 5] }),
/south below north/,
);
});
test('boxes, including antimeridian boxes, contain the expected points', async () => {
const box = await resolveArea({ bbox: [-10, 40, 10, 50] });
assert.equal(areaContains(box, { lat: 45, lon: 0 }), true);
assert.equal(areaContains(box, { lat: 45, lon: 20 }), false);
const pacific = await resolveArea({ bbox: [170, -20, -170, 0] });
assert.equal(areaContains(pacific, { lat: -10, lon: 179 }), true);
assert.equal(areaContains(pacific, { lat: -10, lon: -175 }), true);
assert.equal(areaContains(pacific, { lat: -10, lon: 0 }), false);
assert.equal(areaContains(box, { lat: Number.NaN, lon: 0 }), false);
});
test('radius areas use great-circle distance', async () => {
const circle = await resolveArea({ lat: 37.77, lon: -122.42, radius_km: 50 });
assert.equal(circle.center.radiusKm, 50);
assert.equal(areaContains(circle, { lat: 37.8, lon: -122.27 }), true);
assert.equal(areaContains(circle, { lat: 38.58, lon: -121.49 }), false);
assert.ok(
Math.abs(distanceKm({ lat: 0, lon: 0 }, { lat: 0, lon: 1 }) - 111.2) < 0.1,
);
// A circle across the antimeridian wraps its box.
const dateline = await resolveArea({ lat: 0, lon: 179.9, radius_km: 100 });
assert.ok(dateline.west > dateline.east);
assert.equal(areaContains(dateline, { lat: 0, lon: -179.9 }), true);
});
test('place names resolve through the places service', async () => {
const places = {
resolve: async (name) =>
name === 'Taiwan'
? {
name: 'Taiwan',
bounds: { west: 119, south: 21, east: 123, north: 26 },
}
: null,
};
const taiwan = await resolveArea(
{ place: 'Taiwan' },
{ services: { places } },
);
assert.deepEqual(taiwan, {
label: 'Taiwan',
west: 119,
south: 21,
east: 123,
north: 26,
});
await assert.rejects(
resolveArea({ place: 'Nowhere' }, { services: { places } }),
/No place matched "Nowhere"/,
);
await assert.rejects(
resolveArea({ place: 'Taiwan' }),
(error) => error.code === 'unsupported',
);
});
test('geocode results become bounded places', () => {
assert.deepEqual(
placeFromGeocodeResult({
formatted_address: 'Oslo, Norway',
geometry: {
location: { lat: 59.9, lng: 10.7 },
viewport: {
southwest: { lat: 59.8, lng: 10.5 },
northeast: { lat: 60, lng: 10.9 },
},
},
}),
{
name: 'Oslo, Norway',
bounds: { west: 10.5, south: 59.8, east: 10.9, north: 60 },
point: { lat: 59.9, lon: 10.7 },
},
);
assert.deepEqual(
placeFromGeocodeResult({ geometry: { location: { lat: 0, lng: 179.9 } } })
.bounds,
{ west: 179.65, south: -0.25, east: 180, north: 0.25 },
);
assert.equal(placeFromGeocodeResult({ geometry: {} }), null);
});
test('a named place centers on its own point, not the middle of its bounds', async () => {
// Tokyo's prefecture bounds reach remote Pacific islands.
const places = {
resolve: async () => ({
name: 'Tokyo, Japan',
bounds: { west: 136.07, south: 20.21, east: 153.99, north: 35.9 },
point: { lat: 35.6769, lon: 139.7639 },
}),
};
const tokyo = await resolveArea({ place: 'Tokyo' }, { services: { places } });
assert.deepEqual(areaCenter(tokyo), { lat: 35.6769, lon: 139.7639 });
assert.equal(areaContains(tokyo, { lat: 27.09, lon: 142.19 }), true);
const box = await resolveArea({ bbox: [0, 0, 10, 10] });
assert.deepEqual(areaCenter(box), { lat: 5, lon: 5 });
});
test('radius areas include every point within the radius, near the poles too', async () => {
const polar = await resolveArea({ lat: 80, lon: 0, radius_km: 1500 });
const across = { lat: 89, lon: 180 };
assert.ok(distanceKm({ lat: 80, lon: 0 }, across) < 1500);
assert.ok(areaContains(polar, across));
assert.equal(polar.west, -180);
assert.equal(polar.east, 180);
assert.equal(polar.north, 90);
// Away from the poles, sample the circle's edge and check each point is kept.
const center = { lat: 60, lon: 20 };
const wide = await resolveArea({ ...center, radius_km: 2000 });
assert.ok(wide.east - wide.west < 360);
for (let bearing = 0; bearing < 360; bearing += 5) {
const d = 1999 / 6371.0088;
const b = (bearing * Math.PI) / 180;
const lat1 = (center.lat * Math.PI) / 180;
const lat2 = Math.asin(
Math.sin(lat1) * Math.cos(d) + Math.cos(lat1) * Math.sin(d) * Math.cos(b),
);
const lon2 =
(center.lon * Math.PI) / 180 +
Math.atan2(
Math.sin(b) * Math.sin(d) * Math.cos(lat1),
Math.cos(d) - Math.sin(lat1) * Math.sin(lat2),
);
const point = { lat: (lat2 * 180) / Math.PI, lon: (lon2 * 180) / Math.PI };
assert.ok(areaContains(wide, point), `bearing ${bearing}`);
}
});
test('lines touch an area when a segment crosses it with no vertex inside', async () => {
const box = await resolveArea({ bbox: [-1, -1, 1, 1] });
assert.ok(
lineTouchesArea(
[
[-2, 0],
[2, 0],
],
box,
),
);
assert.ok(
!lineTouchesArea(
[
[-2, 2],
[2, 2],
],
box,
),
);
const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 50 });
assert.ok(
lineTouchesArea(
[
[-2, 0.3],
[2, 0.3],
],
circle,
),
);
// Inside the circle's box but beyond its radius.
assert.ok(
!lineTouchesArea(
[
[0.3, 0.42],
[0.6, 0.42],
],
circle,
),
);
const dateline = await resolveArea({ bbox: [179, -1, -179, 1] });
assert.ok(
lineTouchesArea(
[
[178, 0],
[-178, 0],
],
dateline,
),
);
const nearDateline = await resolveArea({ bbox: [179.5, -1, 179.9, 1] });
assert.ok(
lineTouchesArea(
[
[179, 0],
[-179, 0],
],
nearDateline,
),
);
assert.ok(
!lineTouchesArea(
[
[170, 0],
[175, 0],
],
nearDateline,
),
);
});
test('polygons touch an area by vertex, crossing edge or enclosing it', async () => {
const box = await resolveArea({ bbox: [-1, -1, 1, 1] });
const square = (half, x = 0) => [
[
[x - half, -half],
[x + half, -half],
[x + half, half],
[x - half, half],
],
];
assert.ok(polygonsTouchArea([square(5)], box), 'encloses the area');
assert.ok(polygonsTouchArea([square(0.5)], box), 'inside the area');
assert.ok(polygonsTouchArea([square(1, 1.5)], box), 'overlaps an edge');
assert.ok(!polygonsTouchArea([square(0.5, 5)], box), 'elsewhere');
const ring = [...square(5), ...square(3)];
assert.ok(!polygonsTouchArea([ring], box), 'the area sits in a hole');
const circle = await resolveArea({ lat: 0, lon: 0, radius_km: 20 });
assert.ok(polygonsTouchArea([square(5)], circle));
});
test('a box radius reaches every point of the box, however large', async () => {
const world = await resolveArea({ bbox: [-180, -90, 180, 90] });
assert.ok(Math.abs(areaRadiusKm(world) - Math.PI * 6371.0088) < 1);
const wide = await resolveArea({ bbox: [-60, -70, 100, 60] });
const radius = areaRadiusKm(wide);
const center = areaCenter(wide);
for (let lat = -70; lat <= 60; lat += 2.5)
for (let lon = -60; lon <= 100; lon += 2.5)
assert.ok(
distanceKm(center, { lat, lon }) <= radius + 0.5,
`${lat},${lon}`,
);
const small = await resolveArea({ bbox: [-97.94, 30.1, -97.56, 30.52] });
assert.ok(areaRadiusKm(small) < 30);
});
+226
View File
@@ -0,0 +1,226 @@
/**
* Tool definitions and their composition into a callable catalog.
*
* A tool declares what it does, its arguments, and which services it reads.
* It knows nothing about the surface that exposes it (MCP, voice) or the
* transport its services use. Applications compose the tools they want with
* the services they supply, plus optional interceptors that wrap every call.
*/
import { assertSupportedSchema, validateValue } from './schema.js';
const TOOL_NAME = /^[a-z][a-z0-9_]{0,63}$/;
const KINDS = new Set(['query', 'action']);
/** Who may call a tool, as MCP Apps names them: the model, the app's panel. */
const VISIBILITY = new Set(['model', 'app']);
/** Error codes a tool may report. Each is safe to show to a model or person. */
export const TOOL_ERROR_CODES = Object.freeze([
'invalid_arguments',
'unavailable',
'unsupported',
'malformed',
'retry_later',
]);
/** An expected failure with a stable code and a message written for the caller. */
export class ToolError extends Error {
constructor(code, message, { retryAfterSeconds = null } = {}) {
if (!TOOL_ERROR_CODES.includes(code))
throw new TypeError(`Unknown tool error code: ${code}`);
super(message);
this.name = 'ToolError';
this.code = code;
this.retryAfterSeconds = retryAfterSeconds;
}
}
// Live sources report failures with these codes (src/sources/live/contract.js).
const SOURCE_ERROR_CODES = {
limited: 'retry_later',
denied: 'unavailable',
unavailable: 'unavailable',
malformed: 'malformed',
unsupported: 'unsupported',
};
/** Translate a source failure into a tool error; other errors pass through. */
export function fromSourceError(error) {
const code =
error?.name === 'LiveSourceError' &&
Object.hasOwn(SOURCE_ERROR_CODES, error.code)
? SOURCE_ERROR_CODES[error.code]
: null;
if (!code) return error;
const retryAfterSeconds =
code === 'retry_later' && Number.isFinite(error.retryAfterMs)
? Math.ceil(error.retryAfterMs / 1000)
: null;
return new ToolError(code, error.message, { retryAfterSeconds });
}
/**
* Validate and freeze a tool definition.
*
* `run(args, { services, signal, tools })` resolves to `{ summary, data }`: a
* short sentence for people and a structured object for programs. It may add
* `images`, each `{ mimeType, data }` with base64 data. `tools` reaches other
* tools through the same catalog: `tools.has(name)` and
* `tools.call(name, args)`.
*/
export function defineTool({
name,
kind = 'query',
title,
description,
inputSchema,
annotations = {},
requires = [],
ui = null,
run,
}) {
if (!TOOL_NAME.test(String(name)))
throw new TypeError(`Invalid tool name: ${name}`);
if (!KINDS.has(kind)) throw new TypeError(`Invalid tool kind: ${kind}`);
if (typeof title !== 'string' || !title)
throw new TypeError(`${name} needs a title`);
if (typeof description !== 'string' || !description)
throw new TypeError(`${name} needs a description`);
assertSupportedSchema(inputSchema, `${name}.inputSchema`);
if (inputSchema.type !== 'object')
throw new TypeError(`${name}.inputSchema must describe an object`);
if (
!Array.isArray(requires) ||
requires.some((key) => typeof key !== 'string')
)
throw new TypeError(`${name}.requires must list service names`);
if (typeof run !== 'function') throw new TypeError(`${name} needs run()`);
if (ui !== null) {
if (
ui?.resourceUri !== undefined &&
!(
typeof ui.resourceUri === 'string' && ui.resourceUri.startsWith('ui://')
)
)
throw new TypeError(`${name}.ui.resourceUri must be a ui:// URI`);
if (
ui?.visibility !== undefined &&
!(
Array.isArray(ui.visibility) &&
ui.visibility.length > 0 &&
ui.visibility.every((who) => VISIBILITY.has(who))
)
)
throw new TypeError(`${name}.ui.visibility must list model and/or app`);
if (ui?.resourceUri === undefined && ui?.visibility === undefined)
throw new TypeError(`${name}.ui needs a resourceUri or a visibility`);
}
return Object.freeze({
name,
kind,
title,
description,
inputSchema: structuredClone(inputSchema),
annotations: Object.freeze({
readOnlyHint: kind === 'query',
...annotations,
}),
requires: Object.freeze([...requires]),
ui: ui
? Object.freeze({
...(ui.resourceUri ? { resourceUri: ui.resourceUri } : {}),
...(ui.visibility
? { visibility: Object.freeze([...ui.visibility]) }
: {}),
})
: null,
run,
});
}
/**
* Compose tools with the services an application supplies.
*
* - Duplicate names fail unless the later tool is listed in `replace`.
* - Tools whose required services are missing are left out.
* - Interceptors wrap every call in order, outermost first, as
* `(call, next) => next(call)` where `call` is `{ tool, args, signal }`,
* plus `parent`, the calling tool's name, when one tool calls another.
*/
export function composeCatalog({
tools,
replace = [],
services = {},
interceptors = [],
}) {
const byName = new Map();
const replaceable = new Set(replace);
for (const tool of tools) {
if (byName.has(tool.name) && !replaceable.has(tool.name))
throw new TypeError(`Duplicate tool name: ${tool.name}`);
byName.set(tool.name, tool);
}
for (const name of replaceable) {
if (!byName.has(name))
throw new TypeError(`Cannot replace unknown tool: ${name}`);
}
const available = [...byName.values()].filter((tool) =>
tool.requires.every((key) => services[key] != null),
);
const index = new Map(available.map((tool) => [tool.name, tool]));
const invoke = (name, args, signal, parent) => {
const tool = index.get(name);
if (!tool)
return Promise.reject(
new ToolError('unsupported', `No tool named ${name} is available`),
);
return chain({ tool, args, signal, ...(parent ? { parent } : {}) });
};
const execute = async ({ tool, args, signal }) => {
const problems = validateValue(tool.inputSchema, args);
if (problems.length)
throw new ToolError('invalid_arguments', problems.join('; '));
let result;
try {
result = await tool.run(args, {
services,
signal,
tools: {
has: (name) => index.has(name),
call: (name, nested = {}) => invoke(name, nested, signal, tool.name),
},
});
} catch (error) {
throw fromSourceError(error);
}
if (!result || typeof result.summary !== 'string' || !result.data)
throw new TypeError(`${tool.name} returned no summary or data`);
if (
result.images !== undefined &&
(!Array.isArray(result.images) ||
!result.images.every(
(item) =>
typeof item?.mimeType === 'string' &&
typeof item?.data === 'string',
))
)
throw new TypeError(`${tool.name} returned malformed images`);
return result;
};
const chain = interceptors.reduceRight(
(next, interceptor) => (call) => interceptor(call, next),
execute,
);
return Object.freeze({
/** Available tool definitions, in composition order. */
list: () => [...available],
/** The available tool with this name, or undefined. */
get: (name) => index.get(name),
/** Validate arguments and run a tool through the interceptors. */
async call(name, args = {}, { signal } = {}) {
return invoke(name, args, signal);
},
});
}
+234
View File
@@ -0,0 +1,234 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, defineTool, ToolError } from './catalog.js';
import { validateValue } from './schema.js';
const echo = (name, extra = {}) =>
defineTool({
name,
title: `Echo ${name}`,
description: 'Returns its arguments.',
inputSchema: {
type: 'object',
properties: { word: { type: 'string', maxLength: 5 } },
additionalProperties: false,
},
run: async (args) => ({ summary: name, data: { args } }),
...extra,
});
test('definitions are validated, frozen and read-only by default', () => {
const tool = echo('echo');
assert.equal(Object.isFrozen(tool), true);
assert.equal(tool.kind, 'query');
assert.deepEqual(tool.annotations, { readOnlyHint: true });
assert.equal(echo('act', { kind: 'action' }).annotations.readOnlyHint, false);
assert.throws(() => echo('Bad-Name'), /Invalid tool name/);
assert.throws(() => echo('x', { kind: 'other' }), /Invalid tool kind/);
assert.throws(() => echo('x', { title: '' }), /needs a title/);
assert.throws(
() =>
echo('x', {
inputSchema: {
type: 'object',
properties: { a: { type: 'string', format: 'email' } },
},
}),
/unsupported keyword format/,
);
assert.throws(
() => echo('x', { inputSchema: { type: 'string' } }),
/must describe an object/,
);
});
test('composition rejects duplicates unless replaced, and hides tools without services', () => {
const first = echo('echo');
const second = echo('echo', { description: 'Replacement.' });
assert.throws(
() => composeCatalog({ tools: [first, second] }),
/Duplicate tool name: echo/,
);
assert.throws(
() => composeCatalog({ tools: [first], replace: ['other'] }),
/Cannot replace unknown tool/,
);
const replaced = composeCatalog({
tools: [first, second],
replace: ['echo'],
});
assert.equal(replaced.get('echo').description, 'Replacement.');
const needsFeed = echo('feed', { requires: ['feed'] });
assert.deepEqual(
composeCatalog({ tools: [first, needsFeed] })
.list()
.map((tool) => tool.name),
['echo'],
);
assert.deepEqual(
composeCatalog({ tools: [first, needsFeed], services: { feed: {} } })
.list()
.map((tool) => tool.name),
['echo', 'feed'],
);
});
test('calls validate arguments and pass through interceptors outermost first', async () => {
const order = [];
const catalog = composeCatalog({
tools: [echo('echo')],
interceptors: [
async (call, next) => {
order.push(`outer:${call.tool.name}`);
const result = await next(call);
order.push('outer:done');
return result;
},
(call, next) => {
order.push('inner');
return next({ ...call, args: { word: 'hi' } });
},
],
});
const result = await catalog.call('echo', { word: 'ignored' });
assert.deepEqual(result, { summary: 'echo', data: { args: { word: 'hi' } } });
assert.deepEqual(order, ['outer:echo', 'inner', 'outer:done']);
await assert.rejects(
composeCatalog({ tools: [echo('echo')] }).call('echo', {
word: 'toolong',
extra: 1,
}),
(error) =>
error instanceof ToolError &&
error.code === 'invalid_arguments' &&
/arguments\.word must be at most 5 characters/.test(error.message) &&
/unknown property extra/.test(error.message),
);
await assert.rejects(
composeCatalog({ tools: [] }).call('missing'),
(error) => error.code === 'unsupported',
);
});
test('a tool that returns no summary is a programming error, not a tool error', async () => {
const broken = echo('broken', { run: async () => ({ data: {} }) });
await assert.rejects(
composeCatalog({ tools: [broken] }).call('broken'),
(error) => error instanceof TypeError && !(error instanceof ToolError),
);
});
test('tool errors only use known codes', () => {
assert.throws(() => new ToolError('nope', 'x'), /Unknown tool error code/);
assert.equal(
new ToolError('retry_later', 'x', { retryAfterSeconds: 5 })
.retryAfterSeconds,
5,
);
});
test('only live source errors are translated', async () => {
const { fromSourceError } = await import('./catalog.js');
const plain = new Error('x');
assert.equal(fromSourceError(plain), plain);
const denied = Object.assign(new Error('refused'), {
name: 'LiveSourceError',
code: 'denied',
});
const translated = fromSourceError(denied);
assert.equal(translated.code, 'unavailable');
assert.equal(translated.retryAfterSeconds, null);
const odd = Object.assign(new Error('?'), {
name: 'LiveSourceError',
code: 'toString',
});
assert.equal(fromSourceError(odd), odd);
});
test('patterns are validated and must compile', () => {
const schema = {
type: 'object',
properties: { id: { type: 'string', pattern: '^[a-f]{2}$' } },
};
assert.deepEqual(validateValue(schema, { id: 'ab' }), []);
assert.deepEqual(validateValue(schema, { id: 'xy' }), [
'arguments.id must match ^[a-f]{2}$',
]);
assert.throws(
() =>
echo('x', {
inputSchema: {
type: 'object',
properties: { id: { type: 'string', pattern: '(' } },
},
}),
SyntaxError,
);
});
test('the validator checks every supported keyword', () => {
const schema = {
type: 'object',
required: ['n'],
properties: {
n: { type: 'integer', minimum: 1, maximum: 3 },
list: {
type: 'array',
minItems: 1,
maxItems: 2,
items: { type: 'number' },
},
mode: { type: 'string', enum: ['a', 'b'] },
flag: { type: 'boolean' },
},
};
assert.deepEqual(
validateValue(schema, { n: 2, list: [1], mode: 'a', flag: true }),
[],
);
assert.deepEqual(
validateValue(schema, { n: 1.5, list: [], mode: 'c', flag: 'yes' }),
[
'arguments.n must be an integer',
'arguments.list must have at least 1 items',
'arguments.mode must be one of "a", "b"',
'arguments.flag must be true or false',
],
);
assert.deepEqual(validateValue(schema, {}), ['arguments is missing n']);
assert.deepEqual(validateValue(schema, []), ['arguments must be an object']);
assert.deepEqual(validateValue(schema, { n: Number.NaN }), [
'arguments.n must be a number',
]);
});
test('inherited names are not declared properties, and schema-valued additionalProperties is refused', () => {
const schema = {
type: 'object',
properties: { name: { type: 'string' } },
additionalProperties: false,
};
for (const name of ['constructor', 'toString', '__proto__'])
assert.deepEqual(validateValue(schema, JSON.parse(`{"${name}": 1}`)), [
`arguments has unknown property ${name}`,
]);
assert.deepEqual(validateValue({ ...schema, required: ['toString'] }, {}), [
'arguments is missing toString',
]);
assert.throws(
() =>
defineTool({
name: 'loose',
title: 'Loose',
description: 'Promises checks it cannot make.',
inputSchema: {
type: 'object',
additionalProperties: { type: 'string' },
},
run: async () => ({ summary: '', data: {} }),
}),
/additionalProperties must be true or false/,
);
});
+28
View File
@@ -0,0 +1,28 @@
/** Function-calling adapter for a tool catalog: tool records and results. */
/** Tools as `{ type: 'function', name, description, parameters }` records. */
export function toFunctionTools(tools, { exclude = [] } = {}) {
const skipped = new Set(exclude);
return tools
.filter((tool) => !skipped.has(tool.name))
.map((tool) => ({
type: 'function',
name: tool.name,
description: tool.description,
parameters: structuredClone(tool.inputSchema),
}));
}
/**
* A tool result as one JSON function output. Images are counted rather than
* sent, since function outputs carry text.
*/
export function toFunctionOutput(name, result) {
return {
ok: true,
tool: name,
summary: result.summary,
data: result.data,
...(result.images?.length ? { images_omitted: result.images.length } : {}),
};
}
+50
View File
@@ -0,0 +1,50 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { coreTools, toFunctionOutput, toFunctionTools } from './index.js';
test('catalog tools become independent function records', () => {
const records = toFunctionTools(coreTools, {
exclude: ['show_in_gods_eye_view'],
});
assert.equal(records.length, coreTools.length - 1);
assert.ok(!records.some((tool) => tool.name === 'show_in_gods_eye_view'));
const weather = records.find((tool) => tool.name === 'get_weather');
const source = coreTools.find((tool) => tool.name === 'get_weather');
assert.deepEqual(Object.keys(weather), [
'type',
'name',
'description',
'parameters',
]);
assert.equal(weather.type, 'function');
assert.equal(weather.description, source.description);
assert.deepEqual(weather.parameters, source.inputSchema);
assert.notEqual(weather.parameters, source.inputSchema);
weather.parameters.properties.location.description = 'changed';
assert.notEqual(
source.inputSchema.properties.location.description,
'changed',
);
assert.ok(records.every((tool) => tool.parameters.type === 'object'));
});
test('results become function outputs without image data', () => {
assert.deepEqual(
toFunctionOutput('get_wind', { summary: 'Calm.', data: { calm: true } }),
{ ok: true, tool: 'get_wind', summary: 'Calm.', data: { calm: true } },
);
assert.deepEqual(
toFunctionOutput('get_weather_map', {
summary: 'Radar.',
data: {},
images: [{ mimeType: 'image/png', data: 'AAAA' }],
}),
{
ok: true,
tool: 'get_weather_map',
summary: 'Radar.',
data: {},
images_omitted: 1,
},
);
});
+132
View File
@@ -0,0 +1,132 @@
/**
* The God's Eye View panel: an MCP Apps view (`io.modelcontextprotocol/ui`) that shows
* God's Eye View inside a conversation. The panel is a small page that runs
* the app's panel build inside itself in embed mode, loading it through the
* MCP server, and sends it each view the show_in_gods_eye_view tool returns;
* see docs/TOOLS.md.
*/
export const GLOBE_PANEL_URI = 'ui://gods-eye-view/globe';
/** Where the app's server serves its panel build (see build/panel.js). */
export const PANEL_BASE = '/panel/';
/** The script the panel runs ahead of Cesium's workers, in the build. */
export const PANEL_WORKER_PRELUDE_PATH = 'cesium/worker-prelude.js';
/**
* Sites the app's browser code loads from directly: map imagery, 3D tiles,
* terrain and fonts. Everything from the app's own server comes through the
* MCP server.
*/
const PROVIDER_ORIGINS = Object.freeze([
'https://tile.googleapis.com',
'https://maps.googleapis.com',
'https://api.cesium.com',
'https://assets.ion.cesium.com',
'https://assets.cesium.com',
'https://dev.virtualearth.net',
'https://ecn.t0.tiles.virtualearth.net',
'https://ecn.t1.tiles.virtualearth.net',
'https://ecn.t2.tiles.virtualearth.net',
'https://ecn.t3.tiles.virtualearth.net',
'https://services.arcgisonline.com',
'https://server.arcgisonline.com',
'https://tile.openstreetmap.org',
'https://tiles.openfreemap.org',
'https://gibs.earthdata.nasa.gov',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com',
]);
export const MCP_APP_MIME_TYPE = 'text/html;profile=mcp-app';
const MCP_APPS_PROTOCOL_VERSION = '2026-01-26';
const PANEL_HEIGHT_PX = 520;
const LOAD_TIMEOUT_MS = 90_000;
/**
* The app's address inside the panel, for code that needs an https address
* (the page's own may use a host's scheme). The panel loads its paths
* through the MCP server; the name never resolves.
*/
const PANEL_APP_BASE_URL = 'https://app.gods-eye-view.invalid/';
/** The panel-only tool the panel loads the app through. */
export const PANEL_REQUEST_TOOL = 'panel_request';
/**
* The panel page: its status line, the Open in God's Eye View button, and
* the panel's script, `runtime`, which loads the app through the MCP server
* and shows each view a tool returns.
*/
function panelHtml(runtime, panelKey) {
const config = {
appBaseUrl: PANEL_APP_BASE_URL,
loadTimeoutMs: LOAD_TIMEOUT_MS,
panelBase: PANEL_BASE,
panelHeight: PANEL_HEIGHT_PX,
panelKey,
protocolVersion: MCP_APPS_PROTOCOL_VERSION,
toolName: PANEL_REQUEST_TOOL,
workerPreludePath: PANEL_WORKER_PRELUDE_PATH,
};
const script = `(${runtime})(${JSON.stringify(config)});`.replace(
/<\/script/gi,
'<\\/script',
);
return `<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>God's Eye View</title>
<style>
html, body { margin: 0; height: 100%; min-height: ${PANEL_HEIGHT_PX}px; background: #05070a;
color: #b8c4cc; font: 13px/1.4 system-ui, sans-serif; overflow: hidden; }
#status { position: absolute; inset: 0; z-index: 10000; display: flex; align-items: center;
justify-content: center; padding: 0 24px; text-align: center; }
#actions { position: absolute; right: 10px; top: 10px; z-index: 10001; display: flex; gap: 6px; }
#actions button { padding: 6px 10px; border: 1px solid #3a4a55; border-radius: 6px;
background: rgba(5, 7, 10, 0.75); color: #dfe8ee; font: inherit; cursor: pointer; }
#actions button[hidden] { display: none; }
</style>
</head>
<body>
<div id="status">Waiting for a view…</div>
<div id="actions">
<button id="expand" type="button" hidden>Expand</button>
<button id="open" type="button" hidden>Open in God's Eye View</button>
</div>
<script>${script}</script>
</body>
</html>
`;
}
/**
* The panel as an MCP resource. `runtime` is the panel's script, a function
* of its configuration that runs in the panel page; the application supplies
* it (src/app/globePanelRuntime.js), as it is browser code. The security
* policy lets the panel load map imagery, tiles and fonts from their
* providers; everything from the app's own server arrives through the MCP
* server instead. `panelKey` is the key the panel's requests must carry
* (see src/tools/queries/panelRequest.js), which this page holds.
*/
export function createGlobePanelResource({ runtime, panelKey }) {
if (typeof runtime !== 'function')
throw new TypeError('The globe panel needs its runtime script');
if (typeof panelKey !== 'string' || panelKey.length === 0)
throw new TypeError('The globe panel needs its request key');
return Object.freeze({
uri: GLOBE_PANEL_URI,
name: 'globe',
title: "God's Eye View globe",
description: "Live God's Eye View, showing the view a tool returns.",
mimeType: MCP_APP_MIME_TYPE,
text: panelHtml(runtime, panelKey),
_meta: {
ui: {
csp: {
resourceDomains: [...PROVIDER_ORIGINS],
connectDomains: [...PROVIDER_ORIGINS],
},
prefersBorder: true,
},
},
});
}
+135
View File
@@ -0,0 +1,135 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
GLOBE_PANEL_URI,
MCP_APP_MIME_TYPE,
createGlobePanelResource,
} from './globePanel.js';
import { panelRuntime } from '../app/globePanelRuntime.js';
import { composeCatalog, coreTools } from './index.js';
test('the globe panel is an MCP Apps resource that loads the app through its server', () => {
const resource = createGlobePanelResource({
runtime: panelRuntime,
panelKey: 'test-key',
});
assert.equal(resource.uri, GLOBE_PANEL_URI);
assert.equal(resource.mimeType, MCP_APP_MIME_TYPE);
assert.equal(MCP_APP_MIME_TYPE, 'text/html;profile=mcp-app');
const { csp } = resource._meta.ui;
// The panel reaches only map and font providers; the app's own files
// and data come through the MCP server, so no app address is declared.
assert.deepEqual(csp.connectDomains, csp.resourceDomains);
assert.ok(csp.resourceDomains.includes('https://tile.googleapis.com'));
assert.ok(
csp.resourceDomains.every((origin) => origin.startsWith('https://')),
);
assert.equal(csp.frameDomains, undefined);
assert.equal(csp.baseUriDomains, undefined);
assert.match(resource.text, /"toolName":"panel_request"/);
// The page carries the key its requests need, and sends it with each one.
assert.match(resource.text, /"panelKey":"test-key"/);
assert.match(resource.text, /key: config\.panelKey/);
assert.match(resource.text, /"panelBase":"\/panel\/"/);
assert.match(resource.text, /'ui\/initialize'/);
assert.match(resource.text, /'ui\/notifications\/tool-result'/);
assert.match(resource.text, /"protocolVersion":"2026-01-26"/);
// The MCP Apps SDK's initialize parameters; hosts reject anything else.
assert.match(resource.text, /appInfo: \{/);
assert.doesNotMatch(resource.text, /clientInfo/);
// Fullscreen where the host offers it.
assert.match(resource.text, /'ui\/request-display-mode'/);
assert.match(resource.text, /<button id="expand" type="button" hidden>/);
assert.doesNotMatch(resource.text, /<iframe|createElement\('base'\)/);
const script = resource.text.match(/<script>([\s\S]*)<\/script>/)[1];
assert.doesNotThrow(() => new Function(script));
});
test('the globe panel needs a request key', () => {
for (const panelKey of [undefined, '', 42])
assert.throws(
() => createGlobePanelResource({ runtime: panelRuntime, panelKey }),
TypeError,
);
});
test('show_in_gods_eye_view names the panel and shows a view another answer returned', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { app: { baseUrl: 'http://localhost:5173/' } },
});
assert.deepEqual(catalog.get('show_in_gods_eye_view').ui, {
resourceUri: GLOBE_PANEL_URI,
});
const earlier = {
camera: {
lat: 25,
lon: 121,
altitude_m: 300000,
heading_deg: 0,
pitch_deg: -90,
},
layers: ['ais-live-vessels'],
style: null,
map: null,
follow: null,
annotations: [],
url: 'http://localhost:5173/#v=2',
};
const shown = await catalog.call('show_in_gods_eye_view', {
view: earlier,
layers: ['ais-live-vessels', 'military'],
style: 'thermal',
});
assert.deepEqual(shown.data.view.camera, earlier.camera);
assert.deepEqual(shown.data.view.layers, ['ais-live-vessels', 'military']);
assert.equal(shown.data.view.style, 'thermal');
await assert.rejects(
catalog.call('show_in_gods_eye_view', { view: { camera: { lat: 'x' } } }),
(error) => error.code === 'invalid_arguments',
);
});
test('a view that only follows an aircraft is framed where the aircraft is', async () => {
const { resolveViewArguments } = await import('./queries/share.js');
const tools = {
has: (name) => name === 'find_aircraft',
call: async (name, args) => ({
data: {
rows:
args.icao24 === 'ae1234'
? [{ id: 'ae1234', lat: 32.7, lon: -117.2 }]
: [],
},
}),
};
const { view, label } = await resolveViewArguments(
{ follow: { kind: 'military_aircraft', id: 'ae1234', cockpit: true } },
{ services: {}, tools },
);
assert.deepEqual(
[view.camera.lat, view.camera.lon, view.follow.cockpit],
[32.7, -117.2, true],
);
assert.equal(label, 'military aircraft ae1234');
await assert.rejects(
resolveViewArguments(
{ follow: { kind: 'aircraft', id: 'abc999' } },
{ services: {}, tools },
),
(error) =>
error.code === 'invalid_arguments' &&
/not reported now/.test(error.message),
);
});
test('the package exports the panel resource and its runtime together', async () => {
const panel = await import('gods-eye-view/tools/panel');
const resource = panel.createGlobePanelResource({
runtime: panel.panelRuntime,
panelKey: 'key',
});
assert.equal(resource.uri, panel.GLOBE_PANEL_URI);
assert.equal(panel.PANEL_BASE, '/panel/');
assert.match(resource.text, /panelRuntime|function/);
});
+127
View File
@@ -0,0 +1,127 @@
/**
* Tools that answer questions from God's Eye View data, independent of the
* surface that exposes them. See docs/TOOLS.md.
*/
import {
aircraftInArea,
findAircraft,
getAircraftInfo,
getAircraftTrack,
} from './queries/aviation.js';
import { getWeatherMap, getWind } from './queries/atmosphere.js';
import { findSubmarineCables } from './queries/cables.js';
import {
getHudCaption,
militaryAwareness,
situationBrief,
} from './queries/brief.js';
import {
findMilitaryInstallations,
getCyclones,
getFirePerimeters,
getMapFeatures,
getRegionalBrief,
getTerrainHeight,
getWeather,
} from './queries/environment.js';
import { getBhoteKoshiFlood } from './queries/events.js';
import { getActiveFires, getEarthquakes } from './queries/hazards.js';
import { getRecentImagery } from './queries/imagery.js';
import { findInfrastructure } from './queries/infrastructure.js';
import {
findVessel,
getVesselTrack,
vesselsInArea,
} from './queries/maritime.js';
import {
getBikeShare,
getTrafficFlow,
getTransitVehicles,
} from './queries/mobility.js';
import { placesNearby, planRoute, searchPlaces } from './queries/places.js';
import { panelRequest } from './queries/panelRequest.js';
import { showInGodsEyeView } from './queries/share.js';
import { findAlprCameras } from './queries/surveillance.js';
import {
findCctvCameras,
findRadioStations,
getCctvSnapshot,
} from './queries/media.js';
import {
getRecentLaunches,
nextSatellitePass,
satellitesOverhead,
} from './queries/space.js';
export {
defineTool,
composeCatalog,
ToolError,
TOOL_ERROR_CODES,
} from './catalog.js';
export {
AREA_SCHEMA,
resolveArea,
areaCenter,
areaContains,
distanceKm,
} from './area.js';
export { LIMIT_SCHEMA, DEFAULT_LIMIT, MAX_LIMIT, capRows } from './results.js';
export { toFunctionOutput, toFunctionTools } from './functions.js';
export {
SURFACES,
TOOL_SURFACES,
catalogForSurface,
toolsForSurface,
} from './surfaces.js';
export {
createGeocodePlaceService,
createPlaceSearchService,
createRouteService,
placeFromGeocodeResult,
} from './places.js';
/** Every query Core defines, in a stable order. */
export const coreTools = Object.freeze([
getEarthquakes,
getActiveFires,
getRecentLaunches,
aircraftInArea,
findAircraft,
getAircraftTrack,
getAircraftInfo,
vesselsInArea,
findVessel,
getVesselTrack,
nextSatellitePass,
satellitesOverhead,
findCctvCameras,
getCctvSnapshot,
findAlprCameras,
findRadioStations,
searchPlaces,
placesNearby,
planRoute,
getBikeShare,
getTransitVehicles,
getTrafficFlow,
getWeather,
getWeatherMap,
getWind,
getRecentImagery,
findSubmarineCables,
findInfrastructure,
getBhoteKoshiFlood,
getRegionalBrief,
getCyclones,
getFirePerimeters,
getTerrainHeight,
findMilitaryInstallations,
getMapFeatures,
situationBrief,
militaryAwareness,
getHudCaption,
showInGodsEyeView,
panelRequest,
]);
+332
View File
@@ -0,0 +1,332 @@
import assert from 'node:assert/strict';
import { createServer, request as httpRequest } from 'node:http';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
import { isLocalMcpRequest, localMcpPlugin } from '../../server/mcp/plugin.js';
test('only direct loopback connections to this server are local', () => {
const local = {
remoteAddress: '127.0.0.1',
localPort: 4173,
host: 'localhost:4173',
};
assert.equal(isLocalMcpRequest(local), true);
assert.equal(
isLocalMcpRequest({ ...local, remoteAddress: '::1', host: '[::1]:4173' }),
true,
);
assert.equal(
isLocalMcpRequest({ ...local, origin: 'http://localhost:4173' }),
true,
);
assert.equal(
isLocalMcpRequest({ ...local, env: { PINOKIO_SHARE_LOCAL: 'false' } }),
true,
);
for (const request of [
{ ...local, remoteAddress: '192.168.1.20' },
{ ...local, host: 'attacker.example:4173' },
{ ...local, host: 'localhost.attacker.example' },
{ ...local, host: '' },
// Another local port, as the Host or as the page's origin.
{ ...local, host: 'localhost:9000' },
{ ...local, localPort: 80 },
{ ...local, origin: 'http://127.0.0.1:5173' },
{ ...local, origin: 'http://localhost:5173' },
{ ...local, origin: 'https://attacker.example' },
{ ...local, origin: 'null' },
{ ...local, origin: 'file:///tmp/page.html' },
// Proxied or shared: not from this machine, whatever the socket says.
{ ...local, headers: { 'x-forwarded-for': '203.0.113.9' } },
{ ...local, headers: { forwarded: 'for=203.0.113.9' } },
{ ...local, headers: { 'cf-connecting-ip': '203.0.113.9' } },
{ ...local, env: { PINOKIO_SHARE_LOCAL: 'true' } },
{ ...local, env: { PINOKIO_SHARE_VAR: 'GEV_SHARE_URL' } },
])
assert.equal(isLocalMcpRequest(request), false, JSON.stringify(request));
});
test('the /mcp route answers local MCP requests and refuses others', async (t) => {
let middleware;
const created = [];
const plugin = localMcpPlugin({
createServer: ({ apiBase }) => {
created.push(apiBase);
return {
handle: async (message) =>
message.id === undefined
? null
: { jsonrpc: '2.0', id: message.id, result: { apiBase } },
};
},
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const send = (method, body, headers = {}) =>
new Promise((resolve, reject) => {
const request = httpRequest(
{
host: '127.0.0.1',
port,
path: '/mcp',
method,
headers: {
'Content-Type': 'application/json',
Host: `localhost:${port}`,
...headers,
},
},
(response) => {
let text = '';
response.on('data', (chunk) => (text += chunk));
response.on('end', () =>
resolve({
status: response.statusCode,
json: () => JSON.parse(text),
}),
);
},
);
request.on('error', reject);
request.end(
body === undefined
? undefined
: typeof body === 'string'
? body
: JSON.stringify(body),
);
});
const post = (body, headers) => send('POST', body, headers);
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
assert.equal(ok.status, 200);
assert.deepEqual(await ok.json(), {
jsonrpc: '2.0',
id: 1,
result: { apiBase: `http://localhost:${port}` },
});
assert.equal(
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
.status,
202,
);
await post({ jsonrpc: '2.0', id: 2, method: 'ping' });
assert.deepEqual(created, [`http://localhost:${port}`]);
const foreign = await post(
{ jsonrpc: '2.0', id: 3, method: 'ping' },
{ Origin: 'https://attacker.example' },
);
assert.equal(foreign.status, 403);
assert.deepEqual(await foreign.json(), {
error: 'The MCP server only accepts local requests',
});
assert.equal((await post({}, { Host: 'attacker.example' })).status, 403);
assert.equal(
(await post({}, { Host: `localhost.attacker.example:${port}` })).status,
403,
);
// A Host naming another local port, a page on another port, and a request
// a proxy forwarded are all refused, and no server is made for them.
assert.equal((await post({}, { Host: `localhost:${port + 1}` })).status, 403);
assert.equal(
(await post({}, { Origin: `http://localhost:${port + 1}` })).status,
403,
);
assert.equal(
(await post({}, { 'X-Forwarded-For': '203.0.113.9' })).status,
403,
);
assert.deepEqual(created, [`http://localhost:${port}`]);
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
assert.equal(
(
await fetch(`http://127.0.0.1:${port}/mcp`, {
headers: { Host: `localhost:${port}` },
})
).status,
405,
);
});
test('a client that disconnects cancels its tool call', async (t) => {
let middleware;
let seen;
const started = Promise.withResolvers();
const aborted = Promise.withResolvers();
const plugin = localMcpPlugin({
createServer: () => ({
handle: (message, { signal }) => {
seen = signal;
signal.addEventListener('abort', () => aborted.resolve(), {
once: true,
});
started.resolve();
return new Promise(() => {});
},
}),
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const request = httpRequest({
host: '127.0.0.1',
port,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
Host: `localhost:${port}`,
},
});
request.on('error', () => {});
request.end(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'tools/call' }));
await started.promise;
assert.equal(seen.aborted, false);
request.destroy();
await aborted.promise;
assert.equal(seen.aborted, true);
});
test('a client that stops sending its request body is answered with a timeout', async (t) => {
let middleware;
const plugin = localMcpPlugin({
bodyTimeoutMs: 20,
createServer: () => ({ handle: async () => null }),
});
plugin.configureServer({
middlewares: { use: (path, handler) => (middleware = handler) },
});
const http = createServer((req, res) => middleware(req, res));
await new Promise((resolve) => http.listen(0, '127.0.0.1', resolve));
t.after(() => http.close());
const { port } = http.address();
const status = await new Promise((resolve, reject) => {
const request = httpRequest(
{
host: '127.0.0.1',
port,
path: '/mcp',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': '100',
Host: `localhost:${port}`,
},
},
(response) => {
response.resume();
resolve(response.statusCode);
request.destroy();
},
);
request.on('error', (error) => {
if (error.code !== 'ECONNRESET') reject(error);
});
// Part of the body, then nothing more.
request.write('{"jsonrpc"');
});
assert.equal(status, 408);
});
test('panel requests cannot reach /mcp through a suffix the server routes to it', async (t) => {
const { createServer: createViteServer } = await import('vite');
const vite = await createViteServer({
root: fileURLToPath(new URL('../../', import.meta.url)),
configFile: false,
envFile: false,
publicDir: false,
logLevel: 'silent',
plugins: [localMcpPlugin()],
optimizeDeps: { noDiscovery: true, include: [] },
server: { host: '127.0.0.1', port: 0, hmr: false, watch: null },
});
await vite.listen();
t.after(() => vite.close());
const { port } = vite.httpServer.address();
const rpc = (path, message) =>
new Promise((resolve, reject) => {
const body = JSON.stringify(message);
const request = httpRequest(
{
host: '127.0.0.1',
port,
path,
method: 'POST',
headers: {
'Content-Type': 'application/json',
Host: `localhost:${port}`,
},
},
(response) => {
let text = '';
response.on('data', (chunk) => (text += chunk));
response.on('end', () =>
resolve({ status: response.statusCode, text }),
);
},
);
request.on('error', reject);
request.end(body);
});
// The hazard is real: the server routes /mcp.json to the MCP endpoint.
const direct = await rpc('/mcp.json', {
jsonrpc: '2.0',
id: 1,
method: 'ping',
});
assert.equal(direct.status, 200);
assert.deepEqual(JSON.parse(direct.text), {
jsonrpc: '2.0',
id: 1,
result: {},
});
const page = JSON.parse(
(
await rpc('/mcp', {
jsonrpc: '2.0',
id: 2,
method: 'resources/read',
params: { uri: 'ui://gods-eye-view/globe' },
})
).text,
);
const [, key] = page.result.contents[0].text.match(/"panelKey":"([^"]+)"/);
const body = Buffer.from(
JSON.stringify({ jsonrpc: '2.0', id: 3, method: 'ping' }),
).toString('base64');
for (const path of ['/mcp', '/mcp.json', '/MCP.json', '/mcp.anything']) {
const answer = JSON.parse(
(
await rpc('/mcp', {
jsonrpc: '2.0',
id: 4,
method: 'tools/call',
params: {
name: 'panel_request',
arguments: {
key,
path,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body,
},
},
})
).text,
);
assert.equal(answer.result.isError, true, path);
assert.match(answer.result.content[0].text, /is not available/, path);
}
});
+213
View File
@@ -0,0 +1,213 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { PassThrough } from 'node:stream';
import { createLocalMcpServer } from '../../server/mcp/server.js';
import { createApiFetch } from '../../server/mcp/services.js';
import { parseArgs, serveStdio } from '../../server/mcp/stdio.js';
import { coreTools, toolsForSurface } from './index.js';
const usgs = {
type: 'FeatureCollection',
features: [
{
id: 'us1',
geometry: { type: 'Point', coordinates: [121.5, 24, 10] },
properties: { mag: 5.1, place: 'near Hualien', time: 1767229200000 },
},
],
};
test('relative API requests resolve against the configured base', async () => {
const seen = [];
const apiFetch = createApiFetch({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => seen.push(String(url)),
});
await apiFetch('/api/launches');
await apiFetch('https://earthquake.usgs.gov/feed.geojson');
assert.deepEqual(seen, [
'http://127.0.0.1:5000/api/launches',
'https://earthquake.usgs.gov/feed.geojson',
]);
assert.throws(
() => createApiFetch({ apiBase: 'file:///etc' }),
/http\(s\) URL/,
);
});
test('the stdio server answers newline-delimited requests using only its data sources', async () => {
const contacted = [];
const server = createLocalMcpServer({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => {
contacted.push(new URL(url).origin);
return Response.json(usgs);
},
});
const input = new PassThrough();
const output = new PassThrough();
let written = '';
output.on('data', (chunk) => (written += chunk));
const served = serveStdio(server, { input, output });
input.write(
'{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18"}}\n',
);
input.write('{"jsonrpc":"2.0","method":"notifications/initialized"}\n\n');
input.write('not json\n');
input.write('{"jsonrpc":"2.0","id":2,"method":"tools/list"}\n');
input.end(
'{"jsonrpc":"2.0","id":3,"method":"tools/call","params":{"name":"get_earthquakes","arguments":{}}}\n',
);
await served;
const responses = written
.trim()
.split('\n')
.map((line) => JSON.parse(line));
const byId = new Map(responses.map((response) => [response.id, response]));
assert.equal(responses.length, 4);
assert.equal(byId.get(1).result.serverInfo.name, 'gods-eye-view');
assert.equal(byId.get(null).error.code, -32700);
// Every Core tool's services are composed locally.
assert.deepEqual(
byId.get(2).result.tools.map((tool) => tool.name),
toolsForSurface(coreTools, 'mcp').map((tool) => tool.name),
);
assert.equal(
byId.get(3).result.content[0].text,
'1 earthquake of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
);
// The earthquake feed is fetched directly; nothing else was contacted.
assert.deepEqual(contacted, ['https://earthquake.usgs.gov']);
});
test('command-line arguments are strict', () => {
assert.deepEqual(parseArgs([]), {});
assert.deepEqual(parseArgs(['--api-base', 'http://localhost:5173']), {
apiBase: 'http://localhost:5173',
});
assert.throws(() => parseArgs(['--port', '1']), /Unknown argument/);
});
test('the stdio log names methods, tools and resources but never arguments', async () => {
const { describeRequest } = await import('../../server/mcp/stdio.js');
assert.equal(describeRequest({ method: 'tools/list' }), 'tools/list');
assert.equal(
describeRequest({
method: 'tools/call',
params: { name: 'get_weather', arguments: { location: { place: 'x' } } },
}),
'tools/call get_weather',
);
assert.equal(
describeRequest({ method: 'resources/read', params: { uri: 'ui://a/b' } }),
'resources/read ui://a/b',
);
assert.equal(describeRequest({ result: {} }), null);
});
test('a failed tool call is logged with its reason', async () => {
const server = {
handle: async (message) => ({
jsonrpc: '2.0',
id: message.id,
result: { isError: true, content: [{ type: 'text', text: 'bad area' }] },
}),
};
const input = new PassThrough();
const output = new PassThrough();
output.resume();
const logged = [];
const served = serveStdio(server, {
input,
output,
log: (line) => logged.push(line),
});
input.end(
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"show_in_gods_eye_view"}}\n',
);
await served;
assert.deepEqual(logged, [
'tools/call show_in_gods_eye_view',
' tools/call show_in_gods_eye_view failed: bad area',
]);
});
test("only the key in the server's own panel page opens panel requests", async () => {
const requested = [];
const server = createLocalMcpServer({
apiBase: 'http://127.0.0.1:5000',
fetchImpl: async (url) => {
requested.push(new URL(url).pathname);
return new Response('ok');
},
});
const call = (id, args) =>
server.handle({
jsonrpc: '2.0',
id,
method: 'tools/call',
params: { name: 'panel_request', arguments: args },
});
const page = await server.handle({
jsonrpc: '2.0',
id: 1,
method: 'resources/read',
params: { uri: 'ui://gods-eye-view/globe' },
});
const [, key] = page.result.contents[0].text.match(/"panelKey":"([^"]+)"/);
assert.ok(key.length >= 40);
const refused = await call(2, { path: '/panel/index.html' });
assert.equal(refused.result.isError, true);
const opened = await call(3, { key, path: '/panel/index.html' });
assert.equal(opened.result.isError, false);
assert.deepEqual(requested, ['/panel/index.html']);
// Every server makes its own key.
const other = createLocalMcpServer({ apiBase: 'http://127.0.0.1:5000' });
const otherPage = await other.handle({
jsonrpc: '2.0',
id: 4,
method: 'resources/read',
params: { uri: 'ui://gods-eye-view/globe' },
});
assert.doesNotMatch(otherPage.result.contents[0].text, new RegExp(key));
});
test('a request the client cancels over stdio is aborted and gets no answer', async () => {
let seen;
const server = {
handle: (message, { signal } = {}) => {
if (message.id === 1) {
seen = signal;
return new Promise((resolve) =>
signal.addEventListener(
'abort',
() => resolve({ jsonrpc: '2.0', id: 1, result: {} }),
{ once: true },
),
);
}
return Promise.resolve(
message.id === undefined
? null
: { jsonrpc: '2.0', id: message.id, result: {} },
);
},
};
const input = new PassThrough();
const output = new PassThrough();
let written = '';
output.on('data', (chunk) => (written += chunk));
const served = serveStdio(server, { input, output });
input.write('{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{}}\n');
input.write('{"jsonrpc":"2.0","id":2,"method":"ping"}\n');
input.end(
'{"jsonrpc":"2.0","method":"notifications/cancelled","params":{"requestId":1}}\n',
);
await served;
assert.equal(seen.aborted, true);
const ids = written
.trim()
.split('\n')
.map((line) => JSON.parse(line).id);
assert.deepEqual(ids, [2]);
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Stateless Streamable HTTP transport for an MCP server: one JSON-RPC message
* per POST, answered with a JSON body. Uses standard Request and Response so
* any web-standard runtime can mount it. The host owns routing and any access
* control in front of it.
*/
import { MCP_PROTOCOL_VERSIONS, parseErrorResponse } from './protocol.js';
const MAX_BODY_BYTES = 1024 * 1024;
/** Return `(request) => Promise<Response>` for an MCP server. */
export function createMcpHttpHandler(server) {
return async function handleMcpRequest(request) {
if (request.method !== 'POST')
return new Response(null, { status: 405, headers: { Allow: 'POST' } });
const version = request.headers.get('mcp-protocol-version');
if (version && !MCP_PROTOCOL_VERSIONS.includes(version))
return json(400, {
error: `Unsupported MCP protocol version: ${version}`,
});
const type = request.headers.get('content-type') || '';
if (!/^application\/json\b/i.test(type))
return json(415, { error: 'Content-Type must be application/json' });
const declared = Number(request.headers.get('content-length'));
if (declared > MAX_BODY_BYTES)
return json(413, { error: 'Request too large' });
const text = await readCapped(request, MAX_BODY_BYTES);
if (text === null) return json(413, { error: 'Request too large' });
let message;
try {
message = JSON.parse(text);
} catch {
return json(400, parseErrorResponse());
}
const response = await server.handle(message, { signal: request.signal });
return response ? json(200, response) : new Response(null, { status: 202 });
};
}
/**
* Read a request body as text, stopping as soon as it passes `limit` bytes,
* so a body without a Content-Length cannot be buffered without bound.
* Returns null when the body is too large.
*/
async function readCapped(request, limit) {
if (!request.body) return '';
const reader = request.body.getReader();
const chunks = [];
let size = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
size += value.byteLength;
if (size > limit) {
await reader.cancel().catch(() => {});
return null;
}
chunks.push(value);
}
const bytes = new Uint8Array(size);
let offset = 0;
for (const chunk of chunks) {
bytes.set(chunk, offset);
offset += chunk.byteLength;
}
return new TextDecoder().decode(bytes);
}
function json(status, body) {
return new Response(JSON.stringify(body), {
status,
headers: {
'Content-Type': 'application/json',
'Cache-Control': 'no-store',
},
});
}
Loaded 100 of 223 files, more files were not shown because too many files have changed in this diff. Show more