mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-05 02:44:02 +00:00
Merge main into #873
Resolve the CHANGELOG and CURRENT-STATE conflicts: the PR's entries go under Unreleased, after the 0.2.0 release. Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
commit
b33cae4b74
159 files changed
+18509
-1297
No files matched your search
@@ -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);
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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}` : '.'),
|
||||
),
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
@@ -5,6 +5,9 @@ import { createAisStreamSource } from '../sources/live/standalone.js';
|
||||
const aisLiveVesselsLayer = createApplicationVessels({
|
||||
source: createAisStreamSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||
// 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,
|
||||
|
||||
+22
-8
@@ -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}`);
|
||||
|
||||
@@ -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/,
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
@@ -1,12 +1,8 @@
|
||||
import { createLocalGeoJsonLayer } from './localGeojsonCore.js';
|
||||
import { INFRASTRUCTURE_DATA_URLS } from '../sources/infrastructureData.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;
|
||||
const datacentersUrl = INFRASTRUCTURE_DATA_URLS['local-datacenters'];
|
||||
const damsUrl = INFRASTRUCTURE_DATA_URLS['local-dams'];
|
||||
|
||||
/**
|
||||
* Create fresh datacenter and dam layers without starting or loading them.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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'),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,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,
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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() {} },
|
||||
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 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.
|
||||
export const INFRASTRUCTURE_DATA_URLS = Object.freeze({
|
||||
'local-datacenters': new URL(
|
||||
'../data/local_data/datacenters/datacenters.geojsonl',
|
||||
import.meta.url,
|
||||
).href,
|
||||
'local-dams': 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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -29,6 +29,9 @@ export function createStandaloneLayerSources() {
|
||||
military: createAdsbLolSource(),
|
||||
vessels: createAisStreamSource({
|
||||
apiUrl: import.meta.env?.VITE_AIS_LIVE_API_URL || '/api/ais-live',
|
||||
// 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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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/opensky',
|
||||
'?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;
|
||||
|
||||
@@ -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"\}/);
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
+160
-18
@@ -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'));
|
||||
});
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
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),
|
||||
);
|
||||
});
|
||||
@@ -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,
|
||||
]);
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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]);
|
||||
});
|
||||
@@ -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',
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Model Context Protocol adapter for a tool catalog. See docs/TOOLS.md. */
|
||||
|
||||
export {
|
||||
createMcpServer,
|
||||
toMcpTools,
|
||||
parseErrorResponse,
|
||||
MCP_PROTOCOL_VERSIONS,
|
||||
} from './protocol.js';
|
||||
export { createMcpHttpHandler } from './http.js';
|
||||
@@ -0,0 +1,315 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
createMcpHttpHandler,
|
||||
createMcpServer,
|
||||
MCP_PROTOCOL_VERSIONS,
|
||||
toMcpTools,
|
||||
} from './index.js';
|
||||
|
||||
const tool = (name, run) =>
|
||||
defineTool({
|
||||
name,
|
||||
title: 'Count',
|
||||
description: 'Counts things.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { n: { type: 'integer' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
run,
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: [
|
||||
tool('count', async ({ n = 1 }) => ({
|
||||
summary: `${n} things.`,
|
||||
data: { n },
|
||||
})),
|
||||
tool('busy', async () => {
|
||||
throw new ToolError('retry_later', 'Upstream is busy', {
|
||||
retryAfterSeconds: 10,
|
||||
});
|
||||
}),
|
||||
tool('crash', async () => {
|
||||
throw new Error('database password is hunter2');
|
||||
}),
|
||||
],
|
||||
});
|
||||
const server = createMcpServer({
|
||||
catalog,
|
||||
name: 'test',
|
||||
version: '1.0.0',
|
||||
instructions: 'Be brief.',
|
||||
});
|
||||
const request = (method, params, id = 1) =>
|
||||
server.handle({ jsonrpc: '2.0', id, method, params });
|
||||
|
||||
test('initialize negotiates a supported protocol version', async () => {
|
||||
const known = await request('initialize', { protocolVersion: '2025-06-18' });
|
||||
assert.deepEqual(known.result, {
|
||||
protocolVersion: '2025-06-18',
|
||||
capabilities: { tools: { listChanged: false } },
|
||||
serverInfo: { name: 'test', version: '1.0.0' },
|
||||
instructions: 'Be brief.',
|
||||
});
|
||||
const unknown = await request('initialize', {
|
||||
protocolVersion: '1999-01-01',
|
||||
});
|
||||
assert.equal(unknown.result.protocolVersion, MCP_PROTOCOL_VERSIONS[0]);
|
||||
assert.deepEqual((await request('ping')).result, {});
|
||||
});
|
||||
|
||||
test('notifications and client responses get no reply', async () => {
|
||||
assert.equal(
|
||||
await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
method: 'notifications/initialized',
|
||||
}),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
await server.handle({ jsonrpc: '2.0', id: 9, result: {} }),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('invalid messages and unknown methods are JSON-RPC errors', async () => {
|
||||
assert.equal(
|
||||
(await server.handle({ id: 1, method: 'ping' })).error.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal(
|
||||
(await server.handle([{ jsonrpc: '2.0', id: 1, method: 'ping' }])).error
|
||||
.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal(
|
||||
(await server.handle({ jsonrpc: '2.0', id: 1 })).error.code,
|
||||
-32600,
|
||||
);
|
||||
assert.equal((await request('tools/remove')).error.code, -32601);
|
||||
assert.equal((await request('toString')).error.code, -32601);
|
||||
assert.equal((await request('tools/list', [])).error.code, -32602);
|
||||
assert.equal(
|
||||
(await request('tools/call', { name: 'missing' })).error.code,
|
||||
-32602,
|
||||
);
|
||||
});
|
||||
|
||||
test('tools are listed with titles, schemas and annotations, with surface overrides', async () => {
|
||||
const { tools } = (await request('tools/list')).result;
|
||||
assert.deepEqual(tools[0], {
|
||||
name: 'count',
|
||||
title: 'Count',
|
||||
description: 'Counts things.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { n: { type: 'integer' } },
|
||||
additionalProperties: false,
|
||||
},
|
||||
annotations: { title: 'Count', readOnlyHint: true },
|
||||
});
|
||||
const [custom] = toMcpTools(catalog, {
|
||||
descriptions: { count: { description: 'Counts for this surface.' } },
|
||||
decorate: (definition, source) => ({
|
||||
_meta: { kind: source.kind, seen: definition.name },
|
||||
}),
|
||||
});
|
||||
assert.equal(custom.description, 'Counts for this surface.');
|
||||
assert.deepEqual(custom._meta, { kind: 'query', seen: 'count' });
|
||||
});
|
||||
|
||||
test('tool results carry text and structured content; failures stay generic', async () => {
|
||||
assert.deepEqual(
|
||||
(await request('tools/call', { name: 'count', arguments: { n: 3 } }))
|
||||
.result,
|
||||
{
|
||||
content: [
|
||||
{ type: 'text', text: '3 things.' },
|
||||
{ type: 'text', text: '{"n":3}' },
|
||||
],
|
||||
structuredContent: { n: 3 },
|
||||
isError: false,
|
||||
},
|
||||
);
|
||||
assert.deepEqual((await request('tools/call', { name: 'busy' })).result, {
|
||||
content: [{ type: 'text', text: 'Upstream is busy' }],
|
||||
structuredContent: { error: 'retry_later', retry_after_seconds: 10 },
|
||||
isError: true,
|
||||
});
|
||||
const invalid = (
|
||||
await request('tools/call', { name: 'count', arguments: { n: 'x' } })
|
||||
).result;
|
||||
assert.equal(invalid.isError, true);
|
||||
assert.equal(invalid.structuredContent.error, 'invalid_arguments');
|
||||
const crash = (await request('tools/call', { name: 'crash' })).result;
|
||||
assert.equal(crash.isError, true);
|
||||
assert.equal(JSON.stringify(crash).includes('hunter2'), false);
|
||||
});
|
||||
|
||||
test('the HTTP transport accepts one JSON message per POST', async () => {
|
||||
const handle = createMcpHttpHandler(server);
|
||||
const post = (body, headers = {}) =>
|
||||
handle(
|
||||
new Request('http://localhost/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
body: typeof body === 'string' ? body : JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
const ok = await post({ jsonrpc: '2.0', id: 1, method: 'ping' });
|
||||
assert.equal(ok.status, 200);
|
||||
assert.equal(ok.headers.get('content-type'), 'application/json');
|
||||
assert.deepEqual(await ok.json(), { jsonrpc: '2.0', id: 1, result: {} });
|
||||
assert.equal(
|
||||
(await post({ jsonrpc: '2.0', method: 'notifications/initialized' }))
|
||||
.status,
|
||||
202,
|
||||
);
|
||||
const parse = await post('{');
|
||||
assert.equal(parse.status, 400);
|
||||
assert.equal((await parse.json()).error.code, -32700);
|
||||
assert.equal(
|
||||
(await post({}, { 'MCP-Protocol-Version': '1999-01-01' })).status,
|
||||
400,
|
||||
);
|
||||
assert.equal((await post({}, { 'Content-Type': 'text/plain' })).status, 415);
|
||||
assert.equal((await post('x'.repeat(1024 * 1024 + 1))).status, 413);
|
||||
// A streamed body without Content-Length stops being read past the limit.
|
||||
let produced = 0;
|
||||
const chunk = new Uint8Array(64 * 1024).fill(32);
|
||||
const body = new ReadableStream({
|
||||
pull(controller) {
|
||||
if (produced >= 8 * 1024 * 1024) return controller.close();
|
||||
produced += chunk.byteLength;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
});
|
||||
const streamed = await handle(
|
||||
new Request('http://localhost/mcp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body,
|
||||
duplex: 'half',
|
||||
}),
|
||||
);
|
||||
assert.equal(streamed.status, 413);
|
||||
assert.ok(produced <= 1024 * 1024 + 4 * chunk.byteLength, `read ${produced}`);
|
||||
const get = await handle(new Request('http://localhost/mcp'));
|
||||
assert.equal(get.status, 405);
|
||||
assert.equal(get.headers.get('allow'), 'POST');
|
||||
});
|
||||
|
||||
test('resources are listed and read, and tools name their UI resource', async () => {
|
||||
const shown = defineTool({
|
||||
name: 'show',
|
||||
title: 'Show',
|
||||
description: 'Shows something in a view.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
ui: { resourceUri: 'ui://fixture/view' },
|
||||
run: async () => ({ summary: 'shown', data: {} }),
|
||||
});
|
||||
const withUi = createMcpServer({
|
||||
catalog: composeCatalog({ tools: [shown] }),
|
||||
name: 'fixture',
|
||||
version: '1',
|
||||
resources: [
|
||||
{
|
||||
uri: 'ui://fixture/view',
|
||||
name: 'view',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
text: '<!doctype html><p>view</p>',
|
||||
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
|
||||
},
|
||||
],
|
||||
});
|
||||
const call = async (method, params) =>
|
||||
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).result ??
|
||||
(await withUi.handle({ jsonrpc: '2.0', id: 1, method, params })).error;
|
||||
assert.deepEqual((await call('initialize', {})).capabilities, {
|
||||
tools: { listChanged: false },
|
||||
resources: { listChanged: false },
|
||||
});
|
||||
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
|
||||
ui: { resourceUri: 'ui://fixture/view' },
|
||||
'ui/resourceUri': 'ui://fixture/view',
|
||||
});
|
||||
assert.deepEqual((await call('resources/list')).resources, [
|
||||
{
|
||||
uri: 'ui://fixture/view',
|
||||
name: 'view',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
|
||||
},
|
||||
]);
|
||||
assert.deepEqual(
|
||||
(await call('resources/read', { uri: 'ui://fixture/view' })).contents,
|
||||
[
|
||||
{
|
||||
uri: 'ui://fixture/view',
|
||||
mimeType: 'text/html;profile=mcp-app',
|
||||
text: '<!doctype html><p>view</p>',
|
||||
_meta: { ui: { csp: { frameDomains: ['https://a.example'] } } },
|
||||
},
|
||||
],
|
||||
);
|
||||
assert.equal(
|
||||
(await call('resources/read', { uri: 'ui://fixture/other' })).code,
|
||||
-32002,
|
||||
);
|
||||
// A server without resources does not advertise them.
|
||||
assert.equal(
|
||||
(await server.handle({ jsonrpc: '2.0', id: 1, method: 'initialize' }))
|
||||
.result.capabilities.resources,
|
||||
undefined,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
defineTool({
|
||||
name: 'bad',
|
||||
title: 'Bad',
|
||||
description: 'Points at a web page.',
|
||||
inputSchema: { type: 'object' },
|
||||
ui: { resourceUri: 'https://a.example' },
|
||||
run: async () => ({ summary: '', data: {} }),
|
||||
}),
|
||||
/ui:\/\/ URI/,
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
defineTool({
|
||||
name: 'bad',
|
||||
title: 'Bad',
|
||||
description: 'Visible to nobody.',
|
||||
inputSchema: { type: 'object' },
|
||||
ui: { visibility: ['user'] },
|
||||
run: async () => ({ summary: '', data: {} }),
|
||||
}),
|
||||
/visibility/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a tool only an app may call says so, and answers it without JSON text', async () => {
|
||||
const loader = defineTool({
|
||||
name: 'load',
|
||||
title: 'Load',
|
||||
description: 'Loads a file for the view.',
|
||||
inputSchema: { type: 'object', properties: {} },
|
||||
ui: { visibility: ['app'] },
|
||||
run: async () => ({ summary: 'loaded', data: { body: 'abc' } }),
|
||||
});
|
||||
const appServer = createMcpServer({
|
||||
catalog: composeCatalog({ tools: [loader] }),
|
||||
name: 'fixture',
|
||||
version: '1',
|
||||
});
|
||||
const call = async (method, params) =>
|
||||
(await appServer.handle({ jsonrpc: '2.0', id: 1, method, params })).result;
|
||||
assert.deepEqual((await call('tools/list')).tools[0]._meta, {
|
||||
ui: { visibility: ['app'] },
|
||||
});
|
||||
const result = await call('tools/call', { name: 'load', arguments: {} });
|
||||
assert.deepEqual(result.content, [{ type: 'text', text: 'loaded' }]);
|
||||
assert.deepEqual(result.structuredContent, { body: 'abc' });
|
||||
});
|
||||
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Model Context Protocol server for a tool catalog: JSON-RPC 2.0 handling of
|
||||
* initialize, ping, tools/list and tools/call, and resources/list and
|
||||
* resources/read for the resources an application supplies, such as MCP Apps
|
||||
* views (`ui://` resources) that tools name in `_meta.ui.resourceUri`. Transports (HTTP, stdio) pass
|
||||
* parsed messages in and send the returned responses out.
|
||||
*/
|
||||
|
||||
import { ToolError } from '../catalog.js';
|
||||
|
||||
/** Protocol revisions this server implements, newest first. */
|
||||
export const MCP_PROTOCOL_VERSIONS = Object.freeze([
|
||||
'2025-11-25',
|
||||
'2025-06-18',
|
||||
'2025-03-26',
|
||||
]);
|
||||
|
||||
const PARSE_ERROR = -32700;
|
||||
const INVALID_REQUEST = -32600;
|
||||
const METHOD_NOT_FOUND = -32601;
|
||||
const INVALID_PARAMS = -32602;
|
||||
const INTERNAL_ERROR = -32603;
|
||||
const RESOURCE_NOT_FOUND = -32002;
|
||||
|
||||
/**
|
||||
* Describe catalog tools as MCP tool definitions.
|
||||
*
|
||||
* `descriptions` may override a tool's `title` and `description` by name.
|
||||
* `decorate(definition, tool)` may return extra fields to merge into each
|
||||
* definition, such as application metadata.
|
||||
*/
|
||||
export function toMcpTools(catalog, { descriptions = {}, decorate } = {}) {
|
||||
return catalog.list().map((tool) => {
|
||||
const override = descriptions[tool.name] || {};
|
||||
const title = override.title ?? tool.title;
|
||||
const definition = {
|
||||
name: tool.name,
|
||||
title,
|
||||
description: override.description ?? tool.description,
|
||||
inputSchema: tool.inputSchema,
|
||||
annotations: { title, ...tool.annotations },
|
||||
...(tool.ui
|
||||
? {
|
||||
_meta: {
|
||||
ui: { ...tool.ui },
|
||||
// The flat key is the earlier form of the same field, which
|
||||
// some hosts still read; the MCP Apps SDK sends both.
|
||||
...(tool.ui.resourceUri
|
||||
? { 'ui/resourceUri': tool.ui.resourceUri }
|
||||
: {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
return decorate
|
||||
? { ...definition, ...decorate(definition, tool) }
|
||||
: definition;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a protocol handler. `handle(message, { signal })` resolves to the
|
||||
* JSON-RPC response, or null for notifications.
|
||||
*/
|
||||
export function createMcpServer({
|
||||
catalog,
|
||||
name,
|
||||
version,
|
||||
instructions,
|
||||
descriptions,
|
||||
decorate,
|
||||
resources = [],
|
||||
}) {
|
||||
const tools = () => toMcpTools(catalog, { descriptions, decorate });
|
||||
const resourceByUri = new Map(
|
||||
resources.map((resource) => [resource.uri, resource]),
|
||||
);
|
||||
const methods = {
|
||||
initialize(params) {
|
||||
const requested = params?.protocolVersion;
|
||||
return {
|
||||
protocolVersion: MCP_PROTOCOL_VERSIONS.includes(requested)
|
||||
? requested
|
||||
: MCP_PROTOCOL_VERSIONS[0],
|
||||
capabilities: {
|
||||
tools: { listChanged: false },
|
||||
...(resources.length ? { resources: { listChanged: false } } : {}),
|
||||
},
|
||||
serverInfo: { name, version },
|
||||
...(instructions ? { instructions } : {}),
|
||||
};
|
||||
},
|
||||
ping: () => ({}),
|
||||
'tools/list': () => ({ tools: tools() }),
|
||||
'resources/list': () => ({
|
||||
resources: resources.map(({ text, ...entry }) => entry),
|
||||
}),
|
||||
'resources/read'(params) {
|
||||
const resource = resourceByUri.get(params?.uri);
|
||||
if (!resource)
|
||||
throw rpcError(RESOURCE_NOT_FOUND, `Unknown resource: ${params?.uri}`);
|
||||
const { uri, mimeType, text, _meta } = resource;
|
||||
return {
|
||||
contents: [{ uri, mimeType, text, ...(_meta ? { _meta } : {}) }],
|
||||
};
|
||||
},
|
||||
async 'tools/call'(params, { signal }) {
|
||||
if (typeof params?.name !== 'string')
|
||||
throw rpcError(INVALID_PARAMS, 'tools/call needs a tool name');
|
||||
if (!catalog.get(params.name))
|
||||
throw rpcError(INVALID_PARAMS, `Unknown tool: ${params.name}`);
|
||||
try {
|
||||
const result = await catalog.call(params.name, params.arguments ?? {}, {
|
||||
signal,
|
||||
});
|
||||
// Only an app reads a tool the model cannot call, and apps read
|
||||
// structuredContent.
|
||||
const appOnly =
|
||||
catalog.get(params.name).ui?.visibility?.includes('model') === false;
|
||||
return {
|
||||
// The data is repeated as JSON text for clients that do not read
|
||||
// structuredContent.
|
||||
content: [
|
||||
{ type: 'text', text: result.summary },
|
||||
...(appOnly
|
||||
? []
|
||||
: [{ type: 'text', text: JSON.stringify(result.data) }]),
|
||||
...(result.images || []).map((item) => ({
|
||||
type: 'image',
|
||||
data: item.data,
|
||||
mimeType: item.mimeType,
|
||||
})),
|
||||
],
|
||||
structuredContent: result.data,
|
||||
isError: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
// Expected failures are reported to the model so it can adjust; other
|
||||
// errors are not described, so internal details never leave the server.
|
||||
const known = error instanceof ToolError;
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text',
|
||||
text: known ? error.message : 'The tool failed; try again later',
|
||||
},
|
||||
],
|
||||
structuredContent: {
|
||||
error: known ? error.code : 'unavailable',
|
||||
...(known && error.retryAfterSeconds != null
|
||||
? { retry_after_seconds: error.retryAfterSeconds }
|
||||
: {}),
|
||||
},
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
return Object.freeze({
|
||||
async handle(message, { signal } = {}) {
|
||||
if (!isObject(message) || message.jsonrpc !== '2.0')
|
||||
return failure(
|
||||
message?.id ?? null,
|
||||
INVALID_REQUEST,
|
||||
'Invalid JSON-RPC message',
|
||||
);
|
||||
const isRequest = message.id !== undefined;
|
||||
if (typeof message.method !== 'string')
|
||||
// Responses from the client are accepted and ignored: this server
|
||||
// sends no requests of its own.
|
||||
return isRequest &&
|
||||
message.result === undefined &&
|
||||
message.error === undefined
|
||||
? failure(message.id, INVALID_REQUEST, 'Missing method')
|
||||
: null;
|
||||
if (!isRequest) return null;
|
||||
const method = Object.hasOwn(methods, message.method)
|
||||
? methods[message.method]
|
||||
: null;
|
||||
if (!method)
|
||||
return failure(
|
||||
message.id,
|
||||
METHOD_NOT_FOUND,
|
||||
`Unknown method: ${message.method}`,
|
||||
);
|
||||
if (message.params !== undefined && !isObject(message.params))
|
||||
return failure(message.id, INVALID_PARAMS, 'params must be an object');
|
||||
try {
|
||||
const result = await method(message.params, { signal });
|
||||
return { jsonrpc: '2.0', id: message.id, result };
|
||||
} catch (error) {
|
||||
if (error?.rpcCode)
|
||||
return failure(message.id, error.rpcCode, error.message);
|
||||
return failure(message.id, INTERNAL_ERROR, 'Internal error');
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** A JSON-RPC parse-error response, for transports that cannot parse input. */
|
||||
export function parseErrorResponse() {
|
||||
return failure(null, PARSE_ERROR, 'Parse error');
|
||||
}
|
||||
|
||||
function failure(id, code, message) {
|
||||
return { jsonrpc: '2.0', id, error: { code, message } };
|
||||
}
|
||||
|
||||
function rpcError(code, message) {
|
||||
return Object.assign(new Error(message), { rpcCode: code });
|
||||
}
|
||||
|
||||
function isObject(value) {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Place services backed by the app's own routes. Applications with different
|
||||
* providers can supply any objects with the same contracts:
|
||||
*
|
||||
* - `places.resolve(name, { signal })` turns a name into bounds (`/api/geocode`).
|
||||
* - `placeSearch.search(query, near, { signal })` and
|
||||
* `placeSearch.nearby(near, { signal })` find points of interest
|
||||
* (`/api/google/*`), reporting whether search is configured.
|
||||
* - `routing.route(points, profile, { signal })` plans a route (`/api/route`).
|
||||
*/
|
||||
|
||||
import { ToolError } from './catalog.js';
|
||||
|
||||
const POINT_RADIUS_DEGREES = 0.25;
|
||||
|
||||
/**
|
||||
* Resolve place names to `{ name, bounds: { west, south, east, north }, point }`
|
||||
* or null. `point` is the place's own location, which can lie far from the
|
||||
* middle of its bounds (a region with remote islands, for example).
|
||||
*/
|
||||
export function createGeocodePlaceService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
cacheSize = 200,
|
||||
cacheMs = 10 * 60_000,
|
||||
timeoutMs = 15_000,
|
||||
now = () => Date.now(),
|
||||
} = {}) {
|
||||
// Recent answers by name, so tools that combine others resolve a place to
|
||||
// the same point once instead of once per section. A pending lookup is
|
||||
// shared by its callers: it has its own deadline, is cancelled when every
|
||||
// caller has given up, and only a successful answer stays cached.
|
||||
const cache = new Map();
|
||||
async function lookup(name, signal) {
|
||||
const query = new URLSearchParams({ q: name });
|
||||
const response = await fetchImpl(`/api/geocode?${query}`, { signal });
|
||||
if (!response.ok) throw new Error(`Geocode HTTP ${response.status}`);
|
||||
const payload = await response.json();
|
||||
return placeFromGeocodeResult(payload?.results?.[0]);
|
||||
}
|
||||
function start(key, name) {
|
||||
const controller = new AbortController();
|
||||
const entry = {
|
||||
at: now(),
|
||||
controller,
|
||||
waiters: 0,
|
||||
settled: false,
|
||||
place: lookup(
|
||||
name,
|
||||
AbortSignal.any([controller.signal, AbortSignal.timeout(timeoutMs)]),
|
||||
),
|
||||
};
|
||||
entry.place.then(
|
||||
() => {
|
||||
entry.settled = true;
|
||||
},
|
||||
() => {
|
||||
entry.settled = true;
|
||||
if (cache.get(key) === entry) cache.delete(key);
|
||||
},
|
||||
);
|
||||
cache.set(key, entry);
|
||||
while (cache.size > cacheSize) cache.delete(cache.keys().next().value);
|
||||
return entry;
|
||||
}
|
||||
function join(key, entry, signal) {
|
||||
if (!signal) {
|
||||
// A caller that cannot cancel keeps the lookup alive.
|
||||
entry.waiters = Infinity;
|
||||
return entry.place;
|
||||
}
|
||||
signal.throwIfAborted();
|
||||
entry.waiters += 1;
|
||||
return new Promise((resolve, reject) => {
|
||||
const leave = () => {
|
||||
entry.waiters -= 1;
|
||||
reject(signal.reason);
|
||||
if (entry.waiters <= 0 && !entry.settled) {
|
||||
entry.controller.abort(signal.reason);
|
||||
if (cache.get(key) === entry) cache.delete(key);
|
||||
}
|
||||
};
|
||||
signal.addEventListener('abort', leave, { once: true });
|
||||
entry.place
|
||||
.then(resolve, reject)
|
||||
.finally(() => signal.removeEventListener('abort', leave));
|
||||
});
|
||||
}
|
||||
return {
|
||||
async resolve(name, { signal } = {}) {
|
||||
signal?.throwIfAborted();
|
||||
const key = name.trim().toLowerCase();
|
||||
let entry = cache.get(key);
|
||||
if (entry && now() - entry.at < cacheMs) {
|
||||
cache.delete(key);
|
||||
cache.set(key, entry);
|
||||
} else entry = start(key, name);
|
||||
return join(key, entry, signal);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Convert one geocode result to a place with bounds, or null. */
|
||||
export function placeFromGeocodeResult(result) {
|
||||
const location = result?.geometry?.location;
|
||||
if (!Number.isFinite(location?.lat) || !Number.isFinite(location?.lng))
|
||||
return null;
|
||||
const viewport = result.geometry.viewport;
|
||||
const bounds =
|
||||
viewport &&
|
||||
[
|
||||
viewport.southwest?.lat,
|
||||
viewport.southwest?.lng,
|
||||
viewport.northeast?.lat,
|
||||
viewport.northeast?.lng,
|
||||
].every(Number.isFinite)
|
||||
? {
|
||||
west: viewport.southwest.lng,
|
||||
south: viewport.southwest.lat,
|
||||
east: viewport.northeast.lng,
|
||||
north: viewport.northeast.lat,
|
||||
}
|
||||
: {
|
||||
west: Math.max(-180, location.lng - POINT_RADIUS_DEGREES),
|
||||
south: Math.max(-90, location.lat - POINT_RADIUS_DEGREES),
|
||||
east: Math.min(180, location.lng + POINT_RADIUS_DEGREES),
|
||||
north: Math.min(90, location.lat + POINT_RADIUS_DEGREES),
|
||||
};
|
||||
return {
|
||||
name: result.formatted_address || null,
|
||||
bounds,
|
||||
point: { lat: location.lat, lon: location.lng },
|
||||
};
|
||||
}
|
||||
|
||||
/** Search points of interest; `configured` is false when the server has no search key. */
|
||||
export function createPlaceSearchService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
async function read(path, params, signal) {
|
||||
const response = await fetchImpl(`${path}?${new URLSearchParams(params)}`, {
|
||||
signal,
|
||||
});
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* the status below is authoritative */
|
||||
}
|
||||
if (!response.ok) throw httpFailure(response, 'Place search');
|
||||
return {
|
||||
configured: payload?.configured !== false,
|
||||
places: Array.isArray(payload?.places) ? payload.places : [],
|
||||
};
|
||||
}
|
||||
return {
|
||||
search(query, { latitude, longitude, radiusM }, { signal } = {}) {
|
||||
return read(
|
||||
'/api/google/text-search',
|
||||
{ q: query, lat: latitude, lon: longitude, radiusM },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
nearby({ latitude, longitude, radiusM }, { signal } = {}) {
|
||||
return read(
|
||||
'/api/google/nearby-places',
|
||||
{ lat: latitude, lon: longitude, radiusM },
|
||||
signal,
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Plan routes between `[{ lat, lon }]` points; resolves `{ ok, ... }`. */
|
||||
export function createRouteService({
|
||||
fetchImpl = (...args) => globalThis.fetch(...args),
|
||||
} = {}) {
|
||||
return {
|
||||
async route(points, profile, { signal } = {}) {
|
||||
const coords = points
|
||||
.map((point) => `${point.lon.toFixed(6)},${point.lat.toFixed(6)}`)
|
||||
.join(';');
|
||||
const response = await fetchImpl(
|
||||
`/api/route?${new URLSearchParams({ profile, coords })}`,
|
||||
{ signal },
|
||||
);
|
||||
let payload = null;
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch {
|
||||
/* the status below is authoritative */
|
||||
}
|
||||
if (response.status === 429) throw httpFailure(response, 'Routing');
|
||||
if (payload?.ok === false || response.status === 400)
|
||||
return { ok: false, error: payload?.error || 'no route found' };
|
||||
if (!response.ok) throw httpFailure(response, 'Routing');
|
||||
return payload;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function httpFailure(response, label) {
|
||||
if (response.status === 429) {
|
||||
const seconds = Number(response.headers.get('retry-after'));
|
||||
return new ToolError('retry_later', `${label} is rate limited`, {
|
||||
retryAfterSeconds:
|
||||
Number.isFinite(seconds) && seconds > 0 ? seconds : null,
|
||||
});
|
||||
}
|
||||
return new Error(`${label} HTTP ${response.status}`);
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { createGeocodePlaceService } from './places.js';
|
||||
|
||||
const answer = {
|
||||
results: [
|
||||
{
|
||||
formatted_address: 'Tokyo, Japan',
|
||||
geometry: { location: { lat: 35.68, lng: 139.76 } },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
test('place lookups are cached by name for a while, and failures are not', async () => {
|
||||
let clock = 0;
|
||||
const requests = [];
|
||||
let fail = true;
|
||||
const places = createGeocodePlaceService({
|
||||
now: () => clock,
|
||||
cacheMs: 1000,
|
||||
fetchImpl: async (url) => {
|
||||
requests.push(url);
|
||||
if (fail) return new Response(null, { status: 503 });
|
||||
return Response.json(answer);
|
||||
},
|
||||
});
|
||||
await assert.rejects(places.resolve('Tokyo'), /Geocode HTTP 503/);
|
||||
fail = false;
|
||||
const first = await places.resolve('Tokyo');
|
||||
assert.deepEqual(first.point, { lat: 35.68, lon: 139.76 });
|
||||
assert.equal(await places.resolve(' tokyo '), first);
|
||||
assert.equal(requests.length, 2);
|
||||
clock = 2000;
|
||||
await places.resolve('Tokyo');
|
||||
assert.equal(requests.length, 3);
|
||||
const aborted = new AbortController();
|
||||
aborted.abort();
|
||||
await assert.rejects(places.resolve('Tokyo', { signal: aborted.signal }));
|
||||
});
|
||||
|
||||
test('a lookup every caller abandoned is cancelled and not reused', async () => {
|
||||
const signals = [];
|
||||
const places = createGeocodePlaceService({
|
||||
fetchImpl: (url, { signal }) => {
|
||||
signals.push(signal);
|
||||
if (signals.length === 1)
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
return Promise.resolve(Response.json(answer));
|
||||
},
|
||||
});
|
||||
const caller = new AbortController();
|
||||
const first = places.resolve('Tokyo', { signal: caller.signal });
|
||||
caller.abort();
|
||||
await assert.rejects(first);
|
||||
assert.equal(signals[0].aborted, true);
|
||||
const again = await places.resolve('Tokyo');
|
||||
assert.deepEqual(again.point, { lat: 35.68, lon: 139.76 });
|
||||
assert.equal(signals.length, 2);
|
||||
});
|
||||
|
||||
test('a shared lookup outlives one cancelled caller, and times out on its own', async () => {
|
||||
let release;
|
||||
const signals = [];
|
||||
const places = createGeocodePlaceService({
|
||||
timeoutMs: 50,
|
||||
fetchImpl: (url, { signal }) => {
|
||||
signals.push(signal);
|
||||
if (url.includes('Slow'))
|
||||
return new Promise((resolve, reject) =>
|
||||
signal.addEventListener('abort', () => reject(signal.reason)),
|
||||
);
|
||||
return new Promise((resolve) => {
|
||||
release = () => resolve(Response.json(answer));
|
||||
});
|
||||
},
|
||||
});
|
||||
const leaving = new AbortController();
|
||||
const gone = places.resolve('Tokyo', { signal: leaving.signal });
|
||||
const staying = places.resolve('Tokyo', {
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
leaving.abort();
|
||||
await assert.rejects(gone);
|
||||
assert.equal(signals[0].aborted, false);
|
||||
release();
|
||||
assert.deepEqual((await staying).point, { lat: 35.68, lon: 139.76 });
|
||||
await assert.rejects(
|
||||
places.resolve('Slow'),
|
||||
(error) => error.name === 'TimeoutError',
|
||||
);
|
||||
await places.resolve('Slow').catch(() => {});
|
||||
assert.equal(signals.length, 3, 'a timed-out lookup is not cached');
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
/** Weather map imagery (radar, satellite, lightning) and wind at a point. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { windFrom } from '../../layers/wind/inspection.js';
|
||||
import { sampleWind, windSpeed } from '../../layers/wind/model.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaRadiusKm,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
import { toBase64 } from '../results.js';
|
||||
|
||||
/** Map names offered to callers, and the weather products behind them. */
|
||||
const MAPS = {
|
||||
radar: 'radar',
|
||||
satellite: 'clouds-regional',
|
||||
global_satellite: 'clouds',
|
||||
lightning: 'lightning',
|
||||
};
|
||||
/** The app layer that shows each map. */
|
||||
const MAP_LAYERS = {
|
||||
radar: 'weather-radar',
|
||||
satellite: 'weather-satellite',
|
||||
global_satellite: 'weather-satellite',
|
||||
lightning: 'weather-lightning',
|
||||
};
|
||||
const IMAGE_SIZE = { width: 1024, height: 512 };
|
||||
const SNAP_DEGREES = 0.25;
|
||||
const MIN_HALF_HEIGHT_DEGREES = 0.5;
|
||||
const KM_PER_DEGREE = 111.2;
|
||||
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
|
||||
const snapDown = (value) => Math.floor(value / SNAP_DEGREES) * SNAP_DEGREES;
|
||||
const snapUp = (value) => Math.ceil(value / SNAP_DEGREES) * SNAP_DEGREES;
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/**
|
||||
* A 2:1 box snapped to 0.25° around the area, moved inside the product's
|
||||
* bounds. Returns null when no such box covers the whole area (it would not
|
||||
* fit, or the area crosses the antimeridian), meaning the whole extent
|
||||
* should be shown instead.
|
||||
*/
|
||||
export function weatherWindow(area, bounds) {
|
||||
if (area.west > area.east) return null;
|
||||
const center = areaCenter(area);
|
||||
const halfHeight = Math.max(
|
||||
MIN_HALF_HEIGHT_DEGREES,
|
||||
areaRadiusKm(area) / KM_PER_DEGREE,
|
||||
);
|
||||
const height = snapUp(halfHeight * 2);
|
||||
const width = height * 2;
|
||||
if (width > bounds.east - bounds.west || height > bounds.north - bounds.south)
|
||||
return null;
|
||||
let west = snapDown(center.lon - width / 2);
|
||||
let south = snapDown(center.lat - height / 2);
|
||||
west = Math.min(
|
||||
Math.max(west, snapUp(bounds.west)),
|
||||
snapDown(bounds.east - width),
|
||||
);
|
||||
south = Math.min(
|
||||
Math.max(south, snapUp(bounds.south)),
|
||||
snapDown(bounds.north - height),
|
||||
);
|
||||
const mapBox = { west, south, east: west + width, north: south + height };
|
||||
const insideProduct =
|
||||
mapBox.west >= bounds.west &&
|
||||
mapBox.east <= bounds.east &&
|
||||
mapBox.south >= bounds.south &&
|
||||
mapBox.north <= bounds.north;
|
||||
// Moving the box inside the product can uncover part of the area; only a
|
||||
// box that still holds the whole area (clipped to the product) will do.
|
||||
const coversArea =
|
||||
mapBox.west <= Math.max(area.west, bounds.west) &&
|
||||
mapBox.east >= Math.min(area.east, bounds.east) &&
|
||||
mapBox.south <= Math.max(area.south, bounds.south) &&
|
||||
mapBox.north >= Math.min(area.north, bounds.north);
|
||||
return insideProduct && coversArea ? mapBox : null;
|
||||
}
|
||||
|
||||
export const getWeatherMap = defineTool({
|
||||
name: 'get_weather_map',
|
||||
title: 'Weather map',
|
||||
description:
|
||||
'The latest NOAA weather map image over an area: precipitation radar ' +
|
||||
'(contiguous US), regional or global infrared satellite clouds, or ' +
|
||||
'lightning density (the Americas). Returns the image itself.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
map: { type: 'string', enum: Object.keys(MAPS) },
|
||||
area: AREA_SCHEMA,
|
||||
},
|
||||
required: ['map'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weatherMaps'],
|
||||
async run(args, { services, signal }) {
|
||||
const product = MAPS[args.map];
|
||||
const snapshot = await services.weatherMaps.getSnapshot({
|
||||
product,
|
||||
signal,
|
||||
});
|
||||
if (snapshot.unavailable)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
`The ${args.map} map is unavailable right now`,
|
||||
);
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
let mapBox = null;
|
||||
if (area) {
|
||||
const { bounds } = snapshot;
|
||||
const center = areaCenter(area);
|
||||
if (
|
||||
center.lon < bounds.west ||
|
||||
center.lon > bounds.east ||
|
||||
center.lat < bounds.south ||
|
||||
center.lat > bounds.north
|
||||
)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`${area.label} is outside the ${args.map} map's coverage: ${snapshot.coverage || 'see its bounds'}`,
|
||||
);
|
||||
mapBox = weatherWindow(area, bounds);
|
||||
}
|
||||
const image = await services.weatherMaps.getImage({
|
||||
product,
|
||||
time: snapshot.latest,
|
||||
size: IMAGE_SIZE,
|
||||
bbox: mapBox,
|
||||
signal,
|
||||
});
|
||||
if (!/^image\/(png|jpeg)$/.test(image.contentType))
|
||||
throw new ToolError('unavailable', 'The weather map returned no image');
|
||||
if (image.bytes.byteLength > MAX_IMAGE_BYTES)
|
||||
throw new ToolError('unavailable', 'The weather map image is too large');
|
||||
const where = area ? ` over ${area.label}` : '';
|
||||
return {
|
||||
summary: `${snapshot.title || args.map} at ${snapshot.latest}${where}.`,
|
||||
data: {
|
||||
view: suggestView(
|
||||
services,
|
||||
area
|
||||
? { area, layers: [MAP_LAYERS[args.map]] }
|
||||
: {
|
||||
point: {
|
||||
lat: (snapshot.bounds.south + snapshot.bounds.north) / 2,
|
||||
lon: (snapshot.bounds.west + snapshot.bounds.east) / 2,
|
||||
},
|
||||
altitudeM: 8_000_000,
|
||||
layers: [MAP_LAYERS[args.map]],
|
||||
},
|
||||
),
|
||||
map: args.map,
|
||||
title: snapshot.title ?? null,
|
||||
description: snapshot.description ?? null,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
time: snapshot.latest,
|
||||
box: mapBox ?? snapshot.bounds,
|
||||
source: snapshot.source ?? null,
|
||||
attribution: snapshot.attribution ?? null,
|
||||
},
|
||||
images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getWind = defineTool({
|
||||
name: 'get_wind',
|
||||
title: 'Wind',
|
||||
description:
|
||||
'Forecast wind 10 m above ground at a location from the GFS or IFS ' +
|
||||
'global model: speed and the direction it blows from.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
model: { type: 'string', enum: ['gfs', 'ifs'] },
|
||||
},
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['wind'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const model = args.model ?? 'gfs';
|
||||
const snapshot = await services.wind.getSnapshot({ model, signal });
|
||||
if (snapshot.unavailable || !snapshot.u || !snapshot.v)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Wind forecasts are unavailable right now',
|
||||
);
|
||||
const vector = sampleWind(
|
||||
{ ...snapshot.grid, u: snapshot.u, v: snapshot.v },
|
||||
point.lon,
|
||||
point.lat,
|
||||
);
|
||||
const speed = windSpeed(vector.u, vector.v);
|
||||
const from = windFrom(vector.u, vector.v);
|
||||
const kph = speed * 3.6;
|
||||
return {
|
||||
summary:
|
||||
`Wind at ${point.label}: ${kph.toFixed(0)} km/h` +
|
||||
(from === 'Calm' ? ', calm.' : ` from the ${from}.`),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
point,
|
||||
altitudeM: 2_000_000,
|
||||
layers: ['wind'],
|
||||
}),
|
||||
location: point,
|
||||
speed_mps: round(speed, 1),
|
||||
speed_kph: round(kph, 1),
|
||||
from: from === 'Calm' ? null : from,
|
||||
calm: from === 'Calm',
|
||||
u_mps: round(vector.u, 2),
|
||||
v_mps: round(vector.v, 2),
|
||||
level: snapshot.level ?? '10 m above ground',
|
||||
model,
|
||||
cycle: snapshot.cycle ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,174 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { weatherWindow } from './atmosphere.js';
|
||||
import { resolveArea } from '../area.js';
|
||||
|
||||
const CONUS = { west: -130, south: 20, east: -60, north: 55 };
|
||||
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const weatherMaps = (snapshot = {}) => ({
|
||||
requests: [],
|
||||
async getSnapshot({ product }) {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
product,
|
||||
title: 'MRMS radar',
|
||||
coverage: 'Contiguous United States',
|
||||
source: 'NOAA nowCOAST',
|
||||
attribution: 'NOAA',
|
||||
bounds: CONUS,
|
||||
times: ['2026-10-01T19:00:00.000Z'],
|
||||
latest: '2026-10-01T19:00:00.000Z',
|
||||
...snapshot,
|
||||
};
|
||||
},
|
||||
async getImage(request) {
|
||||
this.requests.push(request);
|
||||
return { contentType: 'image/png', bytes: PNG };
|
||||
},
|
||||
});
|
||||
|
||||
test('weather windows are 2:1, snapped to 0.25° and kept inside the bounds', () => {
|
||||
const austin = weatherWindow(
|
||||
{
|
||||
center: { lat: 30.27, lon: -97.74, radiusKm: 50 },
|
||||
west: -98.3,
|
||||
east: -97.2,
|
||||
south: 29.8,
|
||||
north: 30.7,
|
||||
},
|
||||
CONUS,
|
||||
);
|
||||
assert.deepEqual(austin, {
|
||||
west: -98.75,
|
||||
south: 29.75,
|
||||
east: -96.75,
|
||||
north: 30.75,
|
||||
});
|
||||
const edge = weatherWindow(
|
||||
{
|
||||
center: { lat: 21, lon: -129, radiusKm: 200 },
|
||||
west: -131,
|
||||
east: -127,
|
||||
south: 19,
|
||||
north: 23,
|
||||
},
|
||||
CONUS,
|
||||
);
|
||||
assert.equal(edge.west, -130);
|
||||
assert.equal(edge.south, 20);
|
||||
assert.equal((edge.east - edge.west) / (edge.north - edge.south), 2);
|
||||
assert.equal(
|
||||
weatherWindow(
|
||||
{ bbox: true, west: -180, east: 180, south: -80, north: 80 },
|
||||
CONUS,
|
||||
),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('weather maps return the latest frame as an image over the area', async () => {
|
||||
const maps = weatherMaps();
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { weatherMaps: maps },
|
||||
});
|
||||
const result = await catalog.call('get_weather_map', {
|
||||
map: 'radar',
|
||||
area: { lat: 30.27, lon: -97.74, radius_km: 50 },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'MRMS radar at 2026-10-01T19:00:00.000Z over 50 km around 30.270, -97.740.',
|
||||
);
|
||||
assert.deepEqual(result.images, [
|
||||
{ mimeType: 'image/png', data: 'iVBORw==' },
|
||||
]);
|
||||
assert.deepEqual(maps.requests[0], {
|
||||
product: 'radar',
|
||||
time: '2026-10-01T19:00:00.000Z',
|
||||
size: { width: 1024, height: 512 },
|
||||
bbox: { west: -98.75, south: 29.75, east: -96.75, north: 30.75 },
|
||||
signal: undefined,
|
||||
});
|
||||
const whole = await catalog.call('get_weather_map', { map: 'satellite' });
|
||||
assert.equal(maps.requests[1].product, 'clouds-regional');
|
||||
assert.equal(maps.requests[1].bbox, null);
|
||||
assert.deepEqual(whole.data.box, CONUS);
|
||||
await assert.rejects(
|
||||
catalog.call('get_weather_map', {
|
||||
map: 'radar',
|
||||
area: { lat: 51.5, lon: -0.1, radius_km: 50 },
|
||||
}),
|
||||
/outside the radar map's coverage: Contiguous United States/,
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { weatherMaps: weatherMaps({ unavailable: true }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_weather_map', { map: 'lightning' }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('wind is sampled from the model grid at the location', async () => {
|
||||
// A 1° global grid where every cell blows 10 m/s toward the east (from the west).
|
||||
const nx = 360;
|
||||
const ny = 181;
|
||||
const u = new Float32Array(nx * ny).fill(10);
|
||||
const v = new Float32Array(nx * ny).fill(0);
|
||||
const wind = {
|
||||
requested: [],
|
||||
async getSnapshot({ model }) {
|
||||
this.requested.push(model);
|
||||
return {
|
||||
model,
|
||||
cycle: '2026-10-01T12:00:00Z',
|
||||
level: '10 m above ground',
|
||||
grid: { nx, ny, lo1: 0, la1: 90, dx: 1, dy: 1 },
|
||||
u,
|
||||
v,
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { wind } });
|
||||
const result = await catalog.call('get_wind', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
model: 'ifs',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Wind at 30.2700, -97.7400: 36 km/h from the W.',
|
||||
);
|
||||
assert.equal(result.data.speed_mps, 10);
|
||||
assert.equal(result.data.from, 'W');
|
||||
assert.deepEqual(wind.requested, ['ifs']);
|
||||
u.fill(0);
|
||||
const calm = await catalog.call('get_wind', { location: { lat: 0, lon: 0 } });
|
||||
assert.equal(calm.summary, 'Wind at 0.0000, 0.0000: 0 km/h, calm.');
|
||||
assert.equal(calm.data.calm, true);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { wind: { getSnapshot: async () => ({ unavailable: true }) } },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_wind', { location: { lat: 0, lon: 0 } }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('a weather window that cannot cover the area falls back to the whole map', async () => {
|
||||
const world = { west: -180, south: -90, east: 180, north: 90 };
|
||||
// 100 km around 179.8° crosses the antimeridian.
|
||||
const dateline = await resolveArea({ lat: 0, lon: 179.8, radius_km: 100 });
|
||||
assert.equal(weatherWindow(dateline, world), null);
|
||||
// Near a product edge the window moves inside and still covers the area.
|
||||
const conus = { west: -130, south: 20, east: -60, north: 55 };
|
||||
const edge = await resolveArea({ lat: 40, lon: -129.5, radius_km: 50 });
|
||||
const box = weatherWindow(edge, conus);
|
||||
assert.ok(box);
|
||||
assert.ok(box.west <= Math.max(edge.west, conus.west));
|
||||
assert.ok(box.east >= edge.east);
|
||||
assert.ok(box.south <= edge.south && box.north >= edge.north);
|
||||
});
|
||||
@@ -0,0 +1,387 @@
|
||||
/** Aviation queries over the live aircraft sources (src/sources/live). */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
|
||||
const ICAO24_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
pattern: '^[0-9a-fA-F]{6}$',
|
||||
description: 'ICAO 24-bit address as six hex characters, such as "a1b2c3".',
|
||||
});
|
||||
const CALLSIGN_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
pattern: '^[A-Za-z0-9]{2,8}$',
|
||||
description: 'Flight callsign, such as "UAL123".',
|
||||
});
|
||||
const MILITARY_SCHEMA = Object.freeze({
|
||||
type: 'boolean',
|
||||
description: 'Use the military aircraft feed instead of the general feed.',
|
||||
});
|
||||
const MAX_TRACK_POINTS = 200;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/** The common row for an aircraft record from any live aircraft source. */
|
||||
function aircraftRow(record, from) {
|
||||
const point = { lat: record.latitude, lon: record.longitude };
|
||||
return {
|
||||
id: record.id,
|
||||
callsign: record.callsign || null,
|
||||
lat: round(record.latitude, 5),
|
||||
lon: round(record.longitude, 5),
|
||||
altitude_m: round(record.baroAltitudeM ?? record.ellipsoidAltitudeM, 0),
|
||||
on_ground: record.onGround === true,
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
course_deg: round(record.courseDeg, 0),
|
||||
vertical_rate_mps: round(record.verticalRateMps, 1),
|
||||
type_code: record.typeCode || null,
|
||||
registration: record.registration || null,
|
||||
operator: record.operator || null,
|
||||
origin_country: record.originCountry || null,
|
||||
last_contact: isoTime(record.contactTimeMs ?? record.positionTimeMs),
|
||||
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function snapshotInfo(snapshot) {
|
||||
return {
|
||||
source: snapshot.source ?? null,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
observed_at: isoTime(snapshot.observedAtMs),
|
||||
freshness: snapshot.freshness ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
function feedFor(services, military) {
|
||||
if (!military) return services.aircraft;
|
||||
if (!services.military)
|
||||
throw new ToolError(
|
||||
'unsupported',
|
||||
'The military aircraft feed is not available here',
|
||||
);
|
||||
return services.military;
|
||||
}
|
||||
|
||||
export const aircraftInArea = defineTool({
|
||||
name: 'aircraft_in_area',
|
||||
title: 'Aircraft in an area',
|
||||
description:
|
||||
'Aircraft currently reported inside an area, nearest the center first. ' +
|
||||
'Optionally military aircraft only, airborne only, or within an altitude band.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
military: MILITARY_SCHEMA,
|
||||
airborne_only: { type: 'boolean' },
|
||||
min_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
max_altitude_m: { type: 'number', minimum: -500, maximum: 30000 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const snapshot = await feedFor(services, args.military).getSnapshot(
|
||||
{ latitude: center.lat, longitude: center.lon },
|
||||
{ signal },
|
||||
);
|
||||
const altitude = (record) =>
|
||||
record.baroAltitudeM ?? record.ellipsoidAltitudeM;
|
||||
const rows = snapshot.records
|
||||
.filter(
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
!(args.airborne_only && record.onGround) &&
|
||||
(args.min_altitude_m == null ||
|
||||
altitude(record) >= args.min_altitude_m) &&
|
||||
(args.max_altitude_m == null ||
|
||||
altitude(record) <= args.max_altitude_m),
|
||||
)
|
||||
.map((record) => aircraftRow(record, center))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const kind = args.military ? 'military aircraft' : 'aircraft';
|
||||
// A regional fallback feed covers only part of the world around the
|
||||
// area's center; say so rather than imply a worldwide answer.
|
||||
const regional = /regional/i.test(snapshot.coverage || '');
|
||||
const notes = [
|
||||
...(regional ? [`regional feed: ${snapshot.coverage}`] : []),
|
||||
...(snapshot.freshness === 'stale' ? ['data may be stale'] : []),
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${rows.length} ${kind} in ${area.label}` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area,
|
||||
layers: [args.military ? 'military' : 'flights'],
|
||||
}),
|
||||
...capRows(rows, args.limit),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const findAircraft = defineTool({
|
||||
name: 'find_aircraft',
|
||||
title: 'Find an aircraft',
|
||||
description:
|
||||
'Find aircraft currently reported anywhere by callsign, ICAO 24-bit ' +
|
||||
'address or registration. Registrations are known only for aircraft in ' +
|
||||
'the military feed.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
callsign: CALLSIGN_SCHEMA,
|
||||
icao24: ICAO24_SCHEMA,
|
||||
registration: { type: 'string', minLength: 2, maxLength: 12 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
const given = ['callsign', 'icao24', 'registration'].filter(
|
||||
(key) => args[key] != null,
|
||||
);
|
||||
if (given.length !== 1)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'Give exactly one of callsign, icao24 or registration',
|
||||
);
|
||||
const [key] = given;
|
||||
const wanted = args[key].trim().toUpperCase();
|
||||
const field = {
|
||||
callsign: 'callsign',
|
||||
icao24: 'id',
|
||||
registration: 'registration',
|
||||
}[key];
|
||||
// Only the military feed reports registrations.
|
||||
const feeds = [
|
||||
...(key === 'registration'
|
||||
? []
|
||||
: [{ name: 'civil', feed: services.aircraft }]),
|
||||
{ name: 'military', feed: services.military },
|
||||
].filter(({ feed }) => feed);
|
||||
if (!feeds.length)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'The military aircraft feed is not available here',
|
||||
);
|
||||
const settled = await Promise.allSettled(
|
||||
feeds.map(({ feed }) => feed.getSnapshot({}, { signal })),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
// A search succeeds when any feed answers; missing feeds are named.
|
||||
const unavailable = feeds
|
||||
.filter((_, index) => settled[index].status === 'rejected')
|
||||
.map(({ name }) => name);
|
||||
if (unavailable.length === feeds.length) throw settled[0].reason;
|
||||
const seen = new Set();
|
||||
const matches = settled
|
||||
.flatMap((result, index) =>
|
||||
result.status === 'fulfilled'
|
||||
? result.value.records.map((record) => ({
|
||||
record,
|
||||
feed: feeds[index].name,
|
||||
}))
|
||||
: [],
|
||||
)
|
||||
.filter(
|
||||
({ record }) =>
|
||||
String(record[field] || '')
|
||||
.trim()
|
||||
.toUpperCase() === wanted,
|
||||
)
|
||||
.filter(({ record }) => !seen.has(record.id) && seen.add(record.id));
|
||||
const rows = matches.map(({ record }) => aircraftRow(record));
|
||||
// One match is shown followed; several are not framed.
|
||||
const only = matches.length === 1 ? matches[0] : null;
|
||||
// Each feed that answered, with its own freshness and coverage.
|
||||
const answered = feeds.flatMap(({ name }, index) =>
|
||||
settled[index].status === 'fulfilled'
|
||||
? [{ feed: name, ...snapshotInfo(settled[index].value) }]
|
||||
: [],
|
||||
);
|
||||
const stale = answered
|
||||
.filter((feed) => feed.freshness === 'stale')
|
||||
.map((feed) => feed.feed);
|
||||
return {
|
||||
summary:
|
||||
(rows.length
|
||||
? `Found ${countNoun(rows.length, 'aircraft', 'aircraft')} with ${key} ${wanted}.`
|
||||
: `No aircraft with ${key} ${wanted} is currently reported.`) +
|
||||
(unavailable.length
|
||||
? ` The ${unavailable.join(' and ')} feed did not answer.`
|
||||
: '') +
|
||||
(stale.length
|
||||
? ` The ${stale.join(' and ')} feed data may be stale.`
|
||||
: ''),
|
||||
data: {
|
||||
view: only
|
||||
? suggestView(services, {
|
||||
point: { lat: only.record.latitude, lon: only.record.longitude },
|
||||
follow: {
|
||||
kind:
|
||||
only.feed === 'military' ? 'military_aircraft' : 'aircraft',
|
||||
id: only.record.id,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
...capRows(rows, args.limit),
|
||||
unavailable_feeds: unavailable,
|
||||
stale_feeds: stale,
|
||||
feeds: answered,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getAircraftTrack = defineTool({
|
||||
name: 'get_aircraft_track',
|
||||
title: 'Aircraft track',
|
||||
description:
|
||||
'Recent positions of one aircraft by ICAO 24-bit address, oldest first. ' +
|
||||
`Long tracks are evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { icao24: ICAO24_SCHEMA, military: MILITARY_SCHEMA },
|
||||
required: ['icao24'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
const icao24 = args.icao24.toLowerCase();
|
||||
// A track server answers 404 for an aircraft it has no positions for.
|
||||
const track = await feedFor(services, args.military)
|
||||
.getTrack(icao24, { signal })
|
||||
.catch((error) => {
|
||||
if (error?.status === 404) return { records: [] };
|
||||
throw error;
|
||||
});
|
||||
const points = [...track.records].sort(
|
||||
(a, b) => a.observedAtMs - b.observedAtMs,
|
||||
);
|
||||
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
||||
const first = points[0];
|
||||
const last = points.at(-1);
|
||||
return {
|
||||
summary: points.length
|
||||
? `${countNoun(points.length, 'position')} for ${icao24} from ${isoTime(first.observedAtMs)} to ${isoTime(last.observedAtMs)}.`
|
||||
: `No recent track is available for ${icao24}.`,
|
||||
data: {
|
||||
view: last
|
||||
? suggestView(services, {
|
||||
point: { lat: last.latitude, lon: last.longitude },
|
||||
follow: {
|
||||
kind: args.military ? 'military_aircraft' : 'aircraft',
|
||||
id: icao24,
|
||||
},
|
||||
})
|
||||
: null,
|
||||
icao24,
|
||||
total: points.length,
|
||||
returned: kept.length,
|
||||
thinned: kept.length < points.length,
|
||||
points: kept.map((point) => ({
|
||||
time: isoTime(point.observedAtMs),
|
||||
lat: round(point.latitude, 5),
|
||||
lon: round(point.longitude, 5),
|
||||
altitude_m: round(point.baroAltitudeM, 0),
|
||||
on_ground: point.onGround === true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getAircraftInfo = defineTool({
|
||||
name: 'get_aircraft_info',
|
||||
title: 'Aircraft type and route',
|
||||
description:
|
||||
'Look up an aircraft type and registration by ICAO 24-bit address, and ' +
|
||||
'a flight route by callsign, from adsbdb. Give either or both.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { icao24: ICAO24_SCHEMA, callsign: CALLSIGN_SCHEMA },
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['aircraft'],
|
||||
async run(args, { services, signal }) {
|
||||
if (args.icao24 == null && args.callsign == null)
|
||||
throw new ToolError('invalid_arguments', 'Give icao24, callsign or both');
|
||||
const lookup = (kind, id) =>
|
||||
id == null
|
||||
? null
|
||||
: services.aircraft.getEnrichment({ kind, id }, { signal });
|
||||
const icao24 = args.icao24?.toLowerCase();
|
||||
const callsign = args.callsign?.toUpperCase();
|
||||
const [type, route] = await Promise.all([
|
||||
lookup('type', icao24),
|
||||
lookup('route', callsign),
|
||||
]);
|
||||
const aircraft = type?.found
|
||||
? {
|
||||
icao24,
|
||||
type_code: type.typeCode ?? null,
|
||||
type_name: type.typeName ?? null,
|
||||
registration: type.registration ?? null,
|
||||
}
|
||||
: null;
|
||||
const airport = (value) =>
|
||||
value
|
||||
? {
|
||||
code: value.code || null,
|
||||
name: value.name || null,
|
||||
lat: value.lat ?? null,
|
||||
lon: value.lon ?? null,
|
||||
}
|
||||
: null;
|
||||
const flight = route?.found
|
||||
? {
|
||||
callsign,
|
||||
airline: route.airline ?? null,
|
||||
origin: airport(route.origin),
|
||||
destination: airport(route.destination),
|
||||
}
|
||||
: null;
|
||||
const parts = [];
|
||||
if (icao24)
|
||||
parts.push(
|
||||
aircraft
|
||||
? `aircraft ${icao24} is ${aircraft.type_name || aircraft.type_code || 'an aircraft of unknown type'}${aircraft.registration ? ` (${aircraft.registration})` : ''}`
|
||||
: `no type is known for ${icao24}`,
|
||||
);
|
||||
if (callsign)
|
||||
parts.push(
|
||||
flight
|
||||
? `flight ${callsign} flies ${flight.origin?.code || flight.origin?.name} to ${flight.destination?.code || flight.destination?.name}${flight.airline ? ` (${flight.airline})` : ''}`
|
||||
: `no route is known for ${callsign}`,
|
||||
);
|
||||
const summary = parts.join('; ');
|
||||
return {
|
||||
summary: `${summary.charAt(0).toUpperCase()}${summary.slice(1)}.`,
|
||||
data: { aircraft, route: flight },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,390 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
|
||||
const record = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
reference: id,
|
||||
latitude,
|
||||
longitude,
|
||||
callsign: null,
|
||||
originCountry: null,
|
||||
positionTimeMs: Date.UTC(2026, 0, 1),
|
||||
contactTimeMs: Date.UTC(2026, 0, 1, 0, 0, 5),
|
||||
baroAltitudeM: 10000,
|
||||
ellipsoidAltitudeM: null,
|
||||
onGround: false,
|
||||
speedMps: 230.04,
|
||||
courseDeg: 90.4,
|
||||
verticalRateMps: 0,
|
||||
category: null,
|
||||
typeCode: null,
|
||||
registration: null,
|
||||
operator: null,
|
||||
...extra,
|
||||
});
|
||||
const snapshot = (records, extra = {}) => ({
|
||||
records,
|
||||
complete: true,
|
||||
source: 'Test feed',
|
||||
coverage: 'test coverage',
|
||||
observedAtMs: Date.UTC(2026, 0, 1),
|
||||
freshness: 'current',
|
||||
...extra,
|
||||
});
|
||||
const feed = (records, { track = [], enrichment = {}, ...extra } = {}) => {
|
||||
const calls = [];
|
||||
return {
|
||||
calls,
|
||||
async getSnapshot(query) {
|
||||
calls.push(query);
|
||||
return snapshot(records, extra);
|
||||
},
|
||||
async getTrack(reference) {
|
||||
calls.push(reference);
|
||||
return { records: track, complete: false };
|
||||
},
|
||||
async getEnrichment({ kind, id }) {
|
||||
calls.push(`${kind}:${id}`);
|
||||
return enrichment[`${kind}:${id}`] ?? { found: false };
|
||||
},
|
||||
};
|
||||
};
|
||||
const civil = [
|
||||
record('aaa111', 37.7, -122.4, {
|
||||
callsign: 'UAL123',
|
||||
originCountry: 'United States',
|
||||
}),
|
||||
record('bbb222', 37.58, -122.28, { onGround: true, baroAltitudeM: 0 }),
|
||||
record('ccc333', 51.5, -0.1, { callsign: 'BAW1' }),
|
||||
];
|
||||
const military = [
|
||||
record('ae0001', 37.6, -122.3, {
|
||||
callsign: 'RCH123',
|
||||
registration: '07-7175',
|
||||
typeCode: 'C17',
|
||||
}),
|
||||
];
|
||||
const bay = { lat: 37.6, lon: -122.3, radius_km: 60 };
|
||||
|
||||
test('aircraft in an area are filtered, nearest first, with source details', async () => {
|
||||
const aircraft = feed(civil);
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||
const result = await catalog.call('aircraft_in_area', { area: bay });
|
||||
assert.equal(result.summary, '2 aircraft in 60 km around 37.600, -122.300.');
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['bbb222', 'aaa111'],
|
||||
);
|
||||
assert.deepEqual(aircraft.calls[0], { latitude: 37.6, longitude: -122.3 });
|
||||
assert.deepEqual(result.data.rows[1], {
|
||||
id: 'aaa111',
|
||||
callsign: 'UAL123',
|
||||
lat: 37.7,
|
||||
lon: -122.4,
|
||||
altitude_m: 10000,
|
||||
on_ground: false,
|
||||
speed_mps: 230,
|
||||
course_deg: 90,
|
||||
vertical_rate_mps: 0,
|
||||
type_code: null,
|
||||
registration: null,
|
||||
operator: null,
|
||||
origin_country: 'United States',
|
||||
last_contact: '2026-01-01T00:00:05.000Z',
|
||||
distance_km: 14.2,
|
||||
});
|
||||
assert.equal(result.data.source, 'Test feed');
|
||||
assert.equal(result.data.observed_at, '2026-01-01T00:00:00.000Z');
|
||||
|
||||
const airborne = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
airborne_only: true,
|
||||
});
|
||||
assert.deepEqual(
|
||||
airborne.data.rows.map((row) => row.id),
|
||||
['aaa111'],
|
||||
);
|
||||
const low = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
max_altitude_m: 100,
|
||||
});
|
||||
assert.deepEqual(
|
||||
low.data.rows.map((row) => row.id),
|
||||
['bbb222'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the military feed is used on request and reported when missing', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
aircraft: feed(civil),
|
||||
military: feed(military, { freshness: 'stale' }),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('aircraft_in_area', {
|
||||
area: bay,
|
||||
military: true,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'1 military aircraft in 60 km around 37.600, -122.300 (data may be stale).',
|
||||
);
|
||||
assert.equal(result.data.rows[0].registration, '07-7175');
|
||||
const civilOnly = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil) },
|
||||
});
|
||||
await assert.rejects(
|
||||
civilOnly.call('aircraft_in_area', { area: bay, military: true }),
|
||||
(error) => error.code === 'unsupported',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft are found by exactly one identifier across feeds', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil), military: feed(military) },
|
||||
});
|
||||
const byCallsign = await catalog.call('find_aircraft', {
|
||||
callsign: 'ual123',
|
||||
});
|
||||
assert.equal(byCallsign.summary, 'Found 1 aircraft with callsign UAL123.');
|
||||
assert.deepEqual(
|
||||
byCallsign.data.rows.map((row) => row.id),
|
||||
['aaa111'],
|
||||
);
|
||||
assert.equal(byCallsign.data.rows[0].distance_km, undefined);
|
||||
const byRegistration = await catalog.call('find_aircraft', {
|
||||
registration: '07-7175',
|
||||
});
|
||||
assert.deepEqual(
|
||||
byRegistration.data.rows.map((row) => row.id),
|
||||
['ae0001'],
|
||||
);
|
||||
const none = await catalog.call('find_aircraft', { icao24: 'ABCDEF' });
|
||||
assert.equal(
|
||||
none.summary,
|
||||
'No aircraft with icao24 ABCDEF is currently reported.',
|
||||
);
|
||||
await assert.rejects(catalog.call('find_aircraft', {}), /exactly one/);
|
||||
await assert.rejects(
|
||||
catalog.call('find_aircraft', { callsign: 'UAL123', icao24: 'aaa111' }),
|
||||
/exactly one/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_aircraft', { icao24: 'xyz' }),
|
||||
/must match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('tracks are ordered and thinned to 200 points', async () => {
|
||||
const start = Date.UTC(2026, 0, 1);
|
||||
const track = Array.from({ length: 450 }, (_, index) => ({
|
||||
latitude: 37 + index / 1000,
|
||||
longitude: -122,
|
||||
observedAtMs: start + (449 - index) * 1000,
|
||||
baroAltitudeM: 1000,
|
||||
onGround: false,
|
||||
}));
|
||||
const aircraft = feed(civil, { track });
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||
const result = await catalog.call('get_aircraft_track', { icao24: 'AAA111' });
|
||||
assert.equal(aircraft.calls[0], 'aaa111');
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'450 positions for aaa111 from 2026-01-01T00:00:00.000Z to 2026-01-01T00:07:29.000Z.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
total: result.data.total,
|
||||
returned: result.data.returned,
|
||||
thinned: result.data.thinned,
|
||||
},
|
||||
{ total: 450, returned: 200, thinned: true },
|
||||
);
|
||||
assert.equal(result.data.points[0].time, '2026-01-01T00:00:00.000Z');
|
||||
assert.equal(result.data.points.at(-1).time, '2026-01-01T00:07:29.000Z');
|
||||
const empty = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: feed(civil) },
|
||||
});
|
||||
assert.equal(
|
||||
(await empty.call('get_aircraft_track', { icao24: 'aaa111' })).summary,
|
||||
'No recent track is available for aaa111.',
|
||||
);
|
||||
const failing = (status) => ({
|
||||
getSnapshot: async () => snapshot([]),
|
||||
getTrack: async () => {
|
||||
throw new LiveSourceError('unavailable', `OpenSky HTTP ${status}`, {
|
||||
status,
|
||||
});
|
||||
},
|
||||
});
|
||||
const unknown = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: failing(404) },
|
||||
});
|
||||
assert.equal(
|
||||
(await unknown.call('get_aircraft_track', { icao24: 'abcdef' })).summary,
|
||||
'No recent track is available for abcdef.',
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: failing(502) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_aircraft_track', { icao24: 'abcdef' }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft info combines type and route lookups', async () => {
|
||||
const aircraft = feed(civil, {
|
||||
enrichment: {
|
||||
'type:aaa111': {
|
||||
found: true,
|
||||
typeCode: 'B738',
|
||||
typeName: 'Boeing 737-800',
|
||||
registration: 'N12345',
|
||||
},
|
||||
'route:UAL123': {
|
||||
found: true,
|
||||
airline: 'United Airlines',
|
||||
origin: { code: 'SFO', name: 'San Francisco', lat: 37.6, lon: -122.4 },
|
||||
destination: { code: 'EWR', name: 'Newark', lat: 40.7, lon: -74.2 },
|
||||
},
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { aircraft } });
|
||||
const both = await catalog.call('get_aircraft_info', {
|
||||
icao24: 'AAA111',
|
||||
callsign: 'ual123',
|
||||
});
|
||||
assert.equal(
|
||||
both.summary,
|
||||
'Aircraft aaa111 is Boeing 737-800 (N12345); flight UAL123 flies SFO to EWR (United Airlines).',
|
||||
);
|
||||
assert.equal(both.data.route.destination.name, 'Newark');
|
||||
const unknown = await catalog.call('get_aircraft_info', { callsign: 'XYZ9' });
|
||||
assert.equal(unknown.summary, 'No route is known for XYZ9.');
|
||||
assert.deepEqual(unknown.data, { aircraft: null, route: null });
|
||||
await assert.rejects(
|
||||
catalog.call('get_aircraft_info', {}),
|
||||
/icao24, callsign or both/,
|
||||
);
|
||||
});
|
||||
|
||||
test('live source failures become tool errors with retry guidance', async () => {
|
||||
const limited = {
|
||||
getSnapshot: async () => {
|
||||
throw new LiveSourceError('limited', 'OpenSky rate limited', {
|
||||
retryAfterMs: 45000,
|
||||
});
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: limited },
|
||||
});
|
||||
await assert.rejects(
|
||||
catalog.call('aircraft_in_area', { area: bay }),
|
||||
(error) =>
|
||||
error.name === 'ToolError' &&
|
||||
error.code === 'retry_later' &&
|
||||
error.retryAfterSeconds === 45 &&
|
||||
error.message === 'OpenSky rate limited',
|
||||
);
|
||||
});
|
||||
|
||||
test('aircraft searches keep the feeds that answered and name the others', async () => {
|
||||
const down = {
|
||||
getSnapshot: async () => {
|
||||
throw new LiveSourceError('unavailable', 'OpenSky HTTP 503');
|
||||
},
|
||||
};
|
||||
const military = feed([
|
||||
record('ae1234', 38, -77, { callsign: 'RCH123', registration: '05-5140' }),
|
||||
]);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: down, military },
|
||||
});
|
||||
const byCallsign = await catalog.call('find_aircraft', {
|
||||
callsign: 'rch123',
|
||||
});
|
||||
assert.equal(
|
||||
byCallsign.summary,
|
||||
'Found 1 aircraft with callsign RCH123. The civil feed did not answer.',
|
||||
);
|
||||
assert.deepEqual(byCallsign.data.unavailable_feeds, ['civil']);
|
||||
const civil = feed([]);
|
||||
const byRegistration = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: civil, military },
|
||||
}).call('find_aircraft', { registration: '05-5140' });
|
||||
assert.equal(byRegistration.data.rows[0].id, 'ae1234');
|
||||
assert.deepEqual(byRegistration.data.unavailable_feeds, []);
|
||||
assert.equal(civil.calls.length, 0);
|
||||
await assert.rejects(
|
||||
composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: down, military: down },
|
||||
}).call('find_aircraft', { callsign: 'RCH123' }),
|
||||
(error) =>
|
||||
error.code === 'unavailable' && error.message === 'OpenSky HTTP 503',
|
||||
);
|
||||
});
|
||||
|
||||
test('a regional fallback feed is named in the answer', async () => {
|
||||
const regional = feed([record('abc123', 37.6, -122.3)], {
|
||||
source: 'adsb.lol',
|
||||
coverage: '250nm regional fallback',
|
||||
});
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: regional },
|
||||
}).call('aircraft_in_area', {
|
||||
area: { lat: 37.6, lon: -122.3, radius_km: 20 },
|
||||
});
|
||||
assert.match(result.summary, /\(regional feed: 250nm regional fallback\)\.$/);
|
||||
});
|
||||
|
||||
test('aircraft searches say when a feed that answered is stale', async () => {
|
||||
const stale = feed([], { freshness: 'stale' });
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: stale, military: feed([]) },
|
||||
}).call('find_aircraft', { callsign: 'UAL1' });
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'No aircraft with callsign UAL1 is currently reported. The civil feed data may be stale.',
|
||||
);
|
||||
assert.deepEqual(result.data.stale_feeds, ['civil']);
|
||||
assert.deepEqual(
|
||||
result.data.feeds.map((entry) => [entry.feed, entry.freshness]),
|
||||
[
|
||||
['civil', 'stale'],
|
||||
['military', 'current'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('the real OpenSky source marks an hour-old snapshot stale for searches', async () => {
|
||||
const { createOpenSkySource } =
|
||||
await import('../../sources/live/standalone.js');
|
||||
const now = Date.UTC(2026, 0, 1, 12);
|
||||
const opensky = createOpenSkySource({
|
||||
now: () => now,
|
||||
fetchImpl: async () =>
|
||||
Response.json({ time: now / 1000 - 3600, states: [] }),
|
||||
});
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { aircraft: opensky },
|
||||
}).call('find_aircraft', { callsign: 'UAL1' });
|
||||
assert.deepEqual(result.data.stale_feeds, ['civil']);
|
||||
assert.match(result.summary, /may be stale\.$/);
|
||||
});
|
||||
@@ -0,0 +1,305 @@
|
||||
/** Composite queries that combine other tools' answers for one area. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { feedProvenanceEnvelope } from '../../data/layerSnapshot.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
|
||||
const SECTION_LIMIT = 5;
|
||||
const AWARENESS_LIMIT = 10;
|
||||
const AWARENESS_RADIUS_KM = 250;
|
||||
|
||||
/** Brief sections: the tool each one calls and the arguments it passes. */
|
||||
const SECTIONS = [
|
||||
{
|
||||
key: 'weather',
|
||||
label: 'Weather',
|
||||
tool: 'get_weather',
|
||||
args: (area, center) => ({
|
||||
location: area.argument.place
|
||||
? { place: area.argument.place }
|
||||
: { lat: center.lat, lon: center.lon },
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'earthquakes',
|
||||
layer: 'earthquakes',
|
||||
label: 'Earthquakes',
|
||||
tool: 'get_earthquakes',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'fires',
|
||||
layer: 'local-firms',
|
||||
label: 'Active fires',
|
||||
tool: 'get_active_fires',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'aircraft',
|
||||
layer: 'flights',
|
||||
label: 'Aircraft',
|
||||
tool: 'aircraft_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'vessels',
|
||||
layer: 'ais-live-vessels',
|
||||
label: 'Ships',
|
||||
tool: 'vessels_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'cyclones',
|
||||
layer: 'weather-cyclones',
|
||||
label: 'Tropical cyclones',
|
||||
tool: 'get_cyclones',
|
||||
args: (area) => ({ area: area.argument }),
|
||||
},
|
||||
];
|
||||
|
||||
/** Military contacts around a point, as the app's military awareness panel. */
|
||||
const AWARENESS_SECTIONS = [
|
||||
{
|
||||
key: 'military_aircraft',
|
||||
tool: 'aircraft_in_area',
|
||||
args: (area) => ({
|
||||
area: area.argument,
|
||||
military: true,
|
||||
limit: AWARENESS_LIMIT,
|
||||
}),
|
||||
},
|
||||
{
|
||||
key: 'aircraft',
|
||||
tool: 'aircraft_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'vessels',
|
||||
tool: 'vessels_in_area',
|
||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||
},
|
||||
{
|
||||
key: 'installations',
|
||||
tool: 'find_military_installations',
|
||||
args: (area) => ({ area: area.argument, limit: AWARENESS_LIMIT }),
|
||||
},
|
||||
];
|
||||
|
||||
/** A section's feed state for the caption, from what its result reported. */
|
||||
function sectionFeedState(section) {
|
||||
if (section.unavailable) return 'unavailable';
|
||||
const data = section.data || {};
|
||||
if (data.freshness === 'stale' || data.stale === true) return 'stale';
|
||||
// Answered, but some of the feeds or satellites behind it did not.
|
||||
if (data.missing_sources?.length || data.unavailable_feeds?.length)
|
||||
return 'degraded';
|
||||
return 'nominal';
|
||||
}
|
||||
|
||||
/**
|
||||
* The resolved area, plus the argument sections receive. A named place is
|
||||
* passed by name, so sections resolve it to the same point and label as a
|
||||
* direct query does (place lookups are cached).
|
||||
*/
|
||||
function sectionArea(resolved, original) {
|
||||
return {
|
||||
...resolved,
|
||||
argument: original?.place
|
||||
? { place: original.place }
|
||||
: resolved.center
|
||||
? {
|
||||
lat: resolved.center.lat,
|
||||
lon: resolved.center.lon,
|
||||
radius_km: resolved.center.radiusKm,
|
||||
}
|
||||
: {
|
||||
bbox: [
|
||||
resolved.west,
|
||||
resolved.south,
|
||||
resolved.east,
|
||||
resolved.north,
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Run every section whose tool the catalog offers, through the catalog so
|
||||
* replaced tools and interceptors apply. A failing section is reported as
|
||||
* unavailable instead of failing the whole answer.
|
||||
*/
|
||||
async function runSections(all, area, { tools, signal }) {
|
||||
const center = areaCenter(area);
|
||||
const sections = all.filter(({ tool }) => tools.has(tool));
|
||||
const results = await Promise.allSettled(
|
||||
sections.map(({ tool, args: build }) =>
|
||||
tools.call(tool, build(area, center)),
|
||||
),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
const answers = {};
|
||||
const lines = [];
|
||||
sections.forEach(({ key }, index) => {
|
||||
const result = results[index];
|
||||
if (result.status === 'fulfilled') {
|
||||
answers[key] = { summary: result.value.summary, data: result.value.data };
|
||||
lines.push(result.value.summary);
|
||||
} else {
|
||||
const known = result.reason instanceof ToolError;
|
||||
answers[key] = {
|
||||
unavailable: true,
|
||||
reason: known ? result.reason.message : 'unavailable right now',
|
||||
};
|
||||
}
|
||||
});
|
||||
return { center, answers, lines };
|
||||
}
|
||||
|
||||
async function buildBrief(args, { services, signal, tools }) {
|
||||
// Sections receive the resolved box so a place name is looked up once.
|
||||
const area = sectionArea(
|
||||
await resolveArea(args.area, { services, signal }),
|
||||
args.area,
|
||||
);
|
||||
const { center, answers, lines } = await runSections(SECTIONS, area, {
|
||||
tools,
|
||||
signal,
|
||||
});
|
||||
return { area, center, brief: answers, lines };
|
||||
}
|
||||
|
||||
export const situationBrief = defineTool({
|
||||
name: 'situation_brief',
|
||||
title: 'Situation brief',
|
||||
description:
|
||||
'One overview of an area: current weather, recent earthquakes, active ' +
|
||||
'fires, aircraft overhead, ships and tropical cyclones, each summarized with ' +
|
||||
'its top items. Sections that are unavailable are marked as such.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weather'],
|
||||
async run(args, context) {
|
||||
const { area, brief, lines } = await buildBrief(args, context);
|
||||
return {
|
||||
summary: `Situation in ${area.label}: ${lines.join(' ')}`,
|
||||
data: {
|
||||
view: suggestView(context.services, {
|
||||
area,
|
||||
layers: SECTIONS.filter(({ key }) => brief[key])
|
||||
.map(({ layer }) => layer)
|
||||
.filter(Boolean),
|
||||
}),
|
||||
area: area.label,
|
||||
sections: brief,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getHudCaption = defineTool({
|
||||
name: 'get_hud_caption',
|
||||
title: 'Heads-up display caption',
|
||||
description:
|
||||
"The short heads-up display caption God's Eye View would show for an " +
|
||||
'area, written by the app from the same overview situation_brief gives.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weather', 'summary'],
|
||||
async run(args, context) {
|
||||
const { area, center, brief, lines } = await buildBrief(args, context);
|
||||
// The app's caption context: place labels and each section as a layer
|
||||
// with the feed state its result reported, failures included.
|
||||
const layers = SECTIONS.filter(({ key }) => brief[key]).map(
|
||||
({ key, label }) => ({
|
||||
id: key,
|
||||
name: label,
|
||||
enabled: true,
|
||||
feedState: sectionFeedState(brief[key]),
|
||||
}),
|
||||
);
|
||||
const response = await context.services.summary.summarize(
|
||||
{
|
||||
placeLabels: [area.label],
|
||||
location: area.label,
|
||||
center,
|
||||
observations: lines,
|
||||
enabledLayerLabels: layers.map((layer) => layer.name),
|
||||
enabledLayers: layers.map(({ id, name, feedState }) => ({
|
||||
id,
|
||||
name,
|
||||
feedState,
|
||||
})),
|
||||
feedProvenance: feedProvenanceEnvelope(layers),
|
||||
},
|
||||
{ signal: context.signal },
|
||||
);
|
||||
const caption = response?.data?.summary;
|
||||
if (!response?.ok || typeof caption !== 'string' || !caption)
|
||||
throw new ToolError('unavailable', 'The caption service did not answer');
|
||||
return {
|
||||
summary: caption,
|
||||
data: { area: area.label, caption },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const militaryAwareness = defineTool({
|
||||
name: 'military_awareness',
|
||||
title: 'Military awareness',
|
||||
description:
|
||||
'Military contacts around a location, as the military awareness panel ' +
|
||||
'shows them: military aircraft, other aircraft, ships and mapped ' +
|
||||
`military installations within ${AWARENESS_RADIUS_KM} km by default, ` +
|
||||
'each nearest first. Sections that are unavailable are marked as such.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
radius_km: { type: 'number', minimum: 1, maximum: AWARENESS_RADIUS_KM },
|
||||
},
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['military'],
|
||||
async run(args, { services, signal, tools }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const radiusKm = args.radius_km ?? AWARENESS_RADIUS_KM;
|
||||
const area = sectionArea(
|
||||
await resolveArea(
|
||||
{ lat: point.lat, lon: point.lon, radius_km: radiusKm },
|
||||
{ services, signal },
|
||||
),
|
||||
);
|
||||
const { answers, lines } = await runSections(AWARENESS_SECTIONS, area, {
|
||||
tools,
|
||||
signal,
|
||||
});
|
||||
return {
|
||||
summary: `Military awareness within ${radiusKm} km of ${point.label}: ${lines.join(' ')}`,
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area,
|
||||
layers: ['military', 'ais-live-vessels', 'military-installations'],
|
||||
}),
|
||||
location: point,
|
||||
radius_km: radiusKm,
|
||||
sections: answers,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
/** Submarine cables and their landing points. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaContains,
|
||||
lineTouchesArea,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
||||
|
||||
const ATTRIBUTION = '© TeleGeography — submarinecablemap.com (CC BY-NC-SA 3.0)';
|
||||
|
||||
function cableLines(feature) {
|
||||
const geometry = feature?.geometry;
|
||||
if (geometry?.type === 'LineString') return [geometry.coordinates];
|
||||
if (geometry?.type === 'MultiLineString') return geometry.coordinates;
|
||||
return [];
|
||||
}
|
||||
|
||||
export const findSubmarineCables = defineTool({
|
||||
name: 'find_submarine_cables',
|
||||
title: 'Submarine cables',
|
||||
description:
|
||||
'Undersea telecommunication cables and landing points from the ' +
|
||||
'TeleGeography map: those passing through or landing in an area, ' +
|
||||
'and/or those whose name matches.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
name: { type: 'string', minLength: 2, maxLength: 80 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['cables'],
|
||||
async run(args, { services, signal }) {
|
||||
if (!args.area && !args.name)
|
||||
throw new ToolError('invalid_arguments', 'Give an area, a name or both');
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
const wanted = args.name?.toLowerCase();
|
||||
const { cables, landingPoints } = await services.cables.fetch(signal);
|
||||
const landingRows = (landingPoints?.features || [])
|
||||
.map((feature) => ({
|
||||
id: feature.properties?.id ?? null,
|
||||
name: feature.properties?.name ?? null,
|
||||
lon: feature.geometry?.coordinates?.[0],
|
||||
lat: feature.geometry?.coordinates?.[1],
|
||||
}))
|
||||
.filter(
|
||||
(point) =>
|
||||
point.name &&
|
||||
Number.isFinite(point.lat) &&
|
||||
(!area || areaContains(area, point)),
|
||||
);
|
||||
const rows = (cables?.features || [])
|
||||
.filter((feature) => {
|
||||
const name = String(feature.properties?.name || '');
|
||||
if (!name || (wanted && !name.toLowerCase().includes(wanted)))
|
||||
return false;
|
||||
return (
|
||||
!area ||
|
||||
cableLines(feature).some((line) => lineTouchesArea(line, area))
|
||||
);
|
||||
})
|
||||
.map((feature) => ({
|
||||
id: feature.properties.id ?? null,
|
||||
name: feature.properties.name,
|
||||
}))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
const where = area ? ` in ${area.label}` : '';
|
||||
const what = wanted ? ` matching "${args.name}"` : '';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'submarine cable')}${what}${where}` +
|
||||
(area
|
||||
? `, with ${countNoun(landingRows.length, 'landing point')}.`
|
||||
: '.'),
|
||||
data: {
|
||||
view: suggestView(
|
||||
services,
|
||||
area
|
||||
? { area, layers: ['telegeography-submarine-cables'] }
|
||||
: {
|
||||
point: { lat: 20, lon: 0 },
|
||||
altitudeM: 15_000_000,
|
||||
layers: ['telegeography-submarine-cables'],
|
||||
},
|
||||
),
|
||||
...capRows(rows, args.limit),
|
||||
landing_points: area ? capRows(landingRows, args.limit) : null,
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,110 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createApiFetch } from '../../../server/mcp/services.js';
|
||||
import { createBundledCableSource } from '../../layers/submarineCables/bundledSource.js';
|
||||
|
||||
const cables = {
|
||||
async fetch() {
|
||||
return {
|
||||
cables: {
|
||||
features: [
|
||||
{
|
||||
properties: { id: 'marea', name: 'MAREA' },
|
||||
geometry: {
|
||||
type: 'MultiLineString',
|
||||
coordinates: [
|
||||
[
|
||||
[-76, 36.8],
|
||||
[-40, 40],
|
||||
[-2.9, 43.4],
|
||||
],
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
properties: { id: 'unity', name: 'Unity' },
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-118, 34],
|
||||
[139, 35],
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
landingPoints: {
|
||||
features: [
|
||||
{
|
||||
properties: { id: 'vb', name: 'Virginia Beach, VA, United States' },
|
||||
geometry: { type: 'Point', coordinates: [-75.98, 36.85] },
|
||||
},
|
||||
{
|
||||
properties: { id: 'bilbao', name: 'Bilbao, Spain' },
|
||||
geometry: { type: 'Point', coordinates: [-2.93, 43.26] },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
test('cables are found by area and name, with landing points and attribution', async () => {
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { cables } });
|
||||
const virginia = await catalog.call('find_submarine_cables', {
|
||||
area: { lat: 36.85, lon: -75.98, radius_km: 50 },
|
||||
});
|
||||
assert.equal(
|
||||
virginia.summary,
|
||||
'1 submarine cable in 50 km around 36.850, -75.980, with 1 landing point.',
|
||||
);
|
||||
assert.deepEqual(virginia.data.rows, [{ id: 'marea', name: 'MAREA' }]);
|
||||
assert.equal(
|
||||
virginia.data.landing_points.rows[0].name,
|
||||
'Virginia Beach, VA, United States',
|
||||
);
|
||||
assert.match(virginia.data.attribution, /TeleGeography.*CC BY-NC-SA 3\.0/);
|
||||
const named = await catalog.call('find_submarine_cables', { name: 'uni' });
|
||||
assert.equal(named.summary, '1 submarine cable matching "uni".');
|
||||
assert.equal(named.data.landing_points, null);
|
||||
await assert.rejects(
|
||||
catalog.call('find_submarine_cables', {}),
|
||||
/area, a name or both/,
|
||||
);
|
||||
// Areas a cable crosses between vertices, including at the antimeridian.
|
||||
const atlantic = await catalog.call('find_submarine_cables', {
|
||||
area: { bbox: [-30, 40, -20, 43] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
atlantic.data.rows.map((row) => row.id),
|
||||
['marea'],
|
||||
);
|
||||
const pacific = await catalog.call('find_submarine_cables', {
|
||||
area: { bbox: [179, 34, -179, 35.5] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
pacific.data.rows.map((row) => row.id),
|
||||
['unity'],
|
||||
);
|
||||
});
|
||||
|
||||
test('the local fetch serves bundled data files and nothing outside them', async () => {
|
||||
const fetchImpl = createApiFetch({
|
||||
apiBase: 'http://127.0.0.1:1',
|
||||
fetchImpl: async () => assert.fail('no network'),
|
||||
});
|
||||
const { cables: collection, landingPoints } = await createBundledCableSource({
|
||||
fetchImpl,
|
||||
}).fetch();
|
||||
assert.ok(collection.features.length > 500);
|
||||
assert.ok(landingPoints.features.length > 1000);
|
||||
const outside = await fetchImpl(
|
||||
new URL('../../../package.json', import.meta.url).href,
|
||||
);
|
||||
assert.equal(outside.status, 404);
|
||||
const missing = await fetchImpl(
|
||||
new URL('../../data/local_data/missing.json', import.meta.url).href,
|
||||
);
|
||||
assert.equal(missing.status, 404);
|
||||
});
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Environment queries: weather, regional briefs, cyclones, fire perimeters,
|
||||
* terrain height, mapped military sites and map features.
|
||||
*/
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
polygonsTouchArea,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
|
||||
const MAX_TERRAIN_POINTS = 20;
|
||||
const MAX_SITE_BOX_DEGREES = 10;
|
||||
const FEATURE_KINDS = {
|
||||
administrative_areas: 'getAdministrativeAreas',
|
||||
named_places: 'getEnclosingAreas',
|
||||
monuments: 'getMonuments',
|
||||
};
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
// WMO weather interpretation codes used by the weather provider.
|
||||
const WEATHER_CODES = {
|
||||
0: 'clear sky',
|
||||
1: 'mainly clear',
|
||||
2: 'partly cloudy',
|
||||
3: 'overcast',
|
||||
45: 'fog',
|
||||
48: 'depositing rime fog',
|
||||
51: 'light drizzle',
|
||||
53: 'drizzle',
|
||||
55: 'dense drizzle',
|
||||
56: 'light freezing drizzle',
|
||||
57: 'freezing drizzle',
|
||||
61: 'light rain',
|
||||
63: 'rain',
|
||||
65: 'heavy rain',
|
||||
66: 'light freezing rain',
|
||||
67: 'freezing rain',
|
||||
71: 'light snow',
|
||||
73: 'snow',
|
||||
75: 'heavy snow',
|
||||
77: 'snow grains',
|
||||
80: 'light rain showers',
|
||||
81: 'rain showers',
|
||||
82: 'violent rain showers',
|
||||
85: 'light snow showers',
|
||||
86: 'snow showers',
|
||||
95: 'thunderstorm',
|
||||
96: 'thunderstorm with light hail',
|
||||
99: 'thunderstorm with heavy hail',
|
||||
};
|
||||
|
||||
function weatherRow(weather) {
|
||||
if (!weather) return null;
|
||||
return {
|
||||
observed_at: weather.observedAt ?? null,
|
||||
conditions: WEATHER_CODES[weather.weatherCode] ?? null,
|
||||
weather_code: weather.weatherCode ?? null,
|
||||
temperature_c: weather.temperatureC ?? null,
|
||||
feels_like_c: weather.apparentTemperatureC ?? null,
|
||||
precipitation_mm: weather.precipitationMm ?? null,
|
||||
cloud_cover_pct: weather.cloudCoverPct ?? null,
|
||||
wind_kph: weather.windKph ?? null,
|
||||
wind_direction_deg: weather.windDirectionDeg ?? null,
|
||||
visibility_m: weather.visibilityM ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function describeWeather(row) {
|
||||
if (!row) return 'no current weather';
|
||||
const parts = [
|
||||
row.conditions,
|
||||
Number.isFinite(row.temperature_c) && `${Math.round(row.temperature_c)}°C`,
|
||||
Number.isFinite(row.wind_kph) && `wind ${Math.round(row.wind_kph)} km/h`,
|
||||
].filter(Boolean);
|
||||
return parts.join(', ') || 'conditions unknown';
|
||||
}
|
||||
|
||||
export const getWeather = defineTool({
|
||||
name: 'get_weather',
|
||||
title: 'Current weather',
|
||||
description:
|
||||
'Current weather at a location: conditions, temperature, feels-like, ' +
|
||||
'precipitation, cloud cover, wind and visibility.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { location: POINT_SCHEMA },
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['weather'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const payload = await services.weather.getConditions(point.lat, point.lon, {
|
||||
signal,
|
||||
});
|
||||
const row = weatherRow(payload?.weather);
|
||||
// The proxy answers from its cache, marked stale, when a refresh fails.
|
||||
const stale = payload?.status === 'stale';
|
||||
return {
|
||||
summary:
|
||||
`Weather at ${point.label}: ${describeWeather(row)}` +
|
||||
(stale ? ' (data may be stale).' : '.'),
|
||||
data: {
|
||||
location: point,
|
||||
weather: row,
|
||||
stale,
|
||||
retrieved_at: payload?.retrievedAt ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getRegionalBrief = defineTool({
|
||||
name: 'get_regional_brief',
|
||||
title: 'Regional brief',
|
||||
description:
|
||||
'What and where a location is, its current weather, and recent news ' +
|
||||
'headlines for the region.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { location: POINT_SCHEMA },
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['regional'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const brief = await services.regional.getBrief(point.lat, point.lon, {
|
||||
signal,
|
||||
});
|
||||
const place = brief?.place ?? null;
|
||||
const articles = (brief?.articles || []).slice(0, 10).map((article) => ({
|
||||
title: article.title ?? null,
|
||||
url: article.url ?? null,
|
||||
source: article.domain ?? null,
|
||||
published_at: article.publishedAt ?? null,
|
||||
}));
|
||||
const weather = weatherRow(brief?.weather);
|
||||
const label = place?.label || point.label;
|
||||
return {
|
||||
summary: `${label}: ${describeWeather(weather)}; ${countNoun(articles.length, 'recent headline')}.`,
|
||||
data: {
|
||||
location: point,
|
||||
place: place && {
|
||||
label: place.label ?? null,
|
||||
locality: place.locality ?? null,
|
||||
region: place.region ?? null,
|
||||
country: place.country ?? null,
|
||||
kind: place.kind ?? null,
|
||||
},
|
||||
weather,
|
||||
news_source: brief?.newsSource ?? null,
|
||||
articles,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getCyclones = defineTool({
|
||||
name: 'get_cyclones',
|
||||
title: 'Tropical cyclones',
|
||||
description:
|
||||
'Active tropical cyclones from the NOAA National Hurricane Center and ' +
|
||||
'Central Pacific Hurricane Center, optionally limited to an area.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['cyclones'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
const snapshot = await services.cyclones.getSnapshot({ signal });
|
||||
if (snapshot.unavailable)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Cyclone advisories are unavailable right now',
|
||||
);
|
||||
const storms = snapshot.storms
|
||||
.filter(
|
||||
(storm) =>
|
||||
!area ||
|
||||
areaContains(area, {
|
||||
lat: storm.position.latitude,
|
||||
lon: storm.position.longitude,
|
||||
}),
|
||||
)
|
||||
.map((storm) => ({
|
||||
id: storm.id,
|
||||
name: storm.name,
|
||||
classification: storm.classification,
|
||||
basin: storm.basin,
|
||||
lat: storm.position.latitude,
|
||||
lon: storm.position.longitude,
|
||||
position_at: storm.positionAt,
|
||||
wind_kt: storm.windKt,
|
||||
pressure_hpa: storm.pressureHpa,
|
||||
movement: storm.movement ?? null,
|
||||
advisory: storm.advisoryNumber,
|
||||
issued_at: storm.issuedAt,
|
||||
advisory_url: storm.advisoryUrl,
|
||||
}))
|
||||
.sort((a, b) => (b.wind_kt ?? 0) - (a.wind_kt ?? 0));
|
||||
const where = area ? ` in ${area.label}` : '';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(storms.length, 'active tropical cyclone')}${where}` +
|
||||
(storms[0]
|
||||
? `; strongest ${storms[0].name} at ${storms[0].wind_kt} kt.`
|
||||
: '.'),
|
||||
data: {
|
||||
view: suggestView(
|
||||
services,
|
||||
area
|
||||
? { area, layers: ['weather-cyclones'] }
|
||||
: {
|
||||
point: { lat: 20, lon: 0 },
|
||||
altitudeM: 15_000_000,
|
||||
layers: ['weather-cyclones'],
|
||||
},
|
||||
),
|
||||
storms,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
source: snapshot.source ?? null,
|
||||
attribution: snapshot.attribution ?? null,
|
||||
stale: snapshot.stale === true,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getFirePerimeters = defineTool({
|
||||
name: 'get_fire_perimeters',
|
||||
title: 'Wildfire perimeters',
|
||||
description:
|
||||
'Mapped wildfire perimeters from the US WFIGS service in an area, ' +
|
||||
'largest first, with size, containment, cause and personnel.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['perimeters'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const rows = (await services.perimeters.getSnapshot({ signal }))
|
||||
.filter((fire) => polygonsTouchArea(fire.polygons, area))
|
||||
.map((fire) => ({ fire, point: polygonCenter(fire.polygons) }))
|
||||
.filter(({ point }) => point)
|
||||
.map(({ fire, point }) => ({
|
||||
id: fire.stableId,
|
||||
name: fire.name,
|
||||
acres: round(fire.acres, 0),
|
||||
contained_pct: fire.containedPct,
|
||||
state: fire.state,
|
||||
county: fire.county,
|
||||
category: fire.category,
|
||||
cause: fire.cause,
|
||||
behavior: fire.behavior,
|
||||
personnel: fire.personnel,
|
||||
discovered: isoTime(fire.discoveredTime),
|
||||
updated: isoTime(fire.updatedTime),
|
||||
lat: round(point.lat, 5),
|
||||
lon: round(point.lon, 5),
|
||||
}))
|
||||
.sort((a, b) => (b.acres ?? 0) - (a.acres ?? 0));
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'mapped wildfire perimeter')} in ${area.label}.`,
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['fire-perimeters'] }),
|
||||
...capRows(rows, args.limit),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/** The average vertex of a perimeter's outer rings, enough to place it in an area. */
|
||||
function polygonCenter(polygons) {
|
||||
let lat = 0;
|
||||
let lon = 0;
|
||||
let count = 0;
|
||||
for (const polygon of polygons || []) {
|
||||
for (const [x, y] of polygon?.[0] || []) {
|
||||
if (!Number.isFinite(x) || !Number.isFinite(y)) continue;
|
||||
lon += x;
|
||||
lat += y;
|
||||
count += 1;
|
||||
}
|
||||
}
|
||||
return count ? { lat: lat / count, lon: lon / count } : null;
|
||||
}
|
||||
|
||||
export const getTerrainHeight = defineTool({
|
||||
name: 'get_terrain_height',
|
||||
title: 'Terrain height',
|
||||
description:
|
||||
'Ground elevation above sea level at up to 20 points, with the geoid ' +
|
||||
'and ellipsoid heights.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
points: {
|
||||
type: 'array',
|
||||
minItems: 1,
|
||||
maxItems: MAX_TERRAIN_POINTS,
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
lat: { type: 'number', minimum: -90, maximum: 90 },
|
||||
lon: { type: 'number', minimum: -180, maximum: 180 },
|
||||
},
|
||||
required: ['lat', 'lon'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ['points'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['terrain'],
|
||||
async run(args, { services, signal }) {
|
||||
const results = await services.terrain.getHeights(args.points, { signal });
|
||||
if (!Array.isArray(results) || results.length !== args.points.length)
|
||||
throw new ToolError('malformed', 'Terrain heights were incomplete');
|
||||
const rows = args.points.map((point, index) => ({
|
||||
lat: point.lat,
|
||||
lon: point.lon,
|
||||
elevation_m: round(results[index]?.elevation, 1),
|
||||
geoid_m: round(results[index]?.geoid, 1),
|
||||
ellipsoid_m: round(results[index]?.ellipsoid, 1),
|
||||
}));
|
||||
const first = rows[0];
|
||||
return {
|
||||
summary:
|
||||
rows.length === 1
|
||||
? `Ground elevation is ${first.elevation_m ?? 'unknown'} m at ${first.lat}, ${first.lon}.`
|
||||
: `Ground elevation at ${countNoun(rows.length, 'point')}.`,
|
||||
data: { points: rows },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const NAMES_WAIT_MS = 5000;
|
||||
|
||||
/**
|
||||
* The value `promise` resolves to within `ms`, or null when it takes longer
|
||||
* or fails. Rejects only when `signal` aborts.
|
||||
*/
|
||||
function settleWithin(promise, ms, signal) {
|
||||
signal?.throwIfAborted();
|
||||
return new Promise((resolve, reject) => {
|
||||
const done = (value) => {
|
||||
clearTimeout(timer);
|
||||
signal?.removeEventListener('abort', abort);
|
||||
resolve(value);
|
||||
};
|
||||
const abort = () => {
|
||||
clearTimeout(timer);
|
||||
reject(signal.reason);
|
||||
};
|
||||
const timer = setTimeout(() => done(null), ms);
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
Promise.resolve(promise).then(
|
||||
(value) => done(value ?? null),
|
||||
() => done(null),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export const findMilitaryInstallations = defineTool({
|
||||
name: 'find_military_installations',
|
||||
title: 'Military installations',
|
||||
description:
|
||||
'Military installations mapped in OpenStreetMap within an area of at ' +
|
||||
`most ${MAX_SITE_BOX_DEGREES}° on each side, nearest the center first.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['installations'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
if (
|
||||
area.west > area.east ||
|
||||
area.north - area.south > MAX_SITE_BOX_DEGREES ||
|
||||
area.east - area.west > MAX_SITE_BOX_DEGREES
|
||||
)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`The area must be at most ${MAX_SITE_BOX_DEGREES}° on each side and not cross the antimeridian`,
|
||||
);
|
||||
const center = areaCenter(area);
|
||||
let result = await services.installations.getMappedSites(area, {
|
||||
exact: true,
|
||||
thinned: false,
|
||||
signal,
|
||||
});
|
||||
// Tile results can arrive before the name pack; wait briefly for the
|
||||
// named version rather than answer with generic names.
|
||||
let namesPending = false;
|
||||
if (result.enrichment) {
|
||||
const named = await settleWithin(
|
||||
result.enrichment,
|
||||
NAMES_WAIT_MS,
|
||||
signal,
|
||||
);
|
||||
if (named) result = named;
|
||||
else namesPending = true;
|
||||
}
|
||||
// A saturated source returned only part of the mapped sites.
|
||||
const complete = !result.saturated;
|
||||
const rows = (result.records || [])
|
||||
.map((site) => ({
|
||||
site,
|
||||
point: { lat: site.latitude, lon: site.longitude },
|
||||
}))
|
||||
.filter(({ point }) => areaContains(area, point))
|
||||
.map(({ site, point }) => ({
|
||||
id: site.id,
|
||||
name: site.name ?? null,
|
||||
kind: site.kind ?? null,
|
||||
class: site.class ?? null,
|
||||
lat: round(point.lat, 5),
|
||||
lon: round(point.lon, 5),
|
||||
distance_km: round(distanceKm(center, point), 1),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const notes = [
|
||||
...(complete ? [] : ['partial: the source returned only some sites']),
|
||||
...(namesPending ? ['site names are still loading'] : []),
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'mapped military installation')} in ${area.label}` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area,
|
||||
layers: ['military-installations'],
|
||||
}),
|
||||
...capRows(rows, args.limit),
|
||||
complete,
|
||||
names_pending: namesPending,
|
||||
source: result.source ?? null,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getMapFeatures = defineTool({
|
||||
name: 'get_map_features',
|
||||
title: 'Map features',
|
||||
description:
|
||||
'Named OpenStreetMap features at a location: the administrative areas ' +
|
||||
'containing it, named parks, land uses and water nearby, or monuments ' +
|
||||
'and memorials within 2.5 km.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
kind: { type: 'string', enum: Object.keys(FEATURE_KINDS) },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['location', 'kind'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['features'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const features = await services.features[FEATURE_KINDS[args.kind]](
|
||||
{ lat: point.lat, lon: point.lon },
|
||||
{ signal },
|
||||
);
|
||||
if (features?.unavailable)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Map feature lookups need an Overpass server configured for this app',
|
||||
);
|
||||
if (!Array.isArray(features))
|
||||
throw new ToolError(
|
||||
'retry_later',
|
||||
'The map feature service did not answer',
|
||||
);
|
||||
const rows = features
|
||||
.map((feature) => ({
|
||||
id: feature.id,
|
||||
name:
|
||||
feature.names?.english ||
|
||||
feature.names?.primary ||
|
||||
feature.names?.official ||
|
||||
null,
|
||||
category: feature.category,
|
||||
admin_level: feature.level === 99 ? null : feature.level,
|
||||
lat: round(feature.point?.lat, 5),
|
||||
lon: round(feature.point?.lon, 5),
|
||||
}))
|
||||
.filter((row) => row.name)
|
||||
.sort((a, b) => (a.admin_level ?? 99) - (b.admin_level ?? 99));
|
||||
const kind = args.kind.replace('_', ' ');
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'named feature')} (${kind}) at ${point.label}.`,
|
||||
data: { location: point, ...capRows(rows, args.limit) },
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,782 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import {
|
||||
catalogForSurface,
|
||||
composeCatalog,
|
||||
coreTools,
|
||||
defineTool,
|
||||
ToolError,
|
||||
} from '../index.js';
|
||||
|
||||
const conditions = {
|
||||
observedAt: '2026-10-01T19:15:00.000Z',
|
||||
temperatureC: 26.3,
|
||||
apparentTemperatureC: 32,
|
||||
precipitationMm: 0,
|
||||
cloudCoverPct: 100,
|
||||
windKph: 5.4,
|
||||
windDirectionDeg: 318,
|
||||
visibilityM: 15400,
|
||||
weatherCode: 3,
|
||||
};
|
||||
const weather = {
|
||||
calls: [],
|
||||
async getConditions(latitude, longitude) {
|
||||
this.calls.push([latitude, longitude]);
|
||||
return { status: 'ready', weather: conditions };
|
||||
},
|
||||
};
|
||||
const places = {
|
||||
resolve: async (name) =>
|
||||
name === 'Austin'
|
||||
? {
|
||||
name: 'Austin, Texas',
|
||||
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const catalogWith = (services) =>
|
||||
composeCatalog({ tools: coreTools, services });
|
||||
|
||||
test('weather describes current conditions at a point or place', async () => {
|
||||
const catalog = catalogWith({ weather, places });
|
||||
const result = await catalog.call('get_weather', {
|
||||
location: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Weather at Austin, Texas: overcast, 26°C, wind 5 km/h.',
|
||||
);
|
||||
assert.deepEqual(result.data.weather, {
|
||||
observed_at: '2026-10-01T19:15:00.000Z',
|
||||
conditions: 'overcast',
|
||||
weather_code: 3,
|
||||
temperature_c: 26.3,
|
||||
feels_like_c: 32,
|
||||
precipitation_mm: 0,
|
||||
cloud_cover_pct: 100,
|
||||
wind_kph: 5.4,
|
||||
wind_direction_deg: 318,
|
||||
visibility_m: 15400,
|
||||
});
|
||||
const [latitude, longitude] = weather.calls.at(-1);
|
||||
assert.ok(Math.abs(latitude - 30.31) < 1e-9 && longitude === -97.75);
|
||||
});
|
||||
|
||||
test('the regional brief returns place, weather and at most ten headlines', async () => {
|
||||
const regional = {
|
||||
async getBrief() {
|
||||
return {
|
||||
place: {
|
||||
label: 'Edwards Plateau',
|
||||
locality: null,
|
||||
region: 'Edwards Plateau',
|
||||
country: null,
|
||||
kind: 'natural',
|
||||
},
|
||||
weather: conditions,
|
||||
newsSource: 'Google News RSS',
|
||||
articles: Array.from({ length: 12 }, (_, index) => ({
|
||||
title: `Story ${index}`,
|
||||
url: `https://news.example/${index}`,
|
||||
domain: 'news.example',
|
||||
publishedAt: '2026-10-01T00:00:00Z',
|
||||
})),
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ regional }).call('get_regional_brief', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Edwards Plateau: overcast, 26°C, wind 5 km/h; 10 recent headlines.',
|
||||
);
|
||||
assert.equal(result.data.articles.length, 10);
|
||||
assert.deepEqual(result.data.articles[0], {
|
||||
title: 'Story 0',
|
||||
url: 'https://news.example/0',
|
||||
source: 'news.example',
|
||||
published_at: '2026-10-01T00:00:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
const storm = (id, name, latitude, longitude, windKt) => ({
|
||||
id,
|
||||
name,
|
||||
classification: 'Hurricane',
|
||||
basin: 'Atlantic',
|
||||
position: { latitude, longitude },
|
||||
positionAt: '2026-10-01T18:00:00.000Z',
|
||||
advisoryNumber: '12',
|
||||
issuedAt: '2026-10-01T15:00:00.000Z',
|
||||
windKt,
|
||||
pressureHpa: 980,
|
||||
movement: { towardDeg: 300, speedKt: 10 },
|
||||
advisoryUrl: 'https://www.nhc.noaa.gov/text/X.shtml',
|
||||
});
|
||||
|
||||
test('cyclones are listed strongest first and can be limited to an area', async () => {
|
||||
const cyclones = {
|
||||
async getSnapshot() {
|
||||
return {
|
||||
source: 'NOAA NHC / CPHC',
|
||||
coverage: 'Atlantic and eastern/central North Pacific',
|
||||
stale: false,
|
||||
unavailable: false,
|
||||
storms: [
|
||||
storm('a', 'Alpha', 25, -70, 65),
|
||||
storm('b', 'Beta', 15, -110, 120),
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const all = await catalogWith({ cyclones }).call('get_cyclones', {});
|
||||
assert.equal(
|
||||
all.summary,
|
||||
'2 active tropical cyclones; strongest Beta at 120 kt.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
all.data.storms.map((row) => row.id),
|
||||
['b', 'a'],
|
||||
);
|
||||
const atlantic = await catalogWith({ cyclones }).call('get_cyclones', {
|
||||
area: { bbox: [-100, 0, -10, 50] },
|
||||
});
|
||||
assert.equal(
|
||||
atlantic.summary,
|
||||
'1 active tropical cyclone in the requested box; strongest Alpha at 65 kt.',
|
||||
);
|
||||
const down = { getSnapshot: async () => ({ unavailable: true, storms: [] }) };
|
||||
await assert.rejects(
|
||||
catalogWith({ cyclones: down }).call('get_cyclones', {}),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('fire perimeters are placed by their outline and sorted by size', async () => {
|
||||
const ring = (lon, lat) => [
|
||||
[
|
||||
[lon, lat],
|
||||
[lon + 0.1, lat],
|
||||
[lon + 0.1, lat + 0.1],
|
||||
[lon, lat + 0.1],
|
||||
],
|
||||
];
|
||||
const perimeters = {
|
||||
async getSnapshot() {
|
||||
return [
|
||||
{
|
||||
stableId: 'small',
|
||||
name: 'Small Fire',
|
||||
acres: 120.4,
|
||||
containedPct: 80,
|
||||
state: 'US-CA',
|
||||
county: 'Kern',
|
||||
category: 'WF',
|
||||
cause: 'Human',
|
||||
behavior: null,
|
||||
personnel: 20,
|
||||
discoveredTime: Date.UTC(2026, 8, 1),
|
||||
updatedTime: null,
|
||||
polygons: [ring(-118.5, 35.2)],
|
||||
},
|
||||
{
|
||||
stableId: 'big',
|
||||
name: 'Big Fire',
|
||||
acres: 5000,
|
||||
containedPct: 10,
|
||||
polygons: [ring(-118.8, 35.4)],
|
||||
},
|
||||
{
|
||||
stableId: 'far',
|
||||
name: 'Far Fire',
|
||||
acres: 9000,
|
||||
polygons: [ring(-80, 30)],
|
||||
},
|
||||
{ stableId: 'empty', name: 'No Outline', acres: 1, polygons: [] },
|
||||
];
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||
area: { bbox: [-120, 34, -117, 36] },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 mapped wildfire perimeters in the requested box.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['big', 'small'],
|
||||
);
|
||||
assert.deepEqual(result.data.rows[1], {
|
||||
id: 'small',
|
||||
name: 'Small Fire',
|
||||
acres: 120,
|
||||
contained_pct: 80,
|
||||
state: 'US-CA',
|
||||
county: 'Kern',
|
||||
category: 'WF',
|
||||
cause: 'Human',
|
||||
behavior: null,
|
||||
personnel: 20,
|
||||
discovered: '2026-09-01T00:00:00.000Z',
|
||||
updated: null,
|
||||
lat: 35.25,
|
||||
lon: -118.45,
|
||||
});
|
||||
});
|
||||
|
||||
test('terrain heights are returned in request order', async () => {
|
||||
const terrain = {
|
||||
async getHeights(points) {
|
||||
return points.map((point) => ({
|
||||
...point,
|
||||
elevation: point.lat * 100,
|
||||
geoid: -30.04,
|
||||
ellipsoid: point.lat * 100 - 30.04,
|
||||
}));
|
||||
},
|
||||
};
|
||||
const one = await catalogWith({ terrain }).call('get_terrain_height', {
|
||||
points: [{ lat: 27.9881, lon: 86.925 }],
|
||||
});
|
||||
assert.equal(one.summary, 'Ground elevation is 2798.8 m at 27.9881, 86.925.');
|
||||
const many = await catalogWith({ terrain }).call('get_terrain_height', {
|
||||
points: [
|
||||
{ lat: 1, lon: 1 },
|
||||
{ lat: 2, lon: 2 },
|
||||
],
|
||||
});
|
||||
assert.deepEqual(
|
||||
many.data.points.map((point) => point.elevation_m),
|
||||
[100, 200],
|
||||
);
|
||||
const short = { getHeights: async () => [] };
|
||||
await assert.rejects(
|
||||
catalogWith({ terrain: short }).call('get_terrain_height', {
|
||||
points: [{ lat: 1, lon: 1 }],
|
||||
}),
|
||||
(error) => error.code === 'malformed',
|
||||
);
|
||||
await assert.rejects(
|
||||
catalogWith({ terrain }).call('get_terrain_height', {
|
||||
points: Array(21).fill({ lat: 0, lon: 0 }),
|
||||
}),
|
||||
/at most 20 items/,
|
||||
);
|
||||
});
|
||||
|
||||
test('military installations need a bounded area and are nearest first', async () => {
|
||||
const requested = [];
|
||||
const options = [];
|
||||
let saturated = false;
|
||||
const installations = {
|
||||
async getMappedSites(box, { signal, ...rest } = {}) {
|
||||
requested.push(box);
|
||||
options.push(rest);
|
||||
return {
|
||||
saturated,
|
||||
source: 'OpenStreetMap',
|
||||
records: [
|
||||
{
|
||||
id: 'osm:1',
|
||||
kind: 'installation',
|
||||
class: 'base',
|
||||
name: 'Naval Base Point Loma',
|
||||
latitude: 32.6941,
|
||||
longitude: -117.2494,
|
||||
},
|
||||
{
|
||||
id: 'osm:2',
|
||||
kind: 'installation',
|
||||
class: 'airfield',
|
||||
name: 'North Island',
|
||||
latitude: 32.7,
|
||||
longitude: -117.2,
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = catalogWith({ installations });
|
||||
const result = await catalog.call('find_military_installations', {
|
||||
area: { lat: 32.7, lon: -117.2, radius_km: 20 },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 mapped military installations in 20 km around 32.700, -117.200.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['osm:2', 'osm:1'],
|
||||
);
|
||||
assert.equal(result.data.source, 'OpenStreetMap');
|
||||
assert.equal(result.data.complete, true);
|
||||
assert.ok(requested[0].north - requested[0].south < 1);
|
||||
assert.deepEqual(options[0], { exact: true, thinned: false });
|
||||
saturated = true;
|
||||
const partial = await catalog.call('find_military_installations', {
|
||||
area: { lat: 32.7, lon: -117.2, radius_km: 20 },
|
||||
});
|
||||
assert.equal(partial.data.complete, false);
|
||||
assert.match(
|
||||
partial.summary,
|
||||
/\(partial: the source returned only some sites\)\.$/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_military_installations', {
|
||||
area: { bbox: [-130, 20, -100, 50] },
|
||||
}),
|
||||
/at most 10° on each side/,
|
||||
);
|
||||
});
|
||||
|
||||
test('map features name what is at a location and report an unconfigured server', async () => {
|
||||
const features = {
|
||||
async getAdministrativeAreas() {
|
||||
return [
|
||||
{
|
||||
id: 1,
|
||||
category: 'administrative',
|
||||
names: { primary: 'Texas' },
|
||||
level: 4,
|
||||
point: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
category: 'administrative',
|
||||
names: { primary: 'Travis County', english: 'Travis County' },
|
||||
level: 6,
|
||||
point: null,
|
||||
},
|
||||
{ id: 3, category: 'administrative', names: {}, level: 8, point: null },
|
||||
];
|
||||
},
|
||||
getEnclosingAreas: async () => null,
|
||||
getMonuments: async () => ({
|
||||
unavailable: true,
|
||||
code: 'OVERPASS_NOT_CONFIGURED',
|
||||
}),
|
||||
};
|
||||
const catalog = catalogWith({ features });
|
||||
const result = await catalog.call('get_map_features', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
kind: 'administrative_areas',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 named features (administrative areas) at 30.2700, -97.7400.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => [row.name, row.admin_level]),
|
||||
[
|
||||
['Texas', 4],
|
||||
['Travis County', 6],
|
||||
],
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('get_map_features', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
kind: 'monuments',
|
||||
}),
|
||||
(error) => error.code === 'unavailable' && /Overpass/.test(error.message),
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('get_map_features', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
kind: 'named_places',
|
||||
}),
|
||||
(error) => error.code === 'retry_later',
|
||||
);
|
||||
});
|
||||
|
||||
test('the situation brief combines available sections and marks failures', async () => {
|
||||
const services = {
|
||||
weather,
|
||||
earthquakes: {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'q',
|
||||
usgsId: 'us1',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
mag: 3.1,
|
||||
place: 'near Austin',
|
||||
time: 0,
|
||||
depthKm: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
fires: { getSnapshot: async () => ({ keyRequired: true }) },
|
||||
cyclones: {
|
||||
getSnapshot: async () => {
|
||||
throw new Error('upstream down');
|
||||
},
|
||||
},
|
||||
places,
|
||||
};
|
||||
const result = await catalogWith(services).call('situation_brief', {
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Situation in Austin, Texas: Weather at Austin, Texas: overcast, 26°C, wind 5 km/h. ' +
|
||||
'1 earthquake of M2.5+ in the last 24 hours in Austin, Texas; strongest M3.1 near Austin.',
|
||||
);
|
||||
assert.deepEqual(Object.keys(result.data.sections), [
|
||||
'weather',
|
||||
'earthquakes',
|
||||
'fires',
|
||||
'cyclones',
|
||||
]);
|
||||
assert.deepEqual(result.data.sections.fires, {
|
||||
unavailable: true,
|
||||
reason: 'Fire detections need a NASA FIRMS key configured for this server',
|
||||
});
|
||||
assert.deepEqual(result.data.sections.cyclones, {
|
||||
unavailable: true,
|
||||
reason: 'unavailable right now',
|
||||
});
|
||||
assert.equal(result.data.sections.aircraft, undefined);
|
||||
});
|
||||
|
||||
test('the HUD caption summarizes the brief through the summary service', async () => {
|
||||
const sent = [];
|
||||
const summary = {
|
||||
async summarize(context) {
|
||||
sent.push(context);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { summary: 'Overcast skies over Austin', error: null },
|
||||
};
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ weather, summary, places }).call(
|
||||
'get_hud_caption',
|
||||
{ area: { place: 'Austin' } },
|
||||
);
|
||||
assert.equal(result.summary, 'Overcast skies over Austin');
|
||||
assert.equal(sent[0].location, 'Austin, Texas');
|
||||
assert.deepEqual(sent[0].placeLabels, ['Austin, Texas']);
|
||||
assert.deepEqual(sent[0].enabledLayerLabels, ['Weather']);
|
||||
assert.equal(sent[0].feedProvenance.overall, 'nominal');
|
||||
assert.match(sent[0].observations[0], /^Weather at /);
|
||||
const failing = {
|
||||
summarize: async () => ({
|
||||
ok: false,
|
||||
status: 502,
|
||||
data: { summary: null },
|
||||
}),
|
||||
};
|
||||
await assert.rejects(
|
||||
catalogWith({ weather, summary: failing, places }).call('get_hud_caption', {
|
||||
area: { place: 'Austin' },
|
||||
}),
|
||||
(error) => error instanceof ToolError && error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('military awareness gathers contacts around a point and marks failures', async () => {
|
||||
const contact = (id, latitude, longitude) => ({
|
||||
id,
|
||||
reference: id,
|
||||
latitude,
|
||||
longitude,
|
||||
callsign: 'RCH123',
|
||||
baroAltitudeM: 9000,
|
||||
onGround: false,
|
||||
positionTimeMs: Date.UTC(2026, 0, 1),
|
||||
});
|
||||
const military = {
|
||||
getSnapshot: async () => ({
|
||||
records: [contact('ae1234', 32.8, -117.1), contact('ae9999', 40, -100)],
|
||||
complete: true,
|
||||
source: 'Military feed',
|
||||
freshness: 'current',
|
||||
}),
|
||||
};
|
||||
const aircraft = {
|
||||
getSnapshot: async () => {
|
||||
throw new Error('upstream down');
|
||||
},
|
||||
};
|
||||
const installations = {
|
||||
getMappedSites: async () => ({
|
||||
source: 'OpenStreetMap',
|
||||
records: [
|
||||
{
|
||||
id: 'osm:1',
|
||||
kind: 'installation',
|
||||
class: 'base',
|
||||
name: 'Naval Base Point Loma',
|
||||
latitude: 32.6941,
|
||||
longitude: -117.2494,
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const catalog = catalogWith({ military, aircraft, installations });
|
||||
const result = await catalog.call('military_awareness', {
|
||||
location: { lat: 32.7, lon: -117.2 },
|
||||
radius_km: 100,
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Military awareness within 100 km of 32.7000, -117.2000: ' +
|
||||
'1 military aircraft in 100 km around 32.700, -117.200. ' +
|
||||
'1 mapped military installation in 100 km around 32.700, -117.200.',
|
||||
);
|
||||
assert.deepEqual(Object.keys(result.data.sections), [
|
||||
'military_aircraft',
|
||||
'aircraft',
|
||||
'installations',
|
||||
]);
|
||||
assert.deepEqual(
|
||||
result.data.sections.military_aircraft.data.rows.map((row) => row.id),
|
||||
['ae1234'],
|
||||
);
|
||||
assert.deepEqual(result.data.sections.aircraft, {
|
||||
unavailable: true,
|
||||
reason: 'unavailable right now',
|
||||
});
|
||||
assert.equal(result.data.radius_km, 100);
|
||||
const voice = catalogForSurface(catalog, 'voice');
|
||||
assert.equal(voice.get('aircraft_in_area'), undefined);
|
||||
assert.deepEqual(
|
||||
Object.keys(
|
||||
(
|
||||
await voice.call('military_awareness', {
|
||||
location: { lat: 32.7, lon: -117.2 },
|
||||
radius_km: 100,
|
||||
})
|
||||
).data.sections,
|
||||
),
|
||||
['military_aircraft', 'aircraft', 'installations'],
|
||||
);
|
||||
assert.equal(
|
||||
(await catalog.call('military_awareness', { location: { lat: 0, lon: 0 } }))
|
||||
.data.radius_km,
|
||||
250,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('military_awareness', {
|
||||
location: { lat: 0, lon: 0 },
|
||||
radius_km: 500,
|
||||
}),
|
||||
/radius_km/,
|
||||
);
|
||||
});
|
||||
|
||||
test('composites reach their sections through the catalog', async () => {
|
||||
const replacement = defineTool({
|
||||
name: 'get_weather',
|
||||
title: 'Weather',
|
||||
description: 'Replaced weather.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { location: { type: 'object' } },
|
||||
},
|
||||
run: async () => ({ summary: 'Replaced weather.', data: {} }),
|
||||
});
|
||||
const seen = [];
|
||||
const catalog = composeCatalog({
|
||||
tools: [...coreTools, replacement],
|
||||
replace: ['get_weather'],
|
||||
services: { weather, places },
|
||||
interceptors: [
|
||||
(call, next) => {
|
||||
seen.push([call.tool.name, call.parent ?? null]);
|
||||
return next(call);
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await catalog.call('situation_brief', {
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(result.data.sections.weather.summary, 'Replaced weather.');
|
||||
assert.deepEqual(seen, [
|
||||
['situation_brief', null],
|
||||
['get_weather', 'situation_brief'],
|
||||
]);
|
||||
});
|
||||
|
||||
test('the HUD caption context carries each section feed state', async () => {
|
||||
const sent = [];
|
||||
const summary = {
|
||||
async summarize(context) {
|
||||
sent.push(context);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
data: { summary: 'Austin aircraft STALE' },
|
||||
};
|
||||
},
|
||||
};
|
||||
const aircraft = {
|
||||
getSnapshot: async () => ({
|
||||
records: [],
|
||||
complete: true,
|
||||
source: 'Test feed',
|
||||
freshness: 'stale',
|
||||
}),
|
||||
};
|
||||
const cyclones = {
|
||||
getSnapshot: async () => {
|
||||
throw new Error('upstream down');
|
||||
},
|
||||
};
|
||||
await catalogWith({ weather, summary, places, aircraft, cyclones }).call(
|
||||
'get_hud_caption',
|
||||
{ area: { place: 'Austin' } },
|
||||
);
|
||||
assert.deepEqual(
|
||||
sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]),
|
||||
[
|
||||
['weather', 'nominal'],
|
||||
['aircraft', 'stale'],
|
||||
['cyclones', 'unavailable'],
|
||||
],
|
||||
);
|
||||
assert.equal(sent[0].feedProvenance.overall, 'unavailable');
|
||||
assert.match(sent[0].feedProvenance.note, /Aircraft is STALE/);
|
||||
});
|
||||
|
||||
test('brief sections use a named place at its own point', async () => {
|
||||
const tokyo = {
|
||||
resolve: async () => ({
|
||||
name: 'Tokyo, Japan',
|
||||
point: { lat: 35.6769, lon: 139.7639 },
|
||||
bounds: { west: 135, south: 20, east: 155, north: 36 },
|
||||
}),
|
||||
};
|
||||
const queries = [];
|
||||
const aircraft = {
|
||||
getSnapshot: async (query) => {
|
||||
queries.push(query);
|
||||
return { records: [], complete: true, source: 'Test feed' };
|
||||
},
|
||||
};
|
||||
const result = await catalogWith({ weather, places: tokyo, aircraft }).call(
|
||||
'situation_brief',
|
||||
{ area: { place: 'Tokyo' } },
|
||||
);
|
||||
assert.deepEqual(queries[0], { latitude: 35.6769, longitude: 139.7639 });
|
||||
assert.match(result.data.sections.aircraft.summary, /in Tokyo, Japan\.$/);
|
||||
});
|
||||
|
||||
test('a fire perimeter enclosing the whole area is found', async () => {
|
||||
const perimeters = {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'big',
|
||||
name: 'Big Fire',
|
||||
acres: 250000,
|
||||
polygons: [
|
||||
[
|
||||
[
|
||||
[-122, 38],
|
||||
[-120, 38],
|
||||
[-120, 40],
|
||||
[-122, 40],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
const result = await catalogWith({ perimeters }).call('get_fire_perimeters', {
|
||||
area: { bbox: [-121.2, 38.9, -120.8, 39.1] },
|
||||
});
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['big'],
|
||||
);
|
||||
});
|
||||
|
||||
test('stale weather and partial fire data reach answers and the HUD caption', async () => {
|
||||
const sent = [];
|
||||
const summary = {
|
||||
async summarize(context) {
|
||||
sent.push(context);
|
||||
return { ok: true, status: 200, data: { summary: 'Austin STALE' } };
|
||||
},
|
||||
};
|
||||
const staleWeather = {
|
||||
getConditions: async () => ({ status: 'stale', weather: conditions }),
|
||||
};
|
||||
const fires = {
|
||||
getSnapshot: async () => ({
|
||||
stale: false,
|
||||
fetchedAt: Date.UTC(2026, 0, 1),
|
||||
sources: [
|
||||
{ source: 'VIIRS_NOAA20_NRT', ok: true },
|
||||
{ source: 'VIIRS_SNPP_NRT', ok: false },
|
||||
],
|
||||
fires: [],
|
||||
}),
|
||||
};
|
||||
const catalog = catalogWith({
|
||||
weather: staleWeather,
|
||||
summary,
|
||||
places,
|
||||
fires,
|
||||
});
|
||||
const weatherResult = await catalog.call('get_weather', {
|
||||
location: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(weatherResult.data.stale, true);
|
||||
assert.match(weatherResult.summary, /\(data may be stale\)\.$/);
|
||||
const fireResult = await catalog.call('get_active_fires', {
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.deepEqual(fireResult.data.missing_sources, ['VIIRS_SNPP_NRT']);
|
||||
assert.match(fireResult.summary, /\(no data from VIIRS_SNPP_NRT\)\.$/);
|
||||
await catalog.call('get_hud_caption', { area: { place: 'Austin' } });
|
||||
assert.deepEqual(
|
||||
sent[0].enabledLayers.map((layer) => [layer.id, layer.feedState]),
|
||||
[
|
||||
['weather', 'stale'],
|
||||
['fires', 'degraded'],
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test('installation names wait for the name pack, briefly', async (t) => {
|
||||
const site = (name) => ({
|
||||
id: 'osm:9',
|
||||
kind: 'installation',
|
||||
name,
|
||||
latitude: 30.31,
|
||||
longitude: -97.76,
|
||||
});
|
||||
const source = (enrichment) => ({
|
||||
getMappedSites: async () => ({
|
||||
source: 'OpenStreetMap tiles',
|
||||
records: [site('Military area')],
|
||||
enrichment,
|
||||
}),
|
||||
});
|
||||
const area = { lat: 30.31, lon: -97.76, radius_km: 5 };
|
||||
const named = await catalogWith({
|
||||
installations: source(
|
||||
Promise.resolve({
|
||||
source: 'OpenStreetMap tiles',
|
||||
records: [site('Camp Mabry')],
|
||||
}),
|
||||
),
|
||||
}).call('find_military_installations', { area });
|
||||
assert.equal(named.data.rows[0].name, 'Camp Mabry');
|
||||
assert.equal(named.data.names_pending, false);
|
||||
t.mock.timers.enable({ apis: ['setTimeout'] });
|
||||
const pending = catalogWith({
|
||||
installations: source(new Promise(() => {})),
|
||||
}).call('find_military_installations', { area });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
t.mock.timers.tick(5000);
|
||||
const slow = await pending;
|
||||
assert.equal(slow.data.rows[0].name, 'Military area');
|
||||
assert.equal(slow.data.names_pending, true);
|
||||
assert.match(slow.summary, /site names are still loading\)\.$/);
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
/** The bundled Bhote Koshi 2026 outburst flood event pack. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { BHOTE_KOSHI_FLOOD_PATH } from '../../data/bhoteKoshiFloodPath.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { distanceKm } from '../area.js';
|
||||
|
||||
const EVENT_ID = 'bhote-koshi-2026';
|
||||
const ATTRIBUTION =
|
||||
'Imagery: Vantor Open Data; reconstruction: GeoPera (CC BY-NC 4.0). ' +
|
||||
'Linked witness posts and the GeoGeorgeShadrach geolocation map keep ' +
|
||||
"their owners' terms.";
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/** Length of a `[lon, lat]` line in kilometers. */
|
||||
function pathLengthKm(path) {
|
||||
let total = 0;
|
||||
for (let index = 1; index < path.length; index += 1)
|
||||
total += distanceKm(
|
||||
{ lat: path[index - 1][1], lon: path[index - 1][0] },
|
||||
{ lat: path[index][1], lon: path[index][0] },
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
const pathPoint = ([lon, lat]) => ({ lat, lon });
|
||||
|
||||
/** The box around a `[lon, lat]` path, as an area to frame. */
|
||||
function pathArea(path) {
|
||||
const lons = path.map(([lon]) => lon);
|
||||
const lats = path.map(([, lat]) => lat);
|
||||
return {
|
||||
west: Math.min(...lons),
|
||||
south: Math.min(...lats),
|
||||
east: Math.max(...lons),
|
||||
north: Math.max(...lats),
|
||||
};
|
||||
}
|
||||
|
||||
function evidenceRow(record) {
|
||||
return {
|
||||
sequence: record.sequence ?? null,
|
||||
id: record.id,
|
||||
title: record.title ?? null,
|
||||
summary: record.summary ?? null,
|
||||
phase: record.phase ?? null,
|
||||
lat: round(record.lat, 5),
|
||||
lon: round(record.lon, 5),
|
||||
location_confidence: record.confidence?.location ?? null,
|
||||
time_confidence: record.confidence?.time ?? null,
|
||||
captured_at: record.timing?.capturedAt ?? null,
|
||||
in_imagery: record.imageryCoverage === 'within',
|
||||
source_url: record.media?.sourceUrl ?? null,
|
||||
platform: record.media?.platform ?? null,
|
||||
via: record.provenance?.via ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export const getBhoteKoshiFlood = defineTool({
|
||||
name: 'get_bhote_koshi_flood',
|
||||
title: 'Bhote Koshi flood',
|
||||
description:
|
||||
"The 26 August 2026 Bhote Koshi outburst flood in Nepal as God's Eye " +
|
||||
'View maps it: the evidence trail of geolocated public witness posts ' +
|
||||
'from the collapse to Devighat in story order, the mapped flood path, ' +
|
||||
'and the before and after satellite imagery. A visualization, not an ' +
|
||||
'official hazard model; capture times are unverified.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
phase: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 40,
|
||||
description: 'Only evidence in a phase, such as "downstream passage".',
|
||||
},
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['events'],
|
||||
async run(args, { services, signal }) {
|
||||
const event = await services.events.getEvent(EVENT_ID, { signal });
|
||||
if (!Array.isArray(event?.evidenceSpine))
|
||||
throw new ToolError('malformed', 'The event pack has no evidence');
|
||||
const wanted = args.phase?.toLowerCase();
|
||||
const evidence = event.evidenceSpine
|
||||
.filter((record) => !wanted || record.phase?.toLowerCase() === wanted)
|
||||
.sort((a, b) => a.sequence - b.sequence)
|
||||
.map(evidenceRow);
|
||||
const phases = [
|
||||
...new Set(event.evidenceSpine.map((record) => record.phase)),
|
||||
];
|
||||
const lengthKm = round(pathLengthKm(BHOTE_KOSHI_FLOOD_PATH), 1);
|
||||
const { imagery = {} } = event;
|
||||
return {
|
||||
summary:
|
||||
`${event.title}, observed ${event.observedDate}: ${evidence.length} ` +
|
||||
`evidence record${evidence.length === 1 ? '' : 's'}` +
|
||||
(args.phase ? ` in the ${args.phase} phase` : '') +
|
||||
` along a ${lengthKm} km mapped flood path.`,
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
area: pathArea(BHOTE_KOSHI_FLOOD_PATH),
|
||||
layers: ['bhote-koshi-2026'],
|
||||
}),
|
||||
id: event.id,
|
||||
title: event.title,
|
||||
observed_date: event.observedDate,
|
||||
caveat: event.reconstruction?.caveat ?? null,
|
||||
phases,
|
||||
evidence,
|
||||
witnesses: (event.witnessAnchors || []).map((anchor) => ({
|
||||
title: anchor.title ?? null,
|
||||
lat: round(anchor.lat, 6),
|
||||
lon: round(anchor.lon, 6),
|
||||
source_url: anchor.sourceUrl ?? null,
|
||||
provenance: anchor.provenance ?? null,
|
||||
})),
|
||||
flood_path: {
|
||||
start: pathPoint(BHOTE_KOSHI_FLOOD_PATH[0]),
|
||||
end: pathPoint(BHOTE_KOSHI_FLOOD_PATH.at(-1)),
|
||||
length_km: lengthKm,
|
||||
description:
|
||||
'Simplified river centerline from the debris-dammed lake to Trishuli Bazaar.',
|
||||
},
|
||||
imagery: {
|
||||
box: imagery.rectangle ?? null,
|
||||
before: imagery.before
|
||||
? {
|
||||
observed_at: imagery.before.observedAt,
|
||||
source: imagery.before.source,
|
||||
}
|
||||
: null,
|
||||
after: imagery.after
|
||||
? {
|
||||
observed_at: imagery.after.observedAt,
|
||||
source: imagery.after.source,
|
||||
}
|
||||
: null,
|
||||
},
|
||||
attribution: ATTRIBUTION,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createEventPackSource } from '../../sources/eventPacks.js';
|
||||
|
||||
const EVENT_URL = new URL(
|
||||
'../../../public/events/bhote-koshi-2026/event.json',
|
||||
import.meta.url,
|
||||
);
|
||||
const bundled = JSON.parse(await readFile(EVENT_URL, 'utf8'));
|
||||
|
||||
test('the event pack source reads each event once and checks the id', async () => {
|
||||
const requested = [];
|
||||
const source = createEventPackSource({
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(url);
|
||||
return Response.json(bundled);
|
||||
},
|
||||
});
|
||||
const event = await source.getEvent('bhote-koshi-2026');
|
||||
assert.deepEqual(event, bundled);
|
||||
assert.equal(await source.getEvent('bhote-koshi-2026'), event);
|
||||
assert.deepEqual(requested, ['/events/bhote-koshi-2026/event.json']);
|
||||
await assert.rejects(source.getEvent('../secret'), /Invalid event id/);
|
||||
const missing = createEventPackSource({
|
||||
fetchImpl: async () => new Response(null, { status: 404 }),
|
||||
});
|
||||
await assert.rejects(missing.getEvent('other'), /unavailable \(404\)/);
|
||||
});
|
||||
|
||||
test('the flood tool lists the bundled evidence in story order', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { events: { getEvent: async () => bundled } },
|
||||
});
|
||||
const all = await catalog.call('get_bhote_koshi_flood', {});
|
||||
assert.match(
|
||||
all.summary,
|
||||
/^Bhote Koshi Outburst Flood, observed 2026-08-26: 16 evidence records along a \d+(\.\d)? km mapped flood path\.$/,
|
||||
);
|
||||
assert.deepEqual(
|
||||
all.data.evidence.map((row) => row.sequence),
|
||||
Array.from({ length: 16 }, (_, index) => index + 1),
|
||||
);
|
||||
assert.deepEqual(all.data.evidence[3], {
|
||||
sequence: 4,
|
||||
id: 'gyirong-border-gate',
|
||||
title: 'Gyirong border gate',
|
||||
summary: 'Public footage geolocated at the Nepal-China border crossing.',
|
||||
phase: 'border arrival',
|
||||
lat: 28.28083,
|
||||
lon: 85.37802,
|
||||
location_confidence: 'geolocated',
|
||||
time_confidence: 'unverified',
|
||||
captured_at: null,
|
||||
in_imagery: true,
|
||||
source_url: 'https://www.youtube.com/watch?v=qlE51Eu0thk',
|
||||
platform: 'YouTube',
|
||||
via: 'GeoGeorgeShadrach public geolocation map; independently consistent with the linked GeoConfirmed witness',
|
||||
});
|
||||
assert.deepEqual(all.data.flood_path.start, { lat: 28.33255, lon: 85.48476 });
|
||||
assert.ok(all.data.flood_path.length_km > 50);
|
||||
assert.equal(all.data.imagery.after.observed_at, '2026-08-27');
|
||||
assert.match(all.data.caveat, /not an official hazard model/);
|
||||
assert.match(all.data.attribution, /CC BY-NC 4\.0/);
|
||||
const downstream = await catalog.call('get_bhote_koshi_flood', {
|
||||
phase: 'Downstream passage',
|
||||
});
|
||||
assert.ok(
|
||||
downstream.data.evidence.every((row) => row.phase === 'downstream passage'),
|
||||
);
|
||||
assert.match(downstream.summary, /in the Downstream passage phase/);
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
/** Natural hazard queries: earthquakes and active fires. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { AREA_SCHEMA, areaContains, resolveArea } from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
|
||||
export const getEarthquakes = defineTool({
|
||||
name: 'get_earthquakes',
|
||||
title: 'Recent earthquakes',
|
||||
description:
|
||||
'Earthquakes of magnitude 2.5 or more from the USGS feed for the last 24 ' +
|
||||
'hours, strongest first. Optionally limited to an area and a minimum magnitude.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
min_magnitude: { type: 'number', minimum: 2.5, maximum: 10 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['earthquakes'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
const minimum = args.min_magnitude ?? 2.5;
|
||||
const events = (await services.earthquakes.getSnapshot({ signal }))
|
||||
.filter(
|
||||
(event) => event.mag >= minimum && (!area || areaContains(area, event)),
|
||||
)
|
||||
.sort((a, b) => b.mag - a.mag || (b.time ?? 0) - (a.time ?? 0));
|
||||
const result = capRows(events, args.limit);
|
||||
const where = area ? ` in ${area.label}` : ' worldwide';
|
||||
const strongest = events[0]
|
||||
? `; strongest M${events[0].mag.toFixed(1)}${events[0].place ? ` ${events[0].place}` : ''}`
|
||||
: '';
|
||||
return {
|
||||
summary: `${countNoun(events.length, 'earthquake')} of M${minimum}+ in the last 24 hours${where}${strongest}.`,
|
||||
data: {
|
||||
view: suggestView(
|
||||
services,
|
||||
area
|
||||
? { area, layers: ['earthquakes'] }
|
||||
: {
|
||||
point: { lat: 20, lon: 0 },
|
||||
altitudeM: 15_000_000,
|
||||
layers: ['earthquakes'],
|
||||
},
|
||||
),
|
||||
...result,
|
||||
rows: result.rows.map((event) => ({
|
||||
id: event.usgsId ?? event.stableId,
|
||||
magnitude: event.mag,
|
||||
place: event.place,
|
||||
time: isoTime(event.time),
|
||||
lat: event.lat,
|
||||
lon: event.lon,
|
||||
depth_km: event.depthKm,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getActiveFires = defineTool({
|
||||
name: 'get_active_fires',
|
||||
title: 'Active fires',
|
||||
description:
|
||||
'Satellite fire detections from NASA FIRMS in the last 24 hours within an ' +
|
||||
'area, most intense (fire radiative power) first.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['fires'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const snapshot = await services.fires.getSnapshot({ signal });
|
||||
if (snapshot.keyRequired)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Fire detections need a NASA FIRMS key configured for this server',
|
||||
);
|
||||
const fires = snapshot.fires
|
||||
.filter((fire) => areaContains(area, fire))
|
||||
.sort((a, b) => (b.frp ?? 0) - (a.frp ?? 0));
|
||||
const result = capRows(fires, args.limit);
|
||||
// The proxy serves its last good snapshot when a refresh fails, and marks
|
||||
// satellites whose download failed.
|
||||
const stale = snapshot.stale === true;
|
||||
const missing = (snapshot.sources || [])
|
||||
.filter((source) => source?.ok === false)
|
||||
.map((source) => source.source);
|
||||
const notes = [
|
||||
...(stale ? ['data may be stale'] : []),
|
||||
...(missing.length ? [`no data from ${missing.join(', ')}`] : []),
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(fires.length, 'fire detection')} in ${area.label} in the last 24 hours` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['local-firms'] }),
|
||||
stale,
|
||||
missing_sources: missing,
|
||||
fetched_at: isoTime(snapshot.fetchedAt),
|
||||
...result,
|
||||
rows: result.rows.map((fire) => ({
|
||||
lat: fire.lat,
|
||||
lon: fire.lon,
|
||||
frp_mw: fire.frp ?? null,
|
||||
confidence: fire.confidence ?? null,
|
||||
acquired:
|
||||
[fire.acqDate, fire.acqTime].filter(Boolean).join(' ') || null,
|
||||
satellite: fire.satellite ?? null,
|
||||
day_night: fire.daynight ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,124 @@
|
||||
/** The most recent satellite image of an area. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { AREA_SCHEMA, areaRadiusKm, resolveArea } from '../area.js';
|
||||
import { toBase64 } from '../results.js';
|
||||
|
||||
const MAX_SIDE_KM = 1000;
|
||||
const MAX_LATITUDE = 85;
|
||||
const IMAGE_WIDTH = 1024;
|
||||
const MAX_IMAGE_BYTES = 6 * 1024 * 1024;
|
||||
const SENSORS = {
|
||||
S30: 'Sentinel-2 (30 m)',
|
||||
L30: 'Landsat 8/9 (30 m)',
|
||||
VIIRS: 'VIIRS daily overview (250 m)',
|
||||
};
|
||||
|
||||
export const getRecentImagery = defineTool({
|
||||
name: 'get_recent_imagery',
|
||||
title: 'Recent satellite image',
|
||||
description:
|
||||
'The most recent clear satellite image of an area from NASA Harmonized ' +
|
||||
'Landsat and Sentinel-2 (30 m), falling back to the VIIRS daily ' +
|
||||
`overview. Areas up to ${MAX_SIDE_KM} km across. Returns the image itself.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['imagery'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
if (area.west > area.east)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'The area must not cross the antimeridian',
|
||||
);
|
||||
if (
|
||||
Math.abs(area.south) > MAX_LATITUDE ||
|
||||
Math.abs(area.north) > MAX_LATITUDE
|
||||
)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`Imagery stops at ±${MAX_LATITUDE}° latitude`,
|
||||
);
|
||||
if (areaRadiusKm(area) * 2 > MAX_SIDE_KM * Math.SQRT2)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`The area is too large for imagery; use one up to ${MAX_SIDE_KM} km across`,
|
||||
);
|
||||
const box = {
|
||||
west: area.west,
|
||||
south: area.south,
|
||||
east: area.east,
|
||||
north: area.north,
|
||||
};
|
||||
let latest;
|
||||
try {
|
||||
latest = await services.imagery.latest({ box, signal });
|
||||
} catch (error) {
|
||||
if (error instanceof TypeError)
|
||||
throw new ToolError('invalid_arguments', error.message);
|
||||
throw error;
|
||||
}
|
||||
const { candidate, reason } = latest;
|
||||
if (!candidate)
|
||||
return {
|
||||
summary: `No recent satellite image covers ${area.label}.`,
|
||||
data: { area: area.label, image: null },
|
||||
};
|
||||
const height = Math.max(
|
||||
64,
|
||||
Math.min(
|
||||
1024,
|
||||
Math.round(
|
||||
(IMAGE_WIDTH * (box.north - box.south)) / (box.east - box.west),
|
||||
),
|
||||
),
|
||||
);
|
||||
const image = await services.imagery.getSnapshot({
|
||||
product: candidate.product,
|
||||
day: candidate.day,
|
||||
box,
|
||||
width: IMAGE_WIDTH,
|
||||
height,
|
||||
signal,
|
||||
});
|
||||
if (!/^image\/(png|jpeg)$/.test(image.contentType))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'The imagery service returned no image',
|
||||
);
|
||||
if (image.bytes.byteLength > MAX_IMAGE_BYTES)
|
||||
throw new ToolError('unavailable', 'The image is too large to return');
|
||||
const sensor = SENSORS[candidate.product] || candidate.product;
|
||||
const cloud = candidate.cloud
|
||||
? `${Math.round(candidate.cloud.min)}–${Math.round(candidate.cloud.max)}% cloud`
|
||||
: 'cloud cover unknown';
|
||||
const quality = {
|
||||
clear: 'clear',
|
||||
cloudy: 'cloudy',
|
||||
partial: 'partly covering the area',
|
||||
overview: 'coarse overview',
|
||||
}[reason];
|
||||
return {
|
||||
summary: `Most recent image of ${area.label}: ${sensor}, ${candidate.day}, ${quality}${reason === 'overview' ? '' : `, ${cloud}`}.`,
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['recent-imagery'] }),
|
||||
area: area.label,
|
||||
image: {
|
||||
product: candidate.product,
|
||||
sensor,
|
||||
day: candidate.day,
|
||||
quality: reason,
|
||||
cloud_pct: candidate.cloud ?? null,
|
||||
coverage: candidate.coverage ?? null,
|
||||
},
|
||||
search_errors: latest.errors?.map((error) => error.product) ?? [],
|
||||
},
|
||||
images: [{ mimeType: image.contentType, data: toBase64(image.bytes) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
|
||||
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47]);
|
||||
const imagery = (latest) => ({
|
||||
snapshots: [],
|
||||
boxes: [],
|
||||
async latest({ box }) {
|
||||
this.boxes.push(box);
|
||||
return latest;
|
||||
},
|
||||
async getSnapshot(request) {
|
||||
this.snapshots.push(request);
|
||||
return { contentType: 'image/png', bytes: PNG };
|
||||
},
|
||||
});
|
||||
const austin = { bbox: [-97.9, 30.1, -97.5, 30.5] };
|
||||
|
||||
test('the most recent clear image is returned with its date and sensor', async () => {
|
||||
const service = imagery({
|
||||
candidate: {
|
||||
product: 'S30',
|
||||
day: '2026-09-28',
|
||||
cloud: { min: 2, max: 6 },
|
||||
coverage: 'full',
|
||||
},
|
||||
reason: 'clear',
|
||||
certain: true,
|
||||
errors: [],
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { imagery: service },
|
||||
});
|
||||
const result = await catalog.call('get_recent_imagery', { area: austin });
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Most recent image of the requested box: Sentinel-2 (30 m), 2026-09-28, clear, 2–6% cloud.',
|
||||
);
|
||||
assert.deepEqual(result.images, [
|
||||
{ mimeType: 'image/png', data: 'iVBORw==' },
|
||||
]);
|
||||
assert.deepEqual(service.snapshots[0], {
|
||||
product: 'S30',
|
||||
day: '2026-09-28',
|
||||
box: { west: -97.9, south: 30.1, east: -97.5, north: 30.5 },
|
||||
width: 1024,
|
||||
height: 1024,
|
||||
signal: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('overview fallback, empty results and invalid areas are reported', async () => {
|
||||
const overview = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
imagery: imagery({
|
||||
candidate: { product: 'VIIRS', day: '2026-10-01', cloud: null },
|
||||
reason: 'overview',
|
||||
errors: [{ product: 'S30' }],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const coarse = await overview.call('get_recent_imagery', { area: austin });
|
||||
assert.equal(
|
||||
coarse.summary,
|
||||
'Most recent image of the requested box: VIIRS daily overview (250 m), 2026-10-01, coarse overview.',
|
||||
);
|
||||
assert.deepEqual(coarse.data.search_errors, ['S30']);
|
||||
const none = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { imagery: imagery({ candidate: null, reason: null }) },
|
||||
});
|
||||
assert.equal(
|
||||
(await none.call('get_recent_imagery', { area: austin })).summary,
|
||||
'No recent satellite image covers the requested box.',
|
||||
);
|
||||
await assert.rejects(
|
||||
none.call('get_recent_imagery', { area: { bbox: [-120, 20, -60, 50] } }),
|
||||
/too large for imagery/,
|
||||
);
|
||||
await assert.rejects(
|
||||
none.call('get_recent_imagery', { area: { bbox: [170, 0, -170, 5] } }),
|
||||
/antimeridian/,
|
||||
);
|
||||
await assert.rejects(
|
||||
none.call('get_recent_imagery', { area: { bbox: [0, 85.5, 1, 86] } }),
|
||||
/±85° latitude/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,72 @@
|
||||
/** Bundled OpenStreetMap infrastructure: datacenters and dams. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun } from '../results.js';
|
||||
|
||||
const KINDS = {
|
||||
datacenters: { layerId: 'local-datacenters', noun: 'datacenter' },
|
||||
dams: { layerId: 'local-dams', noun: 'dam' },
|
||||
};
|
||||
const ATTRIBUTION = {
|
||||
datacenters: '© OpenStreetMap contributors (ODbL 1.0)',
|
||||
dams: '© OpenStreetMap contributors (ODbL 1.0), via Open Infrastructure Map',
|
||||
};
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
export const findInfrastructure = defineTool({
|
||||
name: 'find_infrastructure',
|
||||
title: 'Datacenters and dams',
|
||||
description:
|
||||
'Datacenters (with operator and capacity) or dams (with river and power ' +
|
||||
'output) mapped in OpenStreetMap within an area, nearest the center first.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
kind: { type: 'string', enum: Object.keys(KINDS) },
|
||||
area: AREA_SCHEMA,
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['kind', 'area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['infrastructure'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const { layerId, noun } = KINDS[args.kind];
|
||||
const records = await services.infrastructure.getRecords(layerId, {
|
||||
signal,
|
||||
});
|
||||
const rows = records
|
||||
.filter((record) => areaContains(area, record))
|
||||
.map((record) => ({
|
||||
name: record.name,
|
||||
operator: record.operator,
|
||||
...(args.kind === 'datacenters'
|
||||
? { capacity: record.capacity }
|
||||
: { river: record.river, output: record.output }),
|
||||
lat: round(record.lat, 5),
|
||||
lon: round(record.lon, 5),
|
||||
distance_km: round(distanceKm(center, record), 1),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
return {
|
||||
summary: `${countNoun(rows.length, `mapped ${noun}`)} in ${area.label}.`,
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: [layerId] }),
|
||||
...capRows(rows, args.limit),
|
||||
attribution: ATTRIBUTION[args.kind],
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createApiFetch } from '../../../server/mcp/services.js';
|
||||
import {
|
||||
createInfrastructureSource,
|
||||
featurePoint,
|
||||
} from '../../sources/infrastructureData.js';
|
||||
|
||||
test('features are placed at their point or average outline vertex', () => {
|
||||
assert.deepEqual(
|
||||
featurePoint({ geometry: { type: 'Point', coordinates: [10, 20] } }),
|
||||
{ lon: 10, lat: 20 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
featurePoint({
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[0, 0],
|
||||
[2, 0],
|
||||
[2, 2],
|
||||
[0, 2],
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ lon: 1, lat: 1 },
|
||||
);
|
||||
assert.deepEqual(
|
||||
featurePoint({
|
||||
geometry: {
|
||||
type: 'MultiPolygon',
|
||||
coordinates: [
|
||||
[
|
||||
[
|
||||
[4, 4],
|
||||
[6, 6],
|
||||
],
|
||||
],
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ lon: 5, lat: 5 },
|
||||
);
|
||||
assert.equal(featurePoint({ geometry: null }), null);
|
||||
});
|
||||
|
||||
test('datacenters and dams are listed nearest first with their own fields', async () => {
|
||||
const records = {
|
||||
'local-datacenters': [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'Equinix DC2',
|
||||
lat: 38.9,
|
||||
lon: -77.45,
|
||||
operator: 'Equinix',
|
||||
capacity: '20 MW',
|
||||
river: null,
|
||||
output: null,
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'Far DC',
|
||||
lat: 48.8,
|
||||
lon: 2.35,
|
||||
operator: null,
|
||||
capacity: null,
|
||||
river: null,
|
||||
output: null,
|
||||
},
|
||||
],
|
||||
'local-dams': [
|
||||
{
|
||||
id: 'c',
|
||||
name: 'Hoover Dam',
|
||||
lat: 36.016,
|
||||
lon: -114.737,
|
||||
operator: 'USBR',
|
||||
capacity: null,
|
||||
river: 'Colorado River',
|
||||
output: '2080 MW',
|
||||
},
|
||||
],
|
||||
};
|
||||
const infrastructure = { getRecords: async (layerId) => records[layerId] };
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { infrastructure },
|
||||
});
|
||||
const dc = await catalog.call('find_infrastructure', {
|
||||
kind: 'datacenters',
|
||||
area: { lat: 38.95, lon: -77.45, radius_km: 20 },
|
||||
});
|
||||
assert.equal(
|
||||
dc.summary,
|
||||
'1 mapped datacenter in 20 km around 38.950, -77.450.',
|
||||
);
|
||||
assert.deepEqual(dc.data.rows[0], {
|
||||
name: 'Equinix DC2',
|
||||
operator: 'Equinix',
|
||||
capacity: '20 MW',
|
||||
lat: 38.9,
|
||||
lon: -77.45,
|
||||
distance_km: 5.6,
|
||||
});
|
||||
assert.match(dc.data.attribution, /OpenStreetMap contributors \(ODbL 1\.0\)/);
|
||||
const dams = await catalog.call('find_infrastructure', {
|
||||
kind: 'dams',
|
||||
area: { lat: 36, lon: -114.7, radius_km: 20 },
|
||||
});
|
||||
assert.deepEqual(dams.data.rows[0], {
|
||||
name: 'Hoover Dam',
|
||||
operator: 'USBR',
|
||||
river: 'Colorado River',
|
||||
output: '2080 MW',
|
||||
lat: 36.016,
|
||||
lon: -114.737,
|
||||
distance_km: 3.8,
|
||||
});
|
||||
});
|
||||
|
||||
test('the bundled datasets load through the local fetch', async () => {
|
||||
const fetchImpl = createApiFetch({
|
||||
apiBase: 'http://127.0.0.1:1',
|
||||
fetchImpl: async () => assert.fail('no network'),
|
||||
});
|
||||
const source = createInfrastructureSource({ fetchImpl });
|
||||
const dams = await source.getRecords('local-dams');
|
||||
const datacenters = await source.getRecords('local-datacenters');
|
||||
assert.ok(dams.length > 500 && datacenters.length > 3000);
|
||||
assert.ok(
|
||||
dams.every(
|
||||
(record) => Number.isFinite(record.lat) && Number.isFinite(record.lon),
|
||||
),
|
||||
);
|
||||
assert.equal(await source.getRecords('local-dams'), dams);
|
||||
await assert.rejects(
|
||||
source.getRecords('local-other'),
|
||||
/Unknown infrastructure layer/,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,216 @@
|
||||
/** Maritime queries over the live vessel source (src/sources/live). */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import {
|
||||
LIMIT_SCHEMA,
|
||||
capRows,
|
||||
countNoun,
|
||||
isoTime,
|
||||
thinEvenly,
|
||||
} from '../results.js';
|
||||
|
||||
const MMSI_SCHEMA = Object.freeze({
|
||||
type: 'string',
|
||||
pattern: '^\\d{5,10}$',
|
||||
description: 'Maritime Mobile Service Identity, such as "366999712".',
|
||||
});
|
||||
const MAX_TRACK_POINTS = 200;
|
||||
const ALL_VESSELS = 1_000_000;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||
|
||||
function vesselRow(record, from) {
|
||||
const point = { lat: record.latitude, lon: record.longitude };
|
||||
return {
|
||||
mmsi: record.id,
|
||||
name: text(record.name),
|
||||
imo: text(record.imo),
|
||||
type: text(record.type),
|
||||
destination: text(record.destination),
|
||||
lat: round(record.latitude, 5),
|
||||
lon: round(record.longitude, 5),
|
||||
speed_mps: round(record.speedMps, 1),
|
||||
course_deg: round(record.courseDeg, 0),
|
||||
heading_deg: round(record.headingDeg, 0),
|
||||
observed_at: isoTime(record.observedAtMs),
|
||||
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Every vessel the source retains. The source's default is the newest few
|
||||
* thousand, which can leave out a vessel the server still holds; asking for
|
||||
* more than any server keeps lets the server return all of them.
|
||||
*/
|
||||
function readVessels(services, signal) {
|
||||
return services.vessels.getSnapshot({ maxRows: ALL_VESSELS }, { signal });
|
||||
}
|
||||
|
||||
function snapshotInfo(snapshot) {
|
||||
return {
|
||||
source: snapshot.source ?? null,
|
||||
coverage: snapshot.coverage ?? null,
|
||||
observed_at: isoTime(snapshot.observedAtMs),
|
||||
freshness: snapshot.freshness ?? 'unknown',
|
||||
};
|
||||
}
|
||||
|
||||
export const vesselsInArea = defineTool({
|
||||
name: 'vessels_in_area',
|
||||
title: 'Vessels in an area',
|
||||
description:
|
||||
'Ships currently reported by AIS inside an area, nearest the center ' +
|
||||
'first, optionally filtered by vessel type.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
type: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 40,
|
||||
description: 'Vessel type to match, such as "tanker" or "cargo".',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['vessels'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const snapshot = await readVessels(services, signal);
|
||||
const wanted = args.type?.toLowerCase();
|
||||
const rows = snapshot.records
|
||||
.filter(
|
||||
(record) =>
|
||||
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
|
||||
(!wanted || String(record.type).toLowerCase().includes(wanted)),
|
||||
)
|
||||
.map((record) => vesselRow(record, center))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const kind = args.type ? `${args.type} vessel` : 'vessel';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, kind)} in ${area.label}` +
|
||||
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['ais-live-vessels'] }),
|
||||
...capRows(rows, args.limit),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const findVessel = defineTool({
|
||||
name: 'find_vessel',
|
||||
title: 'Find a vessel',
|
||||
description:
|
||||
'Find ships currently reported anywhere by MMSI, IMO number or name. ' +
|
||||
'Names match when they contain the given text.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
mmsi: MMSI_SCHEMA,
|
||||
imo: { type: 'string', pattern: '^\\d{7}$' },
|
||||
name: { type: 'string', minLength: 2, maxLength: 60 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['vessels'],
|
||||
async run(args, { services, signal }) {
|
||||
const given = ['mmsi', 'imo', 'name'].filter((key) => args[key] != null);
|
||||
if (given.length !== 1)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'Give exactly one of mmsi, imo or name',
|
||||
);
|
||||
const [key] = given;
|
||||
const wanted = args[key].trim().toUpperCase();
|
||||
const matches = {
|
||||
mmsi: (record) => record.id === wanted,
|
||||
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
|
||||
name: (record) => String(record.name).toUpperCase().includes(wanted),
|
||||
}[key];
|
||||
const snapshot = await readVessels(services, signal);
|
||||
const rows = snapshot.records
|
||||
.filter(matches)
|
||||
.map((record) => vesselRow(record));
|
||||
return {
|
||||
summary: rows.length
|
||||
? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.`
|
||||
: `No vessel with ${key} ${args[key]} is currently reported.`,
|
||||
data: {
|
||||
// Ships cannot be followed from a link; one match is framed closely.
|
||||
view:
|
||||
rows.length === 1
|
||||
? suggestView(services, {
|
||||
point: { lat: rows[0].lat, lon: rows[0].lon },
|
||||
layers: ['ais-live-vessels'],
|
||||
altitudeM: 5_000,
|
||||
})
|
||||
: null,
|
||||
...capRows(rows, args.limit),
|
||||
...snapshotInfo(snapshot),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getVesselTrack = defineTool({
|
||||
name: 'get_vessel_track',
|
||||
title: 'Vessel track',
|
||||
description:
|
||||
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
|
||||
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { mmsi: MMSI_SCHEMA },
|
||||
required: ['mmsi'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['vessels'],
|
||||
async run(args, { services, signal }) {
|
||||
const track = await services.vessels.getTrack(args.mmsi, { signal });
|
||||
const points = track.records
|
||||
.filter((point) => Number.isFinite(point.observedAtMs))
|
||||
.sort((a, b) => a.observedAtMs - b.observedAtMs);
|
||||
const kept = thinEvenly(points, MAX_TRACK_POINTS);
|
||||
return {
|
||||
summary: points.length
|
||||
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
|
||||
: `No recent track is available for ${args.mmsi}.`,
|
||||
data: {
|
||||
view: kept.length
|
||||
? suggestView(services, {
|
||||
point: { lat: kept.at(-1).latitude, lon: kept.at(-1).longitude },
|
||||
layers: ['ais-live-vessels'],
|
||||
altitudeM: 20_000,
|
||||
})
|
||||
: null,
|
||||
mmsi: args.mmsi,
|
||||
total: points.length,
|
||||
returned: kept.length,
|
||||
thinned: kept.length < points.length,
|
||||
points: kept.map((point) => ({
|
||||
time: isoTime(point.observedAtMs),
|
||||
lat: round(point.latitude, 5),
|
||||
lon: round(point.longitude, 5),
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { LiveSourceError } from '../../sources/live/contract.js';
|
||||
import { createAisStreamSource } from '../../sources/live/standalone.js';
|
||||
import { AISSTREAM_CACHE_MAX } from '../../../server/providers/vessels/ais-store.js';
|
||||
|
||||
const vessel = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
reference: id,
|
||||
latitude,
|
||||
longitude,
|
||||
name: `SHIP ${id}`,
|
||||
imo: '',
|
||||
type: 'Cargo',
|
||||
destination: '',
|
||||
speedMps: 6.17,
|
||||
courseDeg: 181.2,
|
||||
headingDeg: 180,
|
||||
observedAtMs: Date.UTC(2026, 0, 1),
|
||||
altitudeDatum: 'sea-surface',
|
||||
...extra,
|
||||
});
|
||||
const records = [
|
||||
vessel('366999712', 37.81, -122.41, {
|
||||
name: 'GOLDEN BEAR',
|
||||
imo: '9123456',
|
||||
type: 'Passenger',
|
||||
}),
|
||||
vessel('538001234', 37.75, -122.3, {
|
||||
name: 'PACIFIC TRADER',
|
||||
imo: 'IMO7654321',
|
||||
type: 'Tanker',
|
||||
destination: 'OAKLAND',
|
||||
}),
|
||||
vessel('235000001', 51.5, 1.5),
|
||||
];
|
||||
const vessels = (extra = {}) => ({
|
||||
async getSnapshot() {
|
||||
return {
|
||||
records,
|
||||
source: 'AISStream',
|
||||
coverage: 'received AIS positions',
|
||||
observedAtMs: Date.UTC(2026, 0, 1),
|
||||
freshness: 'current',
|
||||
...extra,
|
||||
};
|
||||
},
|
||||
async getTrack(mmsi) {
|
||||
return {
|
||||
records: [
|
||||
{
|
||||
latitude: 37.8,
|
||||
longitude: -122.4,
|
||||
observedAtMs: Date.UTC(2026, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
latitude: 37.7,
|
||||
longitude: -122.5,
|
||||
observedAtMs: Date.UTC(2026, 0, 1, 0),
|
||||
},
|
||||
{ latitude: 37.6, longitude: -122.6, observedAtMs: null },
|
||||
].filter(() => mmsi === '366999712'),
|
||||
complete: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
const bay = { lat: 37.78, lon: -122.4, radius_km: 20 };
|
||||
|
||||
test('vessels in an area are nearest first and filterable by type', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
const all = await catalog.call('vessels_in_area', { area: bay });
|
||||
assert.equal(all.summary, '2 vessels in 20 km around 37.780, -122.400.');
|
||||
assert.deepEqual(
|
||||
all.data.rows.map((row) => row.mmsi),
|
||||
['366999712', '538001234'],
|
||||
);
|
||||
assert.deepEqual(all.data.rows[0], {
|
||||
mmsi: '366999712',
|
||||
name: 'GOLDEN BEAR',
|
||||
imo: '9123456',
|
||||
type: 'Passenger',
|
||||
destination: null,
|
||||
lat: 37.81,
|
||||
lon: -122.41,
|
||||
speed_mps: 6.2,
|
||||
course_deg: 181,
|
||||
heading_deg: 180,
|
||||
observed_at: '2026-01-01T00:00:00.000Z',
|
||||
distance_km: 3.4,
|
||||
});
|
||||
assert.equal(all.data.source, 'AISStream');
|
||||
const tankers = await catalog.call('vessels_in_area', {
|
||||
area: bay,
|
||||
type: 'tanker',
|
||||
});
|
||||
assert.equal(
|
||||
tankers.summary,
|
||||
'1 tanker vessel in 20 km around 37.780, -122.400.',
|
||||
);
|
||||
const stale = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels({ freshness: 'stale' }) },
|
||||
});
|
||||
assert.match(
|
||||
(await stale.call('vessels_in_area', { area: bay })).summary,
|
||||
/\(data may be stale\)\.$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('vessels are found by exactly one identifier', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
assert.deepEqual(
|
||||
(await catalog.call('find_vessel', { mmsi: '366999712' })).data.rows.map(
|
||||
(row) => row.name,
|
||||
),
|
||||
['GOLDEN BEAR'],
|
||||
);
|
||||
assert.deepEqual(
|
||||
(await catalog.call('find_vessel', { imo: '7654321' })).data.rows.map(
|
||||
(row) => row.mmsi,
|
||||
),
|
||||
['538001234'],
|
||||
);
|
||||
const byName = await catalog.call('find_vessel', { name: 'trader' });
|
||||
assert.equal(byName.summary, 'Found 1 vessel with name trader.');
|
||||
assert.equal(
|
||||
(await catalog.call('find_vessel', { mmsi: '99999' })).summary,
|
||||
'No vessel with mmsi 99999 is currently reported.',
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_vessel', { mmsi: '366999712', name: 'xy' }),
|
||||
/exactly one/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('find_vessel', { mmsi: 'abc' }),
|
||||
/must match/,
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel tracks are ordered and drop untimed positions', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: vessels() },
|
||||
});
|
||||
const track = await catalog.call('get_vessel_track', { mmsi: '366999712' });
|
||||
assert.equal(
|
||||
track.summary,
|
||||
'2 positions for 366999712 from 2026-01-01T00:00:00.000Z to 2026-01-01T01:00:00.000Z.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
track.data.points.map((point) => point.lat),
|
||||
[37.7, 37.8],
|
||||
);
|
||||
assert.equal(
|
||||
(await catalog.call('get_vessel_track', { mmsi: '235000001' })).summary,
|
||||
'No recent track is available for 235000001.',
|
||||
);
|
||||
});
|
||||
|
||||
test('a missing AIS key surfaces as an unavailable feed', async () => {
|
||||
const keyless = {
|
||||
getSnapshot: async () => {
|
||||
throw new LiveSourceError('unavailable', 'AISSTREAM_API_KEY not set');
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: keyless },
|
||||
});
|
||||
await assert.rejects(
|
||||
catalog.call('vessels_in_area', { area: bay }),
|
||||
(error) =>
|
||||
error.code === 'unavailable' &&
|
||||
error.message === 'AISSTREAM_API_KEY not set',
|
||||
);
|
||||
});
|
||||
|
||||
test('vessel searches ask for every vessel the server retains', async () => {
|
||||
const requested = [];
|
||||
const source = createAisStreamSource({
|
||||
origin: () => 'http://localhost',
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(new URL(url).searchParams.get('maxRows'));
|
||||
return Response.json({ rows: [], source: 'AISStream', status: 'live' });
|
||||
},
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { vessels: source },
|
||||
});
|
||||
await catalog.call('find_vessel', { mmsi: '366999712' });
|
||||
await catalog.call('vessels_in_area', { area: bay });
|
||||
assert.equal(requested.length, 2);
|
||||
for (const maxRows of requested)
|
||||
assert.ok(Number(maxRows) >= AISSTREAM_CACHE_MAX, maxRows);
|
||||
});
|
||||
@@ -0,0 +1,265 @@
|
||||
/** Public camera and radio queries over the CCTV and radio sources. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, toBase64 } from '../results.js';
|
||||
|
||||
const FRAME_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp']);
|
||||
const MAX_FRAME_BYTES = 3 * 1024 * 1024;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
const text = (value) => (typeof value === 'string' && value ? value : null);
|
||||
|
||||
async function readCatalog(services, signal) {
|
||||
const payload = await services.cctv.getCatalog({ signal });
|
||||
return {
|
||||
cameras: payload.sources.filter(
|
||||
(camera) =>
|
||||
camera?.id &&
|
||||
Number.isFinite(camera.lat) &&
|
||||
Number.isFinite(camera.lon),
|
||||
),
|
||||
// Regional packs the catalog serves only part of, nearest their centers.
|
||||
trimmed: Array.isArray(payload.trimmedPacks) ? payload.trimmedPacks : [],
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether an area's box overlaps a plain region box. */
|
||||
function boxesOverlap(area, region) {
|
||||
if (area.south > region.north || area.north < region.south) return false;
|
||||
const spans =
|
||||
area.west <= area.east
|
||||
? [[area.west, area.east]]
|
||||
: [
|
||||
[area.west, 180],
|
||||
[-180, area.east],
|
||||
];
|
||||
return spans.some(
|
||||
([west, east]) => west <= region.east && east >= region.west,
|
||||
);
|
||||
}
|
||||
|
||||
async function readCameras(services, signal) {
|
||||
return (await readCatalog(services, signal)).cameras;
|
||||
}
|
||||
|
||||
export const findCctvCameras = defineTool({
|
||||
name: 'find_cctv_cameras',
|
||||
title: 'Public cameras in an area',
|
||||
description:
|
||||
'Public traffic and city cameras in an area, nearest the center first. ' +
|
||||
'Use get_cctv_snapshot with a camera id to see its current view.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['cctv'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const catalog = await readCatalog(services, signal);
|
||||
const found = catalog.cameras.filter((camera) =>
|
||||
areaContains(area, camera),
|
||||
);
|
||||
// A trimmed pack matters wherever its cameras are, served or not, so
|
||||
// its region decides, not the cameras returned.
|
||||
const packs = new Set(found.map((camera) => camera.pack).filter(Boolean));
|
||||
const trimmed = catalog.trimmed.filter((entry) =>
|
||||
entry.region ? boxesOverlap(area, entry.region) : packs.has(entry.pack),
|
||||
);
|
||||
const rows = found
|
||||
.map((camera) => ({
|
||||
id: camera.id,
|
||||
name: text(camera.name),
|
||||
city: text(camera.city),
|
||||
provider: text(camera.provider),
|
||||
lat: round(camera.lat, 6),
|
||||
lon: round(camera.lon, 6),
|
||||
heading_deg: round(camera.headingDeg, 0),
|
||||
feed_type: text(camera.feedType),
|
||||
credit: text(camera.credit),
|
||||
distance_km: round(distanceKm(center, camera), 2),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
return {
|
||||
summary:
|
||||
(rows.length || trimmed.length
|
||||
? `${countNoun(rows.length, 'public camera')} in ${area.label}`
|
||||
: // The catalog covers selected regions; an empty answer elsewhere
|
||||
// means it has no cameras there, not that none exist.
|
||||
`The camera catalog has no cameras in ${area.label}`) +
|
||||
(trimmed.length
|
||||
? ` (the catalog serves only some cameras here: ${trimmed
|
||||
.map(
|
||||
(entry) =>
|
||||
`${entry.served} of ${entry.available} from ${entry.pack}`,
|
||||
)
|
||||
.join(', ')}).`
|
||||
: '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['cctv'] }),
|
||||
...capRows(rows, args.limit),
|
||||
complete: trimmed.length === 0,
|
||||
catalog_trimmed: trimmed,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getCctvSnapshot = defineTool({
|
||||
name: 'get_cctv_snapshot',
|
||||
title: 'Current camera view',
|
||||
description:
|
||||
'The current image from one public camera, by the id from find_cctv_cameras.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { camera_id: { type: 'string', minLength: 1, maxLength: 200 } },
|
||||
required: ['camera_id'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['cctv'],
|
||||
async run(args, { services, signal }) {
|
||||
const camera = (await readCameras(services, signal)).find(
|
||||
(candidate) => candidate.id === args.camera_id,
|
||||
);
|
||||
if (!camera)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`No camera has id ${args.camera_id}; use find_cctv_cameras first`,
|
||||
);
|
||||
const frame = await services.cctv.getFrame(camera, { signal });
|
||||
if (!FRAME_TYPES.has(frame.contentType))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
`Camera ${camera.name || camera.id} has no still image right now`,
|
||||
);
|
||||
if (frame.bytes.byteLength > MAX_FRAME_BYTES)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'The camera image is too large to return',
|
||||
);
|
||||
const name = text(camera.name) || camera.id;
|
||||
return {
|
||||
summary: `Current view from ${name}${camera.city ? ` in ${camera.city}` : ''}${camera.credit ? `, courtesy of ${camera.credit}` : ''}.`,
|
||||
data: {
|
||||
view: suggestView(services, {
|
||||
point: { lat: camera.lat, lon: camera.lon },
|
||||
altitudeM: 1_500,
|
||||
layers: ['cctv'],
|
||||
}),
|
||||
id: camera.id,
|
||||
name,
|
||||
city: text(camera.city),
|
||||
provider: text(camera.provider),
|
||||
credit: text(camera.credit),
|
||||
mime_type: frame.contentType,
|
||||
bytes: frame.bytes.byteLength,
|
||||
},
|
||||
images: [{ mimeType: frame.contentType, data: toBase64(frame.bytes) }],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const findRadioStations = defineTool({
|
||||
name: 'find_radio_stations',
|
||||
title: 'Radio stations',
|
||||
description:
|
||||
'Internet radio stations from a directory of popular Radio Browser ' +
|
||||
'stations, not every station, by area and/or ' +
|
||||
'a search term matched against name, tags, language and country. Each ' +
|
||||
"result includes the broadcaster's public stream URL.",
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
query: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 80,
|
||||
description: 'Words such as "jazz", "news" or "Tokyo".',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['radio'],
|
||||
async run(args, { services, signal }) {
|
||||
if (!args.area && !args.query)
|
||||
throw new ToolError('invalid_arguments', 'Give an area, a query or both');
|
||||
const area = args.area
|
||||
? await resolveArea(args.area, { services, signal })
|
||||
: null;
|
||||
const terms = String(args.query || '')
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
const directory = await services.radio.getDirectory({ signal });
|
||||
if (!Array.isArray(directory?.stations))
|
||||
throw new ToolError(
|
||||
'malformed',
|
||||
'The radio directory returned no stations',
|
||||
);
|
||||
const center = area ? areaCenter(area) : null;
|
||||
const rows = directory.stations
|
||||
.filter((station) => !area || areaContains(area, station))
|
||||
.filter((station) => {
|
||||
if (!terms.length) return true;
|
||||
const haystack = [
|
||||
station.name,
|
||||
...(station.tags || []),
|
||||
...(station.languages || []),
|
||||
station.country,
|
||||
station.state,
|
||||
]
|
||||
.join(' ')
|
||||
.toLowerCase();
|
||||
return terms.every((term) => haystack.includes(term));
|
||||
})
|
||||
.map((station) => ({
|
||||
id: station.id,
|
||||
name: text(station.name),
|
||||
country: text(station.country),
|
||||
state: text(station.state),
|
||||
tags: station.tags || [],
|
||||
languages: station.languages || [],
|
||||
codec: text(station.codec),
|
||||
bitrate_kbps: Number.isFinite(station.bitrate) ? station.bitrate : null,
|
||||
stream_url: text(station.streamUrl),
|
||||
homepage: text(station.homepage),
|
||||
lat: round(station.lat, 4),
|
||||
lon: round(station.lon, 4),
|
||||
...(center
|
||||
? { distance_km: round(distanceKm(center, station), 1) }
|
||||
: {}),
|
||||
}));
|
||||
if (center) rows.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const where = area ? ` in ${area.label}` : '';
|
||||
const what = args.query ? ` matching "${args.query}"` : '';
|
||||
// The directory is a selection of popular stations, not every station.
|
||||
const notes = [
|
||||
`from a directory of ${directory.stations.length} popular stations`,
|
||||
...(directory.stale ? ['the directory may be stale'] : []),
|
||||
...(directory.degraded ? ['the directory is incomplete right now'] : []),
|
||||
];
|
||||
return {
|
||||
summary: `${countNoun(rows.length, 'radio station')}${what}${where} (${notes.join('; ')}).`,
|
||||
data: {
|
||||
view: area ? suggestView(services, { area, layers: ['radio'] }) : null,
|
||||
...capRows(rows, args.limit),
|
||||
directory_size: directory.stations.length,
|
||||
stale: directory.stale === true,
|
||||
degraded: directory.degraded === true,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,328 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createMcpServer } from '../mcp/index.js';
|
||||
|
||||
const cameras = [
|
||||
{
|
||||
id: 'austin-1',
|
||||
name: 'Congress at 6th',
|
||||
city: 'Austin',
|
||||
provider: 'City of Austin',
|
||||
lat: 30.2685,
|
||||
lon: -97.7425,
|
||||
headingDeg: 180,
|
||||
feedType: 'image',
|
||||
credit: 'City of Austin',
|
||||
},
|
||||
{
|
||||
id: 'austin-2',
|
||||
name: 'Lamar at 5th',
|
||||
city: 'Austin',
|
||||
provider: 'City of Austin',
|
||||
lat: 30.2695,
|
||||
lon: -97.7535,
|
||||
headingDeg: 90,
|
||||
feedType: 'image',
|
||||
credit: '',
|
||||
},
|
||||
{ id: 'far', name: 'Elsewhere', city: 'Oslo', lat: 59.9, lon: 10.7 },
|
||||
{ id: 'no-position', name: 'Unplaced' },
|
||||
];
|
||||
const PNG = Uint8Array.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
||||
const cctv = (frame = { contentType: 'image/png', bytes: PNG }) => ({
|
||||
frames: [],
|
||||
async getCatalog() {
|
||||
return { sources: cameras };
|
||||
},
|
||||
async getFrame(camera) {
|
||||
this.frames.push(camera.id);
|
||||
return frame;
|
||||
},
|
||||
});
|
||||
const downtown = { lat: 30.2672, lon: -97.7431, radius_km: 3 };
|
||||
|
||||
test('cameras in an area are listed nearest first', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { cctv: cctv() },
|
||||
});
|
||||
const result = await catalog.call('find_cctv_cameras', { area: downtown });
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 public cameras in 3 km around 30.267, -97.743.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['austin-1', 'austin-2'],
|
||||
);
|
||||
assert.deepEqual(result.data.rows[0], {
|
||||
id: 'austin-1',
|
||||
name: 'Congress at 6th',
|
||||
city: 'Austin',
|
||||
provider: 'City of Austin',
|
||||
lat: 30.2685,
|
||||
lon: -97.7425,
|
||||
heading_deg: 180,
|
||||
feed_type: 'image',
|
||||
credit: 'City of Austin',
|
||||
distance_km: 0.16,
|
||||
});
|
||||
assert.equal(result.data.rows[1].credit, null);
|
||||
});
|
||||
|
||||
test('a snapshot returns the frame as an image with its credit', async () => {
|
||||
const source = cctv();
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { cctv: source },
|
||||
});
|
||||
const result = await catalog.call('get_cctv_snapshot', {
|
||||
camera_id: 'austin-1',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Current view from Congress at 6th in Austin, courtesy of City of Austin.',
|
||||
);
|
||||
assert.deepEqual(result.images, [
|
||||
{ mimeType: 'image/png', data: 'iVBORw0KGgo=' },
|
||||
]);
|
||||
assert.equal(result.data.bytes, 8);
|
||||
assert.deepEqual(source.frames, ['austin-1']);
|
||||
|
||||
const server = createMcpServer({ catalog, name: 'test', version: '1' });
|
||||
const response = await server.handle({
|
||||
jsonrpc: '2.0',
|
||||
id: 1,
|
||||
method: 'tools/call',
|
||||
params: { name: 'get_cctv_snapshot', arguments: { camera_id: 'austin-1' } },
|
||||
});
|
||||
assert.deepEqual(response.result.content[2], {
|
||||
type: 'image',
|
||||
data: 'iVBORw0KGgo=',
|
||||
mimeType: 'image/png',
|
||||
});
|
||||
});
|
||||
|
||||
test('unknown cameras, video-only feeds and oversized frames are reported', async () => {
|
||||
const catalog = (frame) =>
|
||||
composeCatalog({ tools: coreTools, services: { cctv: cctv(frame) } });
|
||||
await assert.rejects(
|
||||
catalog().call('get_cctv_snapshot', { camera_id: 'nope' }),
|
||||
/No camera has id nope; use find_cctv_cameras first/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog({ contentType: 'video/mp2t', bytes: PNG }).call(
|
||||
'get_cctv_snapshot',
|
||||
{ camera_id: 'austin-2' },
|
||||
),
|
||||
(error) =>
|
||||
error.code === 'unavailable' && /no still image/.test(error.message),
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog({
|
||||
contentType: 'image/jpeg',
|
||||
bytes: new Uint8Array(3 * 1024 * 1024 + 1),
|
||||
}).call('get_cctv_snapshot', { camera_id: 'austin-2' }),
|
||||
/too large/,
|
||||
);
|
||||
});
|
||||
|
||||
test('radio stations match area and every search term', async () => {
|
||||
const radio = {
|
||||
async getDirectory() {
|
||||
return {
|
||||
stations: [
|
||||
{
|
||||
id: 'a',
|
||||
name: 'KUTX',
|
||||
lat: 30.28,
|
||||
lon: -97.74,
|
||||
country: 'United States',
|
||||
state: 'Texas',
|
||||
tags: ['music', 'indie'],
|
||||
languages: ['english'],
|
||||
codec: 'MP3',
|
||||
bitrate: 128,
|
||||
streamUrl: 'https://example.org/kutx.mp3',
|
||||
homepage: 'https://kutx.org',
|
||||
},
|
||||
{
|
||||
id: 'b',
|
||||
name: 'KMFA Classical',
|
||||
lat: 30.3,
|
||||
lon: -97.7,
|
||||
country: 'United States',
|
||||
state: 'Texas',
|
||||
tags: ['classical'],
|
||||
languages: ['english'],
|
||||
codec: 'AAC',
|
||||
bitrate: null,
|
||||
streamUrl: 'https://example.org/kmfa.aac',
|
||||
},
|
||||
{
|
||||
id: 'c',
|
||||
name: 'Jazz Oslo',
|
||||
lat: 59.9,
|
||||
lon: 10.7,
|
||||
country: 'Norway',
|
||||
tags: ['jazz'],
|
||||
languages: ['norwegian'],
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { radio } });
|
||||
const nearby = await catalog.call('find_radio_stations', {
|
||||
area: { lat: 30.27, lon: -97.74, radius_km: 20 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
nearby.data.rows.map((row) => row.id),
|
||||
['a', 'b'],
|
||||
);
|
||||
assert.equal(nearby.data.rows[0].stream_url, 'https://example.org/kutx.mp3');
|
||||
const jazz = await catalog.call('find_radio_stations', {
|
||||
query: 'jazz norwegian',
|
||||
});
|
||||
assert.equal(
|
||||
jazz.summary,
|
||||
'1 radio station matching "jazz norwegian" (from a directory of 3 popular stations).',
|
||||
);
|
||||
assert.equal(jazz.data.directory_size, 3);
|
||||
assert.equal(jazz.data.stale, false);
|
||||
assert.equal(jazz.data.rows[0].distance_km, undefined);
|
||||
const classicalTexas = await catalog.call('find_radio_stations', {
|
||||
query: 'classical texas',
|
||||
area: { lat: 30.27, lon: -97.74, radius_km: 20 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
classicalTexas.data.rows.map((row) => row.id),
|
||||
['b'],
|
||||
);
|
||||
assert.equal(classicalTexas.data.rows[0].bitrate_kbps, null);
|
||||
await assert.rejects(
|
||||
catalog.call('find_radio_stations', {}),
|
||||
/area, a query or both/,
|
||||
);
|
||||
});
|
||||
|
||||
test('malformed image results are a programming error', async () => {
|
||||
const { defineTool } = await import('../catalog.js');
|
||||
const bad = defineTool({
|
||||
name: 'bad_image',
|
||||
title: 'Bad',
|
||||
description: 'Returns a malformed image.',
|
||||
inputSchema: { type: 'object' },
|
||||
run: async () => ({
|
||||
summary: 'x',
|
||||
data: {},
|
||||
images: [{ mimeType: 'image/png' }],
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
composeCatalog({ tools: [bad] }).call('bad_image'),
|
||||
/malformed images/,
|
||||
);
|
||||
});
|
||||
|
||||
test('a stale or degraded radio directory is reported', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
radio: {
|
||||
getDirectory: async () => ({
|
||||
stations: [],
|
||||
stale: true,
|
||||
degraded: true,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('find_radio_stations', { query: 'jazz' });
|
||||
assert.match(
|
||||
result.summary,
|
||||
/the directory may be stale; the directory is incomplete right now\)\.$/,
|
||||
);
|
||||
assert.equal(result.data.degraded, true);
|
||||
});
|
||||
|
||||
test('cameras from a trimmed catalog pack are reported as partial', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
cctv: {
|
||||
getCatalog: async () => ({
|
||||
trimmedPacks: [
|
||||
{ pack: 'tfl', available: 900, served: 250 },
|
||||
{ pack: 'other', available: 10, served: 5 },
|
||||
],
|
||||
sources: [
|
||||
{
|
||||
id: 'jam-1',
|
||||
name: 'Strand',
|
||||
lat: 51.51,
|
||||
lon: -0.12,
|
||||
pack: 'tfl',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('find_cctv_cameras', {
|
||||
area: { lat: 51.51, lon: -0.12, radius_km: 5 },
|
||||
});
|
||||
assert.equal(result.data.complete, false);
|
||||
assert.deepEqual(result.data.catalog_trimmed, [
|
||||
{ pack: 'tfl', available: 900, served: 250 },
|
||||
]);
|
||||
assert.match(
|
||||
result.summary,
|
||||
/\(the catalog serves only some cameras here: 250 of 900 from tfl\)\.$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('an area the catalog trimmed away entirely is not reported complete', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
cctv: {
|
||||
getCatalog: async () => ({
|
||||
trimmedPacks: [
|
||||
{
|
||||
pack: 'tfl',
|
||||
available: 900,
|
||||
served: 250,
|
||||
region: { west: -0.6, south: 51.3, east: 0.3, north: 51.7 },
|
||||
},
|
||||
],
|
||||
// Every served camera is in central London, none in the suburb.
|
||||
sources: [
|
||||
{
|
||||
id: 'jam-1',
|
||||
name: 'Strand',
|
||||
lat: 51.51,
|
||||
lon: -0.12,
|
||||
pack: 'tfl',
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
const suburb = await catalog.call('find_cctv_cameras', {
|
||||
area: { lat: 51.4, lon: -0.5, radius_km: 3 },
|
||||
});
|
||||
assert.equal(suburb.data.rows.length, 0);
|
||||
assert.equal(suburb.data.complete, false);
|
||||
assert.match(suburb.summary, /250 of 900 from tfl/);
|
||||
const elsewhere = await catalog.call('find_cctv_cameras', {
|
||||
area: { lat: 40.7, lon: -74, radius_km: 3 },
|
||||
});
|
||||
assert.equal(elsewhere.data.complete, true);
|
||||
assert.equal(
|
||||
elsewhere.summary,
|
||||
'The camera catalog has no cameras in 3 km around 40.700, -74.000.',
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,490 @@
|
||||
/** Bike-share and public transit queries. */
|
||||
|
||||
import { suggestView } from '../views.js';
|
||||
import {
|
||||
parseStationInformation,
|
||||
parseStationStatus,
|
||||
} from '../../sources/gbfsStations.js';
|
||||
import {
|
||||
FEED_STALE_AFTER_MS,
|
||||
isStaleVehicleFix,
|
||||
} from '../../layers/transit/policy.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
areaRadiusKm,
|
||||
distanceKm,
|
||||
resolveArea,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, isoTime } from '../results.js';
|
||||
|
||||
const MAX_SYSTEMS = 3;
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
/**
|
||||
* Systems or feeds whose coverage reaches the area, nearest first: the
|
||||
* nearest MAX_SYSTEMS are read and the rest are returned as `skipped`.
|
||||
*/
|
||||
function covering(area, entries, centerOf, radiusOf) {
|
||||
const center = areaCenter(area);
|
||||
const reach = areaRadiusKm(area);
|
||||
const reaching = entries
|
||||
.map((entry) => ({ entry, distance: distanceKm(center, centerOf(entry)) }))
|
||||
.filter(({ entry, distance }) => distance <= reach + radiusOf(entry))
|
||||
.sort((a, b) => a.distance - b.distance)
|
||||
.map(({ entry }) => entry);
|
||||
return {
|
||||
selected: reaching.slice(0, MAX_SYSTEMS),
|
||||
skipped: reaching.slice(MAX_SYSTEMS),
|
||||
};
|
||||
}
|
||||
|
||||
/** A note naming systems left out of an answer, or an empty list. */
|
||||
const skippedNote = (skipped, nameOf) =>
|
||||
skipped.length
|
||||
? [
|
||||
`${skipped.length} more not searched: ${skipped.map(nameOf).join(', ')}; use a smaller area`,
|
||||
]
|
||||
: [];
|
||||
|
||||
export const getBikeShare = defineTool({
|
||||
name: 'get_bike_share',
|
||||
title: 'Bike-share stations',
|
||||
description:
|
||||
'Live bike-share stations in an area from public GBFS feeds, nearest the ' +
|
||||
'center first, with bikes and docks available.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA, limit: LIMIT_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['bikeshare'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const { bikeshare } = services;
|
||||
const { selected: systems, skipped } = covering(
|
||||
area,
|
||||
bikeshare.systems,
|
||||
(system) => ({ lat: system.centerLat, lon: system.centerLon }),
|
||||
(system) => system.loadRadiusKm,
|
||||
);
|
||||
if (!systems.length)
|
||||
return {
|
||||
summary: `No supported bike-share system covers ${area.label}.`,
|
||||
data: { ...capRows([], args.limit), systems: [] },
|
||||
};
|
||||
const center = areaCenter(area);
|
||||
const results = await Promise.allSettled(
|
||||
systems.map(async (system) => {
|
||||
const [information, status] = await Promise.all([
|
||||
bikeshare.getStations(system.stationInformationUrl, { signal }),
|
||||
bikeshare.getStations(system.stationStatusUrl, { signal }),
|
||||
]);
|
||||
const live = parseStationStatus(status);
|
||||
return [...parseStationInformation(information).values()].map(
|
||||
(station) => ({ system, station, live: live.get(station.stationId) }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (results.every((result) => result.status === 'rejected'))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Bike-share feeds are unavailable right now',
|
||||
);
|
||||
const rows = results
|
||||
.flatMap((result) => (result.status === 'fulfilled' ? result.value : []))
|
||||
.filter(({ station }) => areaContains(area, station))
|
||||
.map(({ system, station, live }) => ({
|
||||
system: system.city,
|
||||
provider: system.provider ?? null,
|
||||
station_id: station.stationId,
|
||||
name: station.name || null,
|
||||
lat: round(station.lat, 6),
|
||||
lon: round(station.lon, 6),
|
||||
bikes_available: live?.bikesAvailable ?? null,
|
||||
docks_available: live?.docksAvailable ?? null,
|
||||
capacity: station.capacity,
|
||||
renting: (live?.isRenting ?? station.isRenting) === true,
|
||||
returning: (live?.isReturning ?? station.isReturning) === true,
|
||||
last_reported: Number.isFinite(live?.lastReported)
|
||||
? isoTime(live.lastReported * 1000)
|
||||
: null,
|
||||
distance_km: round(distanceKm(center, station), 2),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const bikes = rows.reduce(
|
||||
(sum, row) => sum + (row.bikes_available ?? 0),
|
||||
0,
|
||||
);
|
||||
const failed = results.filter(
|
||||
(result) => result.status === 'rejected',
|
||||
).length;
|
||||
const notes = [
|
||||
...(failed ? [`${countNoun(failed, 'system')} unavailable`] : []),
|
||||
...skippedNote(skipped, (system) => system.city),
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'bike-share station')} in ${area.label} with ` +
|
||||
`${countNoun(bikes, 'bike')} available` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['bikeshare'] }),
|
||||
...capRows(rows, args.limit),
|
||||
systems: systems.map((system) => system.city),
|
||||
systems_not_searched: skipped.map((system) => system.city),
|
||||
bikes_available: bikes,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const getTransitVehicles = defineTool({
|
||||
name: 'get_transit_vehicles',
|
||||
title: 'Transit vehicles',
|
||||
description:
|
||||
'Live positions of buses, trains and other transit vehicles in an area ' +
|
||||
'from public GTFS-Realtime feeds, optionally for one route.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
area: AREA_SCHEMA,
|
||||
route: {
|
||||
type: 'string',
|
||||
minLength: 1,
|
||||
maxLength: 64,
|
||||
description:
|
||||
'Route id or vehicle label to match, such as "1" or "Red".',
|
||||
},
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['transit'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const { selected: feeds, skipped } = covering(
|
||||
area,
|
||||
await services.transit.getFeeds({ signal }),
|
||||
(feed) => feed.center,
|
||||
(feed) => feed.loadRadiusKm ?? 0,
|
||||
);
|
||||
if (!feeds.length)
|
||||
return {
|
||||
summary: `No supported transit feed covers ${area.label}.`,
|
||||
data: { ...capRows([], args.limit), feeds: [] },
|
||||
};
|
||||
const center = areaCenter(area);
|
||||
const now = services.clock?.now() ?? Date.now();
|
||||
const results = await Promise.allSettled(
|
||||
feeds.map(async (feed) => {
|
||||
const response = await services.transit.requestSnapshot(feed.id, {
|
||||
signal,
|
||||
});
|
||||
if (!response.ok) throw new Error(`Transit HTTP ${response.status}`);
|
||||
const snapshot = await response.json();
|
||||
// The app's freshness rules: the operator's fetch time ages the
|
||||
// snapshot, and fixes older than the vehicle limit are not shown.
|
||||
const fetchedAt = Number.isFinite(snapshot.fetchedAt)
|
||||
? Math.min(snapshot.fetchedAt, now)
|
||||
: now;
|
||||
const contacted = Number.parseInt(
|
||||
response.headers?.get?.('x-transit-contact') || '',
|
||||
10,
|
||||
);
|
||||
const answeredAt = Math.max(
|
||||
fetchedAt,
|
||||
Number.isFinite(contacted) ? Math.min(contacted, now) : fetchedAt,
|
||||
);
|
||||
const stale =
|
||||
response.headers?.get?.('x-gev-cache') === 'STALE-ERROR' ||
|
||||
now - answeredAt > FEED_STALE_AFTER_MS;
|
||||
const vehicles = snapshot.vehicles || [];
|
||||
const current = vehicles.filter(
|
||||
(vehicle) => !isStaleVehicleFix(vehicle, now, fetchedAt),
|
||||
);
|
||||
return {
|
||||
stale,
|
||||
expired: vehicles.length - current.length,
|
||||
vehicles: current.map((vehicle) => ({ feed, vehicle })),
|
||||
};
|
||||
}),
|
||||
);
|
||||
signal?.throwIfAborted();
|
||||
if (results.every((result) => result.status === 'rejected'))
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Transit feeds are unavailable right now',
|
||||
);
|
||||
const wanted = args.route?.trim().toLowerCase();
|
||||
const rows = results
|
||||
.flatMap((result) =>
|
||||
result.status === 'fulfilled' ? result.value.vehicles : [],
|
||||
)
|
||||
.filter(({ vehicle }) => areaContains(area, vehicle))
|
||||
.filter(
|
||||
({ vehicle }) =>
|
||||
!wanted ||
|
||||
[vehicle.routeId, vehicle.label].some(
|
||||
(value) => String(value || '').toLowerCase() === wanted,
|
||||
),
|
||||
)
|
||||
.map(({ feed, vehicle }) => ({
|
||||
feed: feed.name,
|
||||
id: vehicle.id,
|
||||
label: vehicle.label ?? null,
|
||||
route_id: vehicle.routeId ?? null,
|
||||
trip_id: vehicle.tripId ?? null,
|
||||
lat: vehicle.lat,
|
||||
lon: vehicle.lon,
|
||||
bearing_deg: round(vehicle.bearing, 0),
|
||||
speed_mps: round(vehicle.speedMps, 1),
|
||||
status: vehicle.status ?? null,
|
||||
occupancy: vehicle.occupancy ?? null,
|
||||
updated: Number.isFinite(vehicle.timestamp)
|
||||
? isoTime(vehicle.timestamp * 1000)
|
||||
: null,
|
||||
distance_km: round(distanceKm(center, vehicle), 2),
|
||||
}))
|
||||
.sort((a, b) => a.distance_km - b.distance_km);
|
||||
const failed = results.filter(
|
||||
(result) => result.status === 'rejected',
|
||||
).length;
|
||||
const answered = results
|
||||
.filter((result) => result.status === 'fulfilled')
|
||||
.map((result) => result.value);
|
||||
const staleFeeds = answered.filter((feed) => feed.stale).length;
|
||||
const expired = answered.reduce((total, feed) => total + feed.expired, 0);
|
||||
const notes = [
|
||||
...(failed ? [`${countNoun(failed, 'feed')} unavailable`] : []),
|
||||
...(staleFeeds
|
||||
? [
|
||||
`${countNoun(staleFeeds, 'feed')} stale; positions may be out of date`,
|
||||
]
|
||||
: []),
|
||||
...skippedNote(skipped, (feed) => feed.name),
|
||||
];
|
||||
const what = args.route ? ` on route ${args.route}` : '';
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'transit vehicle')}${what} in ${area.label}` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['transit'] }),
|
||||
...capRows(rows, args.limit),
|
||||
stale: staleFeeds > 0,
|
||||
expired_positions_dropped: expired,
|
||||
feeds_not_searched: skipped.map((feed) => feed.name),
|
||||
feeds: feeds.map((feed, index) => ({
|
||||
name: feed.name,
|
||||
status:
|
||||
results[index].status === 'rejected'
|
||||
? 'unavailable'
|
||||
: results[index].value.stale
|
||||
? 'stale'
|
||||
: 'current',
|
||||
operator: feed.operator ?? null,
|
||||
attribution: feed.attribution ?? null,
|
||||
license: feed.license ?? null,
|
||||
})),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
const FLOW_ZOOMS = [12, 11, 10, 9];
|
||||
const CONGESTED_LEVEL = 0.5;
|
||||
|
||||
/** Length in kilometers of a `[lon, lat]` line. */
|
||||
function lineKm(coords) {
|
||||
let total = 0;
|
||||
for (let index = 1; index < coords.length; index += 1)
|
||||
total += distanceKm(
|
||||
{ lat: coords[index - 1][1], lon: coords[index - 1][0] },
|
||||
{ lat: coords[index][1], lon: coords[index][0] },
|
||||
);
|
||||
return total;
|
||||
}
|
||||
|
||||
/**
|
||||
* The parts of a `[lon, lat]` line inside an area. Box areas keep the whole
|
||||
* line (tiles are already clipped to the box); radius areas cut each segment
|
||||
* where it crosses the circle, by bisection.
|
||||
*/
|
||||
function insideParts(coords, area) {
|
||||
if (!area.center) return [coords];
|
||||
const inside = ([lon, lat]) => areaContains(area, { lat, lon });
|
||||
// Where the segment from-to crosses the circle, on its inside, found by
|
||||
// bisection. One end must be inside and the other outside.
|
||||
const edge = (from, to) => {
|
||||
let a = from;
|
||||
let b = to;
|
||||
for (let step = 0; step < 20; step += 1) {
|
||||
const middle = [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
|
||||
if (inside(middle) === inside(from)) a = middle;
|
||||
else b = middle;
|
||||
}
|
||||
return inside(from) ? a : b;
|
||||
};
|
||||
const parts = [];
|
||||
let current = inside(coords[0]) ? [coords[0]] : null;
|
||||
for (let index = 1; index < coords.length; index += 1) {
|
||||
const from = coords[index - 1];
|
||||
const to = coords[index];
|
||||
const fromIn = inside(from);
|
||||
const toIn = inside(to);
|
||||
if (fromIn && toIn) current.push(to);
|
||||
else if (fromIn) {
|
||||
current.push(edge(from, to));
|
||||
parts.push(current);
|
||||
current = null;
|
||||
} else if (toIn) current = [edge(from, to), to];
|
||||
else {
|
||||
// Both ends outside: the segment may still cut across the circle.
|
||||
const middle = Array.from({ length: 15 }, (_, step) => {
|
||||
const t = (step + 1) / 16;
|
||||
return [
|
||||
from[0] + (to[0] - from[0]) * t,
|
||||
from[1] + (to[1] - from[1]) * t,
|
||||
];
|
||||
}).find(inside);
|
||||
if (middle) parts.push([edge(middle, from), edge(middle, to)]);
|
||||
}
|
||||
}
|
||||
if (current) parts.push(current);
|
||||
return parts.filter((part) => part.length > 1);
|
||||
}
|
||||
|
||||
async function readFlow(traffic, area, signal) {
|
||||
const box = {
|
||||
west: area.west,
|
||||
south: area.south,
|
||||
east: area.east,
|
||||
north: area.north,
|
||||
};
|
||||
for (const zoom of FLOW_ZOOMS) {
|
||||
try {
|
||||
if (traffic.fetchFlowDetail)
|
||||
return await traffic.fetchFlowDetail(box, { zoom, signal });
|
||||
return {
|
||||
segments: await traffic.fetchFlowForBounds(box, { zoom, signal }),
|
||||
partial: false,
|
||||
};
|
||||
} catch (error) {
|
||||
if (error?.code !== 'TILE_VIEW_TOO_WIDE') throw error;
|
||||
}
|
||||
}
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'The area is too large for traffic detail; use a city-sized area',
|
||||
);
|
||||
}
|
||||
|
||||
export const getTrafficFlow = defineTool({
|
||||
name: 'get_traffic_flow',
|
||||
title: 'Traffic flow',
|
||||
description:
|
||||
'Live road traffic in a city-sized area from TomTom: average speed as a ' +
|
||||
'share of free-flow speed, congested and closed road length, and a ' +
|
||||
'breakdown by road category.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: { area: AREA_SCHEMA },
|
||||
required: ['area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['traffic'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
if (area.west > area.east)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'The area must not cross the antimeridian',
|
||||
);
|
||||
const status = await services.traffic.getStatus({ signal });
|
||||
if (!status.hasKey)
|
||||
throw new ToolError(
|
||||
'unavailable',
|
||||
'Live traffic needs a TomTom key configured for this server',
|
||||
);
|
||||
const flow = await readFlow(services.traffic, area, signal);
|
||||
// Tiles cover the area's box; a radius area keeps only the parts of each
|
||||
// road inside its circle.
|
||||
const segments = flow.segments
|
||||
.filter(
|
||||
(segment) => Array.isArray(segment.coords) && segment.coords.length > 1,
|
||||
)
|
||||
.flatMap((segment) =>
|
||||
insideParts(segment.coords, area).map((coords) => ({
|
||||
...segment,
|
||||
coords,
|
||||
})),
|
||||
);
|
||||
const categories = new Map();
|
||||
let measuredKm = 0;
|
||||
let weighted = 0;
|
||||
let congestedKm = 0;
|
||||
let closedKm = 0;
|
||||
for (const segment of segments) {
|
||||
const km = lineKm(segment.coords);
|
||||
const key = segment.roadCategory || 'other';
|
||||
const entry = categories.get(key) || {
|
||||
km: 0,
|
||||
weighted: 0,
|
||||
congestedKm: 0,
|
||||
};
|
||||
entry.km += km;
|
||||
if (segment.closure) closedKm += km;
|
||||
if (Number.isFinite(segment.trafficLevel)) {
|
||||
measuredKm += km;
|
||||
weighted += segment.trafficLevel * km;
|
||||
entry.weighted += segment.trafficLevel * km;
|
||||
if (segment.trafficLevel < CONGESTED_LEVEL) {
|
||||
congestedKm += km;
|
||||
entry.congestedKm += km;
|
||||
}
|
||||
}
|
||||
categories.set(key, entry);
|
||||
}
|
||||
const percent = (part, whole) =>
|
||||
whole > 0 ? Math.round((part / whole) * 100) : null;
|
||||
const speedPct = percent(weighted, measuredKm);
|
||||
const byCategory = [...categories.entries()]
|
||||
.map(([category, entry]) => ({
|
||||
category,
|
||||
road_km: round(entry.km, 1),
|
||||
speed_pct_of_free_flow: percent(entry.weighted, entry.km),
|
||||
congested_km: round(entry.congestedKm, 1),
|
||||
}))
|
||||
.sort((a, b) => b.road_km - a.road_km);
|
||||
return {
|
||||
summary:
|
||||
(segments.length
|
||||
? `Traffic in ${area.label}: ${speedPct ?? 'unknown'}% of free-flow speed on average; ` +
|
||||
`${round(congestedKm, 1)} km congested of ${round(measuredKm, 1)} km measured` +
|
||||
(closedKm > 0 ? `; ${round(closedKm, 1)} km closed.` : '.')
|
||||
: `No live traffic is reported in ${area.label}.`) +
|
||||
(flow.partial
|
||||
? ' Some map tiles did not load, so these figures are partial.'
|
||||
: ''),
|
||||
data: {
|
||||
view: suggestView(services, { area, layers: ['traffic'] }),
|
||||
area: area.label,
|
||||
partial: flow.partial,
|
||||
speed_pct_of_free_flow: speedPct,
|
||||
measured_km: round(measuredKm, 1),
|
||||
congested_km: round(congestedKm, 1),
|
||||
closed_km: round(closedKm, 1),
|
||||
congested_below_pct: CONGESTED_LEVEL * 100,
|
||||
by_category: byCategory,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,498 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { GBFS_CITY_REGISTRY } from '../../layers/bikeshare/registry.js';
|
||||
|
||||
const system = (id, city, centerLat, centerLon) => ({
|
||||
id,
|
||||
city,
|
||||
centerLat,
|
||||
centerLon,
|
||||
loadRadiusKm: 30,
|
||||
provider: 'Test Bikes',
|
||||
stationInformationUrl: `https://gbfs.example/${id}/station_information.json`,
|
||||
stationStatusUrl: `https://gbfs.example/${id}/station_status.json`,
|
||||
});
|
||||
const documents = {
|
||||
'https://gbfs.example/austin/station_information.json': {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 's1',
|
||||
name: 'Congress & 2nd',
|
||||
lat: 30.2645,
|
||||
lon: -97.7445,
|
||||
capacity: 12,
|
||||
},
|
||||
{
|
||||
station_id: 's2',
|
||||
name: 'Far Station',
|
||||
lat: 30.4,
|
||||
lon: -97.9,
|
||||
capacity: 8,
|
||||
},
|
||||
{
|
||||
station_id: 's3',
|
||||
name: 'Lamar & 5th',
|
||||
lat: 30.268,
|
||||
lon: -97.753,
|
||||
capacity: '10',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'https://gbfs.example/austin/station_status.json': {
|
||||
data: {
|
||||
stations: [
|
||||
{
|
||||
station_id: 's1',
|
||||
num_bikes_available: 4,
|
||||
num_docks_available: 8,
|
||||
is_renting: 1,
|
||||
last_reported: 1767225600,
|
||||
},
|
||||
{
|
||||
station_id: 's3',
|
||||
num_bikes_available: 0,
|
||||
num_docks_available: 10,
|
||||
is_renting: 0,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
const bikeshare = (systems, failing = new Set()) => ({
|
||||
systems,
|
||||
async getStations(url) {
|
||||
if ([...failing].some((id) => url.includes(`/${id}/`)))
|
||||
throw new Error('GBFS HTTP 502');
|
||||
return documents[url];
|
||||
},
|
||||
});
|
||||
const downtown = { lat: 30.2672, lon: -97.7431, radius_km: 2 };
|
||||
|
||||
test('bike share joins station information with live status inside the area', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: bikeshare([
|
||||
system('austin', 'Austin, TX', 30.27, -97.74),
|
||||
system('oslo', 'Oslo', 59.9, 10.7),
|
||||
]),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_bike_share', { area: downtown });
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 bike-share stations in 2 km around 30.267, -97.743 with 4 bikes available.',
|
||||
);
|
||||
assert.deepEqual(result.data.systems, ['Austin, TX']);
|
||||
assert.deepEqual(result.data.rows[0], {
|
||||
system: 'Austin, TX',
|
||||
provider: 'Test Bikes',
|
||||
station_id: 's1',
|
||||
name: 'Congress & 2nd',
|
||||
lat: 30.2645,
|
||||
lon: -97.7445,
|
||||
bikes_available: 4,
|
||||
docks_available: 8,
|
||||
capacity: 12,
|
||||
renting: true,
|
||||
returning: true,
|
||||
last_reported: '2026-01-01T00:00:00.000Z',
|
||||
distance_km: 0.33,
|
||||
});
|
||||
assert.equal(result.data.rows[1].renting, false);
|
||||
assert.equal(result.data.rows[1].capacity, 10);
|
||||
});
|
||||
|
||||
test('bike share reports uncovered areas and failed systems honestly', async () => {
|
||||
const none = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { bikeshare: bikeshare([system('oslo', 'Oslo', 59.9, 10.7)]) },
|
||||
});
|
||||
const empty = await none.call('get_bike_share', { area: downtown });
|
||||
assert.equal(
|
||||
empty.summary,
|
||||
'No supported bike-share system covers 2 km around 30.267, -97.743.',
|
||||
);
|
||||
assert.equal(empty.data.total, 0);
|
||||
const both = [
|
||||
system('austin', 'Austin, TX', 30.27, -97.74),
|
||||
system('round-rock', 'Round Rock', 30.5, -97.68),
|
||||
];
|
||||
const partial = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { bikeshare: bikeshare(both, new Set(['round-rock'])) },
|
||||
});
|
||||
assert.match(
|
||||
(
|
||||
await partial.call('get_bike_share', {
|
||||
area: { lat: 30.4, lon: -97.7, radius_km: 20 },
|
||||
})
|
||||
).summary,
|
||||
/\(1 system unavailable\)\.$/,
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { bikeshare: bikeshare(both, new Set(['austin', 'round-rock'])) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_bike_share', { area: downtown }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
const feed = {
|
||||
id: 'capmetro',
|
||||
name: 'CapMetro',
|
||||
operator: 'Capital Metro',
|
||||
center: { lat: 30.27, lon: -97.74 },
|
||||
loadRadiusKm: 50,
|
||||
attribution: 'Capital Metro',
|
||||
license: 'Open data',
|
||||
};
|
||||
const vehicles = [
|
||||
{
|
||||
id: 'v1',
|
||||
lat: 30.2675,
|
||||
lon: -97.7425,
|
||||
bearing: 91.6,
|
||||
speedMps: 8.23,
|
||||
timestamp: 1767225600,
|
||||
routeId: '1',
|
||||
tripId: 't1',
|
||||
label: '1 North',
|
||||
status: 'IN_TRANSIT_TO',
|
||||
occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
},
|
||||
{
|
||||
id: 'v2',
|
||||
lat: 30.268,
|
||||
lon: -97.745,
|
||||
bearing: null,
|
||||
speedMps: null,
|
||||
timestamp: null,
|
||||
routeId: '801',
|
||||
label: 'Rapid',
|
||||
},
|
||||
{ id: 'v3', lat: 30.5, lon: -97.6, routeId: '1' },
|
||||
];
|
||||
// One minute after v1's fix.
|
||||
const clock = { now: () => Date.UTC(2026, 0, 1, 0, 1) };
|
||||
const transit = (
|
||||
snapshot = { ok: true, status: 200, json: async () => ({ vehicles }) },
|
||||
) => ({
|
||||
requested: [],
|
||||
async getFeeds() {
|
||||
return [
|
||||
feed,
|
||||
{ ...feed, id: 'far', name: 'Far Away', center: { lat: 0, lon: 0 } },
|
||||
];
|
||||
},
|
||||
async requestSnapshot(id) {
|
||||
this.requested.push(id);
|
||||
return snapshot;
|
||||
},
|
||||
});
|
||||
|
||||
test('transit vehicles come from nearby feeds and can be limited to a route', async () => {
|
||||
const source = transit();
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { transit: source, clock },
|
||||
});
|
||||
const all = await catalog.call('get_transit_vehicles', { area: downtown });
|
||||
assert.equal(
|
||||
all.summary,
|
||||
'2 transit vehicles in 2 km around 30.267, -97.743.',
|
||||
);
|
||||
assert.deepEqual(source.requested, ['capmetro']);
|
||||
assert.deepEqual(all.data.rows[0], {
|
||||
feed: 'CapMetro',
|
||||
id: 'v1',
|
||||
label: '1 North',
|
||||
route_id: '1',
|
||||
trip_id: 't1',
|
||||
lat: 30.2675,
|
||||
lon: -97.7425,
|
||||
bearing_deg: 92,
|
||||
speed_mps: 8.2,
|
||||
status: 'IN_TRANSIT_TO',
|
||||
occupancy: 'FEW_SEATS_AVAILABLE',
|
||||
updated: '2026-01-01T00:00:00.000Z',
|
||||
distance_km: 0.07,
|
||||
});
|
||||
assert.deepEqual(all.data.feeds, [
|
||||
{
|
||||
name: 'CapMetro',
|
||||
status: 'current',
|
||||
operator: 'Capital Metro',
|
||||
attribution: 'Capital Metro',
|
||||
license: 'Open data',
|
||||
},
|
||||
]);
|
||||
const rapid = await catalog.call('get_transit_vehicles', {
|
||||
area: downtown,
|
||||
route: 'rapid',
|
||||
});
|
||||
assert.equal(
|
||||
rapid.summary,
|
||||
'1 transit vehicle on route rapid in 2 km around 30.267, -97.743.',
|
||||
);
|
||||
assert.equal(rapid.data.rows[0].id, 'v2');
|
||||
});
|
||||
|
||||
test('transit reports uncovered areas and unavailable feeds', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { transit: transit() },
|
||||
});
|
||||
const oslo = await catalog.call('get_transit_vehicles', {
|
||||
area: { lat: 59.9, lon: 10.7, radius_km: 5 },
|
||||
});
|
||||
assert.equal(
|
||||
oslo.summary,
|
||||
'No supported transit feed covers 5 km around 59.900, 10.700.',
|
||||
);
|
||||
const down = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { transit: transit({ ok: false, status: 503 }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
down.call('get_transit_vehicles', { area: downtown }),
|
||||
(error) => error.code === 'unavailable',
|
||||
);
|
||||
});
|
||||
|
||||
test('traffic flow aggregates speed, congestion and closures by road length', async () => {
|
||||
// Two 0.01° segments along the equator (about 1.11 km each) and one closure.
|
||||
const segments = [
|
||||
{
|
||||
coords: [
|
||||
[0, 0],
|
||||
[0.01, 0],
|
||||
],
|
||||
trafficLevel: 1,
|
||||
roadCategory: 'motorway',
|
||||
closure: false,
|
||||
},
|
||||
{
|
||||
coords: [
|
||||
[0, 0.001],
|
||||
[0.01, 0.001],
|
||||
],
|
||||
trafficLevel: 0.25,
|
||||
roadCategory: 'street',
|
||||
closure: false,
|
||||
},
|
||||
{
|
||||
coords: [
|
||||
[0, 0.002],
|
||||
[0.005, 0.002],
|
||||
],
|
||||
trafficLevel: 0,
|
||||
roadCategory: 'street',
|
||||
closure: true,
|
||||
},
|
||||
{ coords: [[0, 0]], trafficLevel: 1 },
|
||||
];
|
||||
const zooms = [];
|
||||
const traffic = {
|
||||
getStatus: async () => ({ hasKey: true }),
|
||||
async fetchFlowForBounds(box, { zoom }) {
|
||||
zooms.push(zoom);
|
||||
if (zoom > 10)
|
||||
throw Object.assign(new Error('Zoom in'), {
|
||||
code: 'TILE_VIEW_TOO_WIDE',
|
||||
});
|
||||
return segments;
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({ tools: coreTools, services: { traffic } });
|
||||
const result = await catalog.call('get_traffic_flow', {
|
||||
area: { bbox: [-0.1, -0.1, 0.1, 0.1] },
|
||||
});
|
||||
assert.deepEqual(zooms, [12, 11, 10]);
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Traffic in the requested box: 50% of free-flow speed on average; 1.7 km congested of 2.8 km measured; 0.6 km closed.',
|
||||
);
|
||||
assert.equal(result.data.speed_pct_of_free_flow, 50);
|
||||
assert.deepEqual(result.data.by_category, [
|
||||
{
|
||||
category: 'street',
|
||||
road_km: 1.7,
|
||||
speed_pct_of_free_flow: 17,
|
||||
congested_km: 1.7,
|
||||
},
|
||||
{
|
||||
category: 'motorway',
|
||||
road_km: 1.1,
|
||||
speed_pct_of_free_flow: 100,
|
||||
congested_km: 0,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test('traffic reports a missing key, empty areas and areas that are too wide', async () => {
|
||||
const keyless = {
|
||||
getStatus: async () => ({ hasKey: false }),
|
||||
fetchFlowForBounds: async () => [],
|
||||
};
|
||||
await assert.rejects(
|
||||
composeCatalog({ tools: coreTools, services: { traffic: keyless } }).call(
|
||||
'get_traffic_flow',
|
||||
{ area: downtown },
|
||||
),
|
||||
(error) => error.code === 'unavailable' && /TomTom key/.test(error.message),
|
||||
);
|
||||
const quiet = {
|
||||
getStatus: async () => ({ hasKey: true }),
|
||||
fetchFlowForBounds: async () => [],
|
||||
};
|
||||
assert.equal(
|
||||
(
|
||||
await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { traffic: quiet },
|
||||
}).call('get_traffic_flow', { area: downtown })
|
||||
).summary,
|
||||
'No live traffic is reported in 2 km around 30.267, -97.743.',
|
||||
);
|
||||
const wide = {
|
||||
getStatus: async () => ({ hasKey: true }),
|
||||
fetchFlowForBounds: async () => {
|
||||
throw Object.assign(new Error('Zoom in'), { code: 'TILE_VIEW_TOO_WIDE' });
|
||||
},
|
||||
};
|
||||
await assert.rejects(
|
||||
composeCatalog({ tools: coreTools, services: { traffic: wide } }).call(
|
||||
'get_traffic_flow',
|
||||
{ area: { bbox: [-100, 30, -95, 35] } },
|
||||
),
|
||||
/too large for traffic detail/,
|
||||
);
|
||||
});
|
||||
|
||||
test('transit marks stale feeds and drops expired positions', async () => {
|
||||
const headers = new Headers({ 'x-gev-cache': 'STALE-ERROR' });
|
||||
const snapshot = (extra) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers,
|
||||
json: async () => ({
|
||||
vehicles: [
|
||||
vehicles[0],
|
||||
{ ...vehicles[0], id: 'old', timestamp: 1767225600 - 3600 },
|
||||
],
|
||||
...extra,
|
||||
}),
|
||||
});
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { transit: transit(snapshot()), clock },
|
||||
}).call('get_transit_vehicles', { area: downtown });
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.id),
|
||||
['v1'],
|
||||
);
|
||||
assert.equal(result.data.expired_positions_dropped, 1);
|
||||
assert.equal(result.data.stale, true);
|
||||
assert.equal(result.data.feeds[0].status, 'stale');
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'1 transit vehicle in 2 km around 30.267, -97.743 (1 feed stale; positions may be out of date).',
|
||||
);
|
||||
});
|
||||
|
||||
test('bike share names the systems it did not search', async () => {
|
||||
const empty = { data: { stations: [] } };
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: {
|
||||
systems: GBFS_CITY_REGISTRY,
|
||||
getStations: async () => empty,
|
||||
},
|
||||
},
|
||||
});
|
||||
// New York, Philadelphia, Washington and Boston.
|
||||
const result = await catalog.call('get_bike_share', {
|
||||
area: { bbox: [-77.5, 38.5, -70.5, 42.8] },
|
||||
});
|
||||
assert.equal(result.data.systems.length, 3);
|
||||
assert.ok(result.data.systems_not_searched.length >= 1);
|
||||
assert.match(
|
||||
result.summary,
|
||||
/more not searched: .*; use a smaller area\)\.$/,
|
||||
);
|
||||
});
|
||||
|
||||
test('traffic counts only roads inside a radius area and reports missing tiles', async () => {
|
||||
const flow = (segments, partial = false) => ({
|
||||
getStatus: async () => ({ hasKey: true }),
|
||||
fetchFlowDetail: async () => ({ segments, partial }),
|
||||
});
|
||||
const call = (traffic, area) =>
|
||||
composeCatalog({ tools: coreTools, services: { traffic } }).call(
|
||||
'get_traffic_flow',
|
||||
{ area },
|
||||
);
|
||||
const circle = { lat: 0, lon: 0, radius_km: 10 };
|
||||
// In the circle's bounding box, but about 12.6 km from its center.
|
||||
const corner = [
|
||||
{
|
||||
coords: [
|
||||
[0.08, 0.08],
|
||||
[0.085, 0.085],
|
||||
],
|
||||
closure: true,
|
||||
trafficLevel: 1,
|
||||
},
|
||||
];
|
||||
const outside = await call(flow(corner), circle);
|
||||
assert.equal(outside.data.closed_km, 0);
|
||||
assert.equal(outside.data.measured_km, 0);
|
||||
// A 20 km road through the center keeps the 20 km inside the circle.
|
||||
const across = [
|
||||
{
|
||||
coords: [
|
||||
[-0.2, 0],
|
||||
[0.2, 0],
|
||||
],
|
||||
trafficLevel: 1,
|
||||
},
|
||||
];
|
||||
const crossing = await call(flow(across), circle);
|
||||
assert.ok(Math.abs(crossing.data.measured_km - 20) < 0.2);
|
||||
// A road cutting the circle with both ends outside it.
|
||||
const chord = [
|
||||
{
|
||||
coords: [
|
||||
[-0.2, 0.05],
|
||||
[0.2, 0.05],
|
||||
],
|
||||
trafficLevel: 1,
|
||||
},
|
||||
];
|
||||
assert.ok((await call(flow(chord), circle)).data.measured_km > 10);
|
||||
const partial = await call(flow(across, true), circle);
|
||||
assert.equal(partial.data.partial, true);
|
||||
assert.match(partial.summary, /Some map tiles did not load/);
|
||||
});
|
||||
|
||||
test('a whole-world bike-share search names every system it skipped', async () => {
|
||||
const empty = { data: { stations: [] } };
|
||||
const result = await composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
bikeshare: {
|
||||
systems: GBFS_CITY_REGISTRY,
|
||||
getStations: async () => empty,
|
||||
},
|
||||
},
|
||||
}).call('get_bike_share', { area: { bbox: [-180, -90, 180, 90] } });
|
||||
const named = [...result.data.systems, ...result.data.systems_not_searched];
|
||||
assert.equal(named.length, GBFS_CITY_REGISTRY.length);
|
||||
});
|
||||
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* The God's Eye View panel's requests. A panel cannot reach the app's server
|
||||
* itself: hosts serve panels from their own sites and may refuse other
|
||||
* addresses, such as a server on the user's machine. The panel asks this
|
||||
* tool instead, and it requests the path from the app's server. Large
|
||||
* responses come back in parts.
|
||||
*
|
||||
* It is meant for the panel. `visibility: ['app']` asks hosts to keep it
|
||||
* from the model, and every call must also carry the key in the panel's page
|
||||
* (`services.app.panelKey`), so a client that lists the tool to the model
|
||||
* without loading the panel cannot use it. The key is not access control:
|
||||
* any MCP client can read the panel's page, key included. What protects
|
||||
* sensitive routes is that those the panel never needs are refused to every
|
||||
* caller.
|
||||
*/
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { PANEL_REQUEST_TOOL } from '../globePanel.js';
|
||||
|
||||
/** Bytes of response body per call, before base64. */
|
||||
export const PANEL_PART_BYTES = 512 * 1024;
|
||||
const HELD_MS = 2 * 60 * 1000;
|
||||
const HELD_LIMIT_BYTES = 256 * 1024 * 1024;
|
||||
/** The largest response the panel may load, read up to this and no more. */
|
||||
export const PANEL_RESPONSE_LIMIT_BYTES = 64 * 1024 * 1024;
|
||||
const REQUEST_TIMEOUT_MS = 60 * 1000;
|
||||
const METHODS = new Set(['GET', 'HEAD', 'POST']);
|
||||
// Routes the panel never loads, matched as the server routes them: without
|
||||
// regard to case, and with a mounted route also matching when a dot follows
|
||||
// it (`/mcp.json` reaches `/mcp`). Provider Settings, which only the app's own
|
||||
// page may use; credential and model endpoints; the MCP server itself; and
|
||||
// the development server's internal routes.
|
||||
const REFUSED_PATHS = [
|
||||
/^\/api\/(?:setup|realtime|openai)(?:[/.]|$)/i,
|
||||
/^\/mcp(?:[/.]|$)/i,
|
||||
/^\/(?:@|__)/,
|
||||
];
|
||||
const FORWARDED_REQUEST_HEADERS = ['accept', 'content-type'];
|
||||
const DROPPED_RESPONSE_HEADERS = new Set([
|
||||
'connection',
|
||||
'content-encoding',
|
||||
'content-length',
|
||||
'keep-alive',
|
||||
'set-cookie',
|
||||
'transfer-encoding',
|
||||
]);
|
||||
// Already compressed, so gzip would only cost time.
|
||||
const INCOMPRESSIBLE =
|
||||
/^(?:image\/(?!svg)|video\/|audio\/|font\/woff2)|zip|compressed/;
|
||||
const COMPRESS_MIN_BYTES = 1024;
|
||||
|
||||
/**
|
||||
* Requests in flight at once for one server's panels. Several panels (one
|
||||
* per conversation) share a server, so further requests wait their turn
|
||||
* rather than fail; past `PANEL_QUEUED_REQUESTS` waiting, they are refused.
|
||||
*/
|
||||
export const PANEL_CONCURRENT_REQUESTS = 6;
|
||||
export const PANEL_QUEUED_REQUESTS = 256;
|
||||
|
||||
/**
|
||||
* Each server's panel state: bodies too large for one call, kept for the
|
||||
* calls that read the rest, and the requests in flight. Kept per server
|
||||
* (its `services.app`), so one server's panel never reads another's.
|
||||
*/
|
||||
const panels = new WeakMap();
|
||||
function panelState(app) {
|
||||
if (!panels.has(app))
|
||||
panels.set(app, {
|
||||
held: new Map(),
|
||||
heldBytes: 0,
|
||||
inFlight: 0,
|
||||
waiting: [],
|
||||
});
|
||||
return panels.get(app);
|
||||
}
|
||||
|
||||
/** Take a place among the requests in flight, waiting for one if need be. */
|
||||
async function acquire(state, signal) {
|
||||
if (state.inFlight < PANEL_CONCURRENT_REQUESTS) {
|
||||
state.inFlight += 1;
|
||||
return;
|
||||
}
|
||||
if (state.waiting.length >= PANEL_QUEUED_REQUESTS)
|
||||
throw new ToolError(
|
||||
'retry_later',
|
||||
'The panel has too many requests waiting',
|
||||
{
|
||||
retryAfterSeconds: 1,
|
||||
},
|
||||
);
|
||||
await new Promise((resolve, reject) => {
|
||||
const waiter = { resolve };
|
||||
const abort = () => {
|
||||
const index = state.waiting.indexOf(waiter);
|
||||
if (index !== -1) state.waiting.splice(index, 1);
|
||||
reject(signal.reason);
|
||||
};
|
||||
waiter.resolve = () => {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
resolve();
|
||||
};
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
state.waiting.push(waiter);
|
||||
});
|
||||
}
|
||||
|
||||
/** Give up a place, handing it straight to the next waiting request. */
|
||||
function releaseSlot(state) {
|
||||
const next = state.waiting.shift();
|
||||
if (next) next.resolve();
|
||||
else state.inFlight -= 1;
|
||||
}
|
||||
|
||||
function base64(bytes) {
|
||||
let binary = '';
|
||||
for (let start = 0; start < bytes.length; start += 0x8000)
|
||||
binary += String.fromCharCode(...bytes.subarray(start, start + 0x8000));
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
function fromBase64(text) {
|
||||
return Uint8Array.from(atob(text), (char) => char.charCodeAt(0));
|
||||
}
|
||||
|
||||
/** Compare a caller's key with the panel's, in time independent of where they differ. */
|
||||
function isPanelKey(given, expected) {
|
||||
if (typeof expected !== 'string' || expected.length === 0) return false;
|
||||
if (typeof given !== 'string' || given.length !== expected.length)
|
||||
return false;
|
||||
let difference = 0;
|
||||
for (let index = 0; index < expected.length; index += 1)
|
||||
difference |= given.charCodeAt(index) ^ expected.charCodeAt(index);
|
||||
return difference === 0;
|
||||
}
|
||||
|
||||
async function gzip(bytes) {
|
||||
const stream = new Blob([bytes])
|
||||
.stream()
|
||||
.pipeThrough(new CompressionStream('gzip'));
|
||||
return new Uint8Array(await new Response(stream).arrayBuffer());
|
||||
}
|
||||
|
||||
function release(state, id) {
|
||||
state.heldBytes -= state.held.get(id).bytes.length;
|
||||
state.held.delete(id);
|
||||
}
|
||||
|
||||
/** Drop held responses nobody continued in time. */
|
||||
function forgetExpired(state, now = Date.now()) {
|
||||
for (const [id, entry] of state.held)
|
||||
if (entry.expires <= now) release(state, id);
|
||||
}
|
||||
|
||||
/** Hold a response for its later parts, first making room for it. */
|
||||
function hold(state, id, entry) {
|
||||
forgetExpired(state);
|
||||
// Oldest first; a response being read is renewed, so it is the newest.
|
||||
for (const oldest of state.held.keys()) {
|
||||
if (state.heldBytes + entry.bytes.length <= HELD_LIMIT_BYTES) break;
|
||||
release(state, oldest);
|
||||
}
|
||||
state.held.set(id, entry);
|
||||
state.heldBytes += entry.bytes.length;
|
||||
}
|
||||
|
||||
/** Read a response body, refusing one larger than the panel may load. */
|
||||
async function readLimited(answer) {
|
||||
const reader = answer.body?.getReader();
|
||||
if (!reader) return new Uint8Array(0);
|
||||
const chunks = [];
|
||||
let total = 0;
|
||||
for (;;) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
total += value.length;
|
||||
if (total > PANEL_RESPONSE_LIMIT_BYTES) {
|
||||
await reader.cancel();
|
||||
throw new ToolError('unavailable', 'The response is too large to load');
|
||||
}
|
||||
chunks.push(value);
|
||||
}
|
||||
const bytes = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function part(response, bytes, offset) {
|
||||
const end = Math.min(bytes.length, offset + PANEL_PART_BYTES);
|
||||
return {
|
||||
...response,
|
||||
offset,
|
||||
body: base64(bytes.subarray(offset, end)),
|
||||
...(end < bytes.length ? { nextOffset: end } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The path to request, as the URL parser reads it against the app's
|
||||
* address. Anything that leaves the app's server is refused, including
|
||||
* paths parsers read as another host: two slashes, or a slash and a
|
||||
* backslash. Refused routes are matched decoded too, as servers route.
|
||||
*/
|
||||
function checkedPath(path, baseUrl) {
|
||||
if (typeof path !== 'string' || !path.startsWith('/'))
|
||||
throw new ToolError('invalid_arguments', 'path must start with /');
|
||||
const base = new URL(baseUrl);
|
||||
let url;
|
||||
try {
|
||||
url = new URL(path, base);
|
||||
} catch {
|
||||
throw new ToolError('invalid_arguments', 'path is not a valid path');
|
||||
}
|
||||
if (url.origin !== base.origin)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
"path must stay on the app's server",
|
||||
);
|
||||
const { pathname } = url;
|
||||
let decoded;
|
||||
try {
|
||||
decoded = decodeURIComponent(pathname);
|
||||
} catch {
|
||||
throw new ToolError('invalid_arguments', 'path is not a valid path');
|
||||
}
|
||||
if (
|
||||
REFUSED_PATHS.some(
|
||||
(pattern) => pattern.test(pathname) || pattern.test(decoded),
|
||||
)
|
||||
)
|
||||
throw new ToolError('invalid_arguments', `${pathname} is not available`);
|
||||
return pathname + url.search;
|
||||
}
|
||||
|
||||
export const panelRequest = defineTool({
|
||||
name: PANEL_REQUEST_TOOL,
|
||||
title: "God's Eye View panel request",
|
||||
description:
|
||||
"Loads a file or data for the God's Eye View panel from the app's " +
|
||||
'server. Only the panel calls this; it does not answer questions.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
key: {
|
||||
type: 'string',
|
||||
description: "The panel's key, from its own page.",
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
description: "A path on the app's server, starting with /.",
|
||||
},
|
||||
method: { type: 'string', enum: [...METHODS] },
|
||||
headers: {
|
||||
type: 'object',
|
||||
description: 'Request headers; only Accept and Content-Type are sent.',
|
||||
},
|
||||
body: { type: 'string', description: 'The request body, base64.' },
|
||||
id: {
|
||||
type: 'string',
|
||||
description: 'Continue a response an earlier call returned in parts.',
|
||||
},
|
||||
offset: { type: 'integer', minimum: 0 },
|
||||
},
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['app'],
|
||||
ui: { visibility: ['app'] },
|
||||
async run(args, { services, signal }) {
|
||||
if (!isPanelKey(args.key, services.app.panelKey))
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
"Only the God's Eye View panel may make this request",
|
||||
);
|
||||
const state = panelState(services.app);
|
||||
forgetExpired(state);
|
||||
if (args.id !== undefined) {
|
||||
const entry = state.held.get(args.id);
|
||||
if (entry) {
|
||||
// Still being read: renew it, and keep it newest.
|
||||
state.held.delete(args.id);
|
||||
state.held.set(args.id, { ...entry, expires: Date.now() + HELD_MS });
|
||||
}
|
||||
if (!entry)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
'That response is no longer held; request the path again',
|
||||
);
|
||||
return {
|
||||
summary: `Part of ${entry.path}`,
|
||||
data: part(entry.response, entry.bytes, args.offset ?? 0),
|
||||
};
|
||||
}
|
||||
const path = checkedPath(args.path, services.app.baseUrl);
|
||||
const method = args.method ?? 'GET';
|
||||
if (!METHODS.has(method))
|
||||
throw new ToolError('invalid_arguments', `Unsupported method ${method}`);
|
||||
const headers = {};
|
||||
for (const [name, value] of Object.entries(args.headers ?? {})) {
|
||||
if (FORWARDED_REQUEST_HEADERS.includes(name.toLowerCase()))
|
||||
headers[name] = String(value);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
await acquire(state, signal);
|
||||
const deadline = AbortSignal.timeout(REQUEST_TIMEOUT_MS);
|
||||
let answer;
|
||||
let bytes;
|
||||
try {
|
||||
answer = await services.app.fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
...(args.body !== undefined && method === 'POST'
|
||||
? { body: fromBase64(args.body) }
|
||||
: {}),
|
||||
// A redirect could lead off the app's server; the panel gets it as is.
|
||||
redirect: 'manual',
|
||||
signal: signal ? AbortSignal.any([signal, deadline]) : deadline,
|
||||
});
|
||||
bytes = await readLimited(answer);
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
if (error instanceof ToolError) throw error;
|
||||
throw new ToolError('unavailable', "The app's server did not answer");
|
||||
} finally {
|
||||
releaseSlot(state);
|
||||
}
|
||||
const type = answer.headers.get('content-type') || '';
|
||||
let encoding = 'identity';
|
||||
if (bytes.length >= COMPRESS_MIN_BYTES && !INCOMPRESSIBLE.test(type)) {
|
||||
bytes = await gzip(bytes);
|
||||
encoding = 'gzip';
|
||||
}
|
||||
const responseHeaders = {};
|
||||
answer.headers.forEach((value, name) => {
|
||||
if (!DROPPED_RESPONSE_HEADERS.has(name)) responseHeaders[name] = value;
|
||||
});
|
||||
const response = {
|
||||
status: answer.status,
|
||||
statusText: answer.statusText,
|
||||
headers: responseHeaders,
|
||||
encoding,
|
||||
totalBytes: bytes.length,
|
||||
};
|
||||
if (bytes.length > PANEL_PART_BYTES) {
|
||||
response.id = crypto.randomUUID();
|
||||
hold(state, response.id, {
|
||||
path,
|
||||
response,
|
||||
bytes,
|
||||
expires: Date.now() + HELD_MS,
|
||||
});
|
||||
}
|
||||
return {
|
||||
summary: `${answer.status} ${method} ${path}`,
|
||||
data: part(response, bytes, 0),
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,347 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import {
|
||||
PANEL_CONCURRENT_REQUESTS,
|
||||
PANEL_PART_BYTES,
|
||||
PANEL_RESPONSE_LIMIT_BYTES,
|
||||
} from './panelRequest.js';
|
||||
|
||||
const fromBase64 = (text) => Buffer.from(text, 'base64');
|
||||
const KEY = 'panel-key-for-tests';
|
||||
const gunzip = async (bytes) =>
|
||||
new Uint8Array(
|
||||
await new Response(
|
||||
new Blob([bytes]).stream().pipeThrough(new DecompressionStream('gzip')),
|
||||
).arrayBuffer(),
|
||||
);
|
||||
|
||||
function catalogWith(handler) {
|
||||
const requests = [];
|
||||
const fetchImpl = async (path, init) => {
|
||||
requests.push({ path, ...init });
|
||||
return handler(path, init);
|
||||
};
|
||||
return {
|
||||
requests,
|
||||
catalog: composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
app: {
|
||||
baseUrl: 'http://localhost:4173/',
|
||||
fetch: fetchImpl,
|
||||
panelKey: KEY,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
test('panel_request is for the panel only', () => {
|
||||
const { catalog } = catalogWith(() => new Response('x'));
|
||||
assert.deepEqual(catalog.get('panel_request').ui, { visibility: ['app'] });
|
||||
});
|
||||
|
||||
test('a small response returns whole, compressed when that helps', async () => {
|
||||
const text = 'body { color: red; }\n'.repeat(200);
|
||||
const { catalog, requests } = catalogWith(
|
||||
() =>
|
||||
new Response(text, {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'text/css', 'set-cookie': 'a=b' },
|
||||
}),
|
||||
);
|
||||
const { data } = await catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
path: '/panel/assets/style.css',
|
||||
headers: { Accept: 'text/css', Cookie: 'secret', 'X-Other': '1' },
|
||||
});
|
||||
assert.deepEqual(requests[0].headers, { Accept: 'text/css' });
|
||||
assert.equal(data.status, 200);
|
||||
assert.equal(data.encoding, 'gzip');
|
||||
assert.equal(data.nextOffset, undefined);
|
||||
assert.equal(data.headers['content-type'], 'text/css');
|
||||
assert.equal(data.headers['set-cookie'], undefined);
|
||||
const body = await gunzip(fromBase64(data.body));
|
||||
assert.equal(new TextDecoder().decode(body), text);
|
||||
});
|
||||
|
||||
test('a large response comes in parts that join to the original', async () => {
|
||||
const bytes = new Uint8Array(PANEL_PART_BYTES * 2 + 10);
|
||||
for (let index = 0; index < bytes.length; index++)
|
||||
bytes[index] = (index * 7919) % 251;
|
||||
const { catalog } = catalogWith(
|
||||
() => new Response(bytes, { headers: { 'content-type': 'image/png' } }),
|
||||
);
|
||||
let { data } = await catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
path: '/a.png',
|
||||
});
|
||||
assert.equal(data.encoding, 'identity');
|
||||
assert.equal(data.totalBytes, bytes.length);
|
||||
const parts = [fromBase64(data.body)];
|
||||
while (data.nextOffset !== undefined) {
|
||||
({ data } = await catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
id: data.id,
|
||||
offset: data.nextOffset,
|
||||
}));
|
||||
parts.push(fromBase64(data.body));
|
||||
}
|
||||
assert.equal(parts.length, 3);
|
||||
assert.deepEqual(new Uint8Array(Buffer.concat(parts)), bytes);
|
||||
});
|
||||
|
||||
test('a request body and method pass through; settings and other sites do not', async () => {
|
||||
const { catalog, requests } = catalogWith(
|
||||
() => new Response('{}', { status: 201 }),
|
||||
);
|
||||
const { data } = await catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
path: '/api/overpass',
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: Buffer.from('{"a":1}').toString('base64'),
|
||||
});
|
||||
assert.equal(data.status, 201);
|
||||
assert.equal(new TextDecoder().decode(requests[0].body), '{"a":1}');
|
||||
for (const path of [
|
||||
'/api/setup/status',
|
||||
'/api/setup',
|
||||
'/api/%73etup/status',
|
||||
'//evil.example/x',
|
||||
'/\\evil.example/secret',
|
||||
'/\\/evil.example/secret',
|
||||
'http://evil.example/',
|
||||
])
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { key: KEY, path }),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
path,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { key: KEY, id: 'unknown' }),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
);
|
||||
assert.equal(requests.length, 1);
|
||||
});
|
||||
|
||||
test('the path is requested as the parser reads it, without following redirects', async () => {
|
||||
const { catalog, requests } = catalogWith(() => new Response('ok'));
|
||||
await catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
key: KEY,
|
||||
path: '/panel/a/../b.js?x=1',
|
||||
});
|
||||
assert.equal(requests[0].path, '/panel/b.js?x=1');
|
||||
assert.equal(requests[0].redirect, 'manual');
|
||||
assert.ok(requests[0].signal instanceof AbortSignal);
|
||||
});
|
||||
|
||||
test('a response larger than the panel may load is refused while reading', async () => {
|
||||
const chunk = new Uint8Array(1024 * 1024);
|
||||
let sent = 0;
|
||||
const { catalog } = catalogWith(
|
||||
() =>
|
||||
new Response(
|
||||
new ReadableStream({
|
||||
pull(controller) {
|
||||
sent += chunk.length;
|
||||
controller.enqueue(chunk);
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { key: KEY, path: '/huge' }),
|
||||
(error) => error.code === 'unavailable' && /too large/.test(error.message),
|
||||
);
|
||||
assert.ok(sent <= PANEL_RESPONSE_LIMIT_BYTES + 2 * chunk.length, `${sent}`);
|
||||
});
|
||||
|
||||
test('large responses read in turn keep their own parts', async () => {
|
||||
const bodies = [1, 2].map((fill) =>
|
||||
new Uint8Array(PANEL_PART_BYTES + 5).fill(fill),
|
||||
);
|
||||
let index = 0;
|
||||
const { catalog } = catalogWith(
|
||||
() =>
|
||||
new Response(bodies[index++], {
|
||||
headers: { 'content-type': 'image/png' },
|
||||
}),
|
||||
);
|
||||
const first = (
|
||||
await catalog.call('panel_request', { key: KEY, path: '/a.png' })
|
||||
).data;
|
||||
const second = (
|
||||
await catalog.call('panel_request', { key: KEY, path: '/b.png' })
|
||||
).data;
|
||||
for (const [start, fill] of [
|
||||
[first, 1],
|
||||
[second, 2],
|
||||
]) {
|
||||
const { data } = await catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
id: start.id,
|
||||
offset: start.nextOffset,
|
||||
});
|
||||
assert.deepEqual([...fromBase64(data.body)], [1, 1, 1, 1, 1].fill(fill));
|
||||
}
|
||||
});
|
||||
|
||||
test('a call without the panel key is refused before any request', async () => {
|
||||
const { catalog, requests } = catalogWith(() => new Response('ok'));
|
||||
for (const key of [undefined, '', 'wrong', `${KEY}x`, KEY.toUpperCase()])
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', {
|
||||
...(key === undefined ? {} : { key }),
|
||||
path: '/panel/index.html',
|
||||
}),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
String(key),
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { id: 'held-elsewhere', offset: 0 }),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
);
|
||||
assert.equal(requests.length, 0);
|
||||
});
|
||||
|
||||
test('without a panel key in its services the tool refuses every call', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
app: {
|
||||
baseUrl: 'http://localhost:4173/',
|
||||
fetch: async () => new Response('ok'),
|
||||
},
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { key: '', path: '/panel/index.html' }),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
);
|
||||
});
|
||||
|
||||
test('routes the panel never loads are refused in any case or encoding', async () => {
|
||||
const { catalog, requests } = catalogWith(() => new Response('ok'));
|
||||
for (const path of [
|
||||
'/API/setup/status',
|
||||
'/Api/Setup',
|
||||
'/api/realtime/token',
|
||||
'/API/REALTIME/token',
|
||||
'/api/openai/hud-summary',
|
||||
'/api/%4fpenai/hud-summary',
|
||||
'/mcp',
|
||||
'/MCP',
|
||||
// A dot after a mounted route still reaches it.
|
||||
'/mcp.json',
|
||||
'/MCP.json',
|
||||
'/api/setup.json',
|
||||
'/api/realtime.token',
|
||||
'/api/OpenAI.x/hud-summary',
|
||||
'/@fs/etc/passwd',
|
||||
'/@vite/client',
|
||||
'/%40fs/x',
|
||||
'/__open-in-editor?file=x',
|
||||
])
|
||||
await assert.rejects(
|
||||
catalog.call('panel_request', { key: KEY, path }),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
path,
|
||||
);
|
||||
// Similar names the panel does load stay available.
|
||||
await catalog.call('panel_request', { key: KEY, path: '/api/setups-help' });
|
||||
await catalog.call('panel_request', { key: KEY, path: '/mcpx.json' });
|
||||
assert.deepEqual(
|
||||
requests.map((request) => request.path),
|
||||
['/api/setups-help', '/mcpx.json'],
|
||||
);
|
||||
});
|
||||
|
||||
test('requests past the limit wait their turn instead of failing', async () => {
|
||||
const gates = [];
|
||||
const { catalog, requests } = catalogWith(
|
||||
() =>
|
||||
new Promise((resolve) => gates.push(() => resolve(new Response('ok')))),
|
||||
);
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
// Two panels on one server, each sending as many requests as it may.
|
||||
const calls = Array.from({ length: PANEL_CONCURRENT_REQUESTS * 2 }, (_, n) =>
|
||||
catalog.call('panel_request', { key: KEY, path: `/panel/${n}.js` }),
|
||||
);
|
||||
await tick();
|
||||
assert.equal(requests.length, PANEL_CONCURRENT_REQUESTS);
|
||||
// Each finished request lets one waiting request start.
|
||||
for (let started = PANEL_CONCURRENT_REQUESTS; gates.length;) {
|
||||
gates.shift()();
|
||||
await tick();
|
||||
if (started < calls.length) {
|
||||
started += 1;
|
||||
assert.equal(requests.length, started);
|
||||
}
|
||||
}
|
||||
const results = await Promise.all(calls);
|
||||
assert.ok(results.every(({ data }) => data.status === 200));
|
||||
});
|
||||
|
||||
test('a waiting request its caller cancels leaves the queue', async () => {
|
||||
const gates = [];
|
||||
const { catalog, requests } = catalogWith(
|
||||
() =>
|
||||
new Promise((resolve) => gates.push(() => resolve(new Response('ok')))),
|
||||
);
|
||||
const tick = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const busy = Array.from({ length: PANEL_CONCURRENT_REQUESTS }, (_, n) =>
|
||||
catalog.call('panel_request', { key: KEY, path: `/panel/${n}.js` }),
|
||||
);
|
||||
const cancel = new AbortController();
|
||||
const waiting = catalog.call(
|
||||
'panel_request',
|
||||
{ key: KEY, path: '/panel/cancelled.js' },
|
||||
{ signal: cancel.signal },
|
||||
);
|
||||
const after = catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
path: '/panel/after.js',
|
||||
});
|
||||
await tick();
|
||||
cancel.abort();
|
||||
await assert.rejects(waiting);
|
||||
gates.shift()();
|
||||
await tick();
|
||||
// The cancelled request never reached the server; the next one did.
|
||||
assert.equal(requests.at(-1).path, '/panel/after.js');
|
||||
while (gates.length) gates.shift()();
|
||||
await Promise.all([...busy, after]);
|
||||
assert.ok(
|
||||
!requests.some((request) => request.path === '/panel/cancelled.js'),
|
||||
);
|
||||
});
|
||||
|
||||
test("one server's held responses are not readable through another's", async () => {
|
||||
const big = new Uint8Array(PANEL_PART_BYTES * 2).fill(7);
|
||||
const one = catalogWith(
|
||||
() => new Response(big, { headers: { 'content-type': 'image/png' } }),
|
||||
);
|
||||
const other = catalogWith(() => new Response('x'));
|
||||
const { data } = await one.catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
path: '/big.png',
|
||||
});
|
||||
assert.ok(data.id);
|
||||
await assert.rejects(
|
||||
other.catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
id: data.id,
|
||||
offset: data.nextOffset,
|
||||
}),
|
||||
(error) => error.code === 'invalid_arguments',
|
||||
);
|
||||
const rest = await one.catalog.call('panel_request', {
|
||||
key: KEY,
|
||||
id: data.id,
|
||||
offset: data.nextOffset,
|
||||
});
|
||||
assert.equal(rest.data.offset, data.nextOffset);
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
/** Place search and routing queries. */
|
||||
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import {
|
||||
AREA_SCHEMA,
|
||||
POINT_SCHEMA,
|
||||
areaCenter,
|
||||
areaContains,
|
||||
areaRadiusKm,
|
||||
resolveArea,
|
||||
resolvePoint,
|
||||
} from '../area.js';
|
||||
import { LIMIT_SCHEMA, capRows, countNoun, thinEvenly } from '../results.js';
|
||||
|
||||
// Most results the app's place search endpoints return, and the widest
|
||||
// radius a text search covers.
|
||||
const SEARCH_RESULTS = 5;
|
||||
const NEARBY_RESULTS = 20;
|
||||
const MAX_SEARCH_RADIUS_M = 50_000;
|
||||
|
||||
const MODES = { walk: 'foot', drive: 'car', bike: 'bike' };
|
||||
const MAX_ROUTE_POINTS = 100;
|
||||
const NOT_CONFIGURED =
|
||||
'Place search needs a Google Places key configured for this server';
|
||||
|
||||
const round = (value, digits) =>
|
||||
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
|
||||
|
||||
function placeRow(place) {
|
||||
return {
|
||||
id: place.id ?? null,
|
||||
name: place.name ?? null,
|
||||
address: place.address ?? null,
|
||||
type: place.primaryType ?? null,
|
||||
lat: round(place.latitude, 6),
|
||||
lon: round(place.longitude, 6),
|
||||
distance_m: Number.isFinite(place.distanceM) ? place.distanceM : null,
|
||||
};
|
||||
}
|
||||
|
||||
export const searchPlaces = defineTool({
|
||||
name: 'search_places',
|
||||
title: 'Search places',
|
||||
description:
|
||||
'Find businesses, landmarks and other points of interest matching a ' +
|
||||
'query within an area, using Google Places.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
query: { type: 'string', minLength: 1, maxLength: 200 },
|
||||
area: AREA_SCHEMA,
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['query', 'area'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['placeSearch'],
|
||||
async run(args, { services, signal }) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
const center = areaCenter(area);
|
||||
const wantedM = areaRadiusKm(area) * 1000;
|
||||
const radiusM = Math.round(
|
||||
Math.min(MAX_SEARCH_RADIUS_M, Math.max(500, wantedM)),
|
||||
);
|
||||
const result = await services.placeSearch.search(
|
||||
args.query,
|
||||
{ latitude: center.lat, longitude: center.lon, radiusM },
|
||||
{ signal },
|
||||
);
|
||||
if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED);
|
||||
const rows = result.places
|
||||
.filter((place) =>
|
||||
areaContains(area, { lat: place.latitude, lon: place.longitude }),
|
||||
)
|
||||
.map(placeRow);
|
||||
// The search returns a few matches within a capped radius; say so when
|
||||
// either limit may have left places out.
|
||||
const limited = result.places.length >= SEARCH_RESULTS;
|
||||
const narrowed = wantedM > MAX_SEARCH_RADIUS_M;
|
||||
const notes = [
|
||||
...(limited
|
||||
? [`the search returns at most ${SEARCH_RESULTS} matches`]
|
||||
: []),
|
||||
...(narrowed
|
||||
? [`searched within ${MAX_SEARCH_RADIUS_M / 1000} km of the center`]
|
||||
: []),
|
||||
];
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'place')} matching "${args.query}" in ${area.label}` +
|
||||
(notes.length ? ` (${notes.join('; ')}).` : '.'),
|
||||
data: {
|
||||
...capRows(rows, args.limit),
|
||||
may_have_more: limited || narrowed,
|
||||
searched_radius_km: radiusM / 1000,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const placesNearby = defineTool({
|
||||
name: 'places_nearby',
|
||||
title: 'Places nearby',
|
||||
description:
|
||||
'Notable places around a point within a radius, most notable first, ' +
|
||||
'using Google Places.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
location: POINT_SCHEMA,
|
||||
radius_m: { type: 'number', minimum: 10, maximum: 5000 },
|
||||
limit: LIMIT_SCHEMA,
|
||||
},
|
||||
required: ['location'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['placeSearch'],
|
||||
async run(args, { services, signal }) {
|
||||
const point = await resolvePoint(args.location, { services, signal });
|
||||
const radiusM = Math.round(args.radius_m ?? 250);
|
||||
const result = await services.placeSearch.nearby(
|
||||
{ latitude: point.lat, longitude: point.lon, radiusM },
|
||||
{ signal },
|
||||
);
|
||||
if (!result.configured) throw new ToolError('unavailable', NOT_CONFIGURED);
|
||||
const rows = result.places.map(placeRow);
|
||||
const limited = result.places.length >= NEARBY_RESULTS;
|
||||
return {
|
||||
summary:
|
||||
`${countNoun(rows.length, 'place')} within ${radiusM} m of ${point.label}` +
|
||||
(limited
|
||||
? ` (nearby search returns at most ${NEARBY_RESULTS} places).`
|
||||
: '.'),
|
||||
data: { ...capRows(rows, args.limit), may_have_more: limited },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export const planRoute = defineTool({
|
||||
name: 'plan_route',
|
||||
title: 'Plan a route',
|
||||
description:
|
||||
'Walking, driving or cycling route between two locations over ' +
|
||||
'OpenStreetMap, with distance, duration and a simplified path.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
from: POINT_SCHEMA,
|
||||
to: POINT_SCHEMA,
|
||||
mode: { type: 'string', enum: Object.keys(MODES) },
|
||||
},
|
||||
required: ['from', 'to'],
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['routing'],
|
||||
async run(args, { services, signal }) {
|
||||
const [from, to] = await Promise.all([
|
||||
resolvePoint(args.from, { services, signal }),
|
||||
resolvePoint(args.to, { services, signal }),
|
||||
]);
|
||||
const mode = args.mode ?? 'walk';
|
||||
const route = await services.routing.route([from, to], MODES[mode], {
|
||||
signal,
|
||||
});
|
||||
if (!route?.ok)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
`No ${mode} route from ${from.label} to ${to.label}: ${route?.error || 'no route found'}`,
|
||||
);
|
||||
const path = thinEvenly(route.geometry, MAX_ROUTE_POINTS).map(
|
||||
([lon, lat]) => [round(lon, 5), round(lat, 5)],
|
||||
);
|
||||
const km = route.distanceM / 1000;
|
||||
const minutes = Math.round(route.durationS / 60);
|
||||
return {
|
||||
summary: `${mode[0].toUpperCase()}${mode.slice(1)} from ${from.label} to ${to.label}: ${km.toFixed(1)} km, about ${minutes} minutes.`,
|
||||
data: {
|
||||
mode,
|
||||
from,
|
||||
to,
|
||||
distance_m: route.distanceM,
|
||||
duration_s: route.durationS,
|
||||
path_lon_lat: path,
|
||||
path_points: route.geometry.length,
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,276 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
import { createPlaceSearchService, createRouteService } from '../places.js';
|
||||
|
||||
const place = (id, latitude, longitude, extra = {}) => ({
|
||||
id,
|
||||
name: `Place ${id}`,
|
||||
address: `${id} Main St`,
|
||||
latitude,
|
||||
longitude,
|
||||
distanceM: 120,
|
||||
primaryType: 'cafe',
|
||||
types: ['cafe'],
|
||||
...extra,
|
||||
});
|
||||
const placeSearch = (result) => ({
|
||||
calls: [],
|
||||
async search(query, near) {
|
||||
this.calls.push({ query, ...near });
|
||||
return result;
|
||||
},
|
||||
async nearby(near) {
|
||||
this.calls.push(near);
|
||||
return result;
|
||||
},
|
||||
});
|
||||
const places = {
|
||||
resolve: async (name) =>
|
||||
({
|
||||
Austin: {
|
||||
name: 'Austin, Texas',
|
||||
bounds: { west: -97.94, south: 30.1, east: -97.56, north: 30.52 },
|
||||
},
|
||||
Dallas: {
|
||||
name: 'Dallas, Texas',
|
||||
bounds: { west: -96.99, south: 32.62, east: -96.46, north: 33.02 },
|
||||
},
|
||||
})[name] ?? null,
|
||||
};
|
||||
|
||||
test('place search is centered on the area, kept inside it, and reports a missing key', async () => {
|
||||
const search = placeSearch({
|
||||
configured: true,
|
||||
places: [place('a', 30.27, -97.74), place('far', 40.7, -74)],
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { placeSearch: search, places },
|
||||
});
|
||||
const result = await catalog.call('search_places', {
|
||||
query: 'coffee',
|
||||
area: { place: 'Austin' },
|
||||
});
|
||||
assert.equal(result.summary, '1 place matching "coffee" in Austin, Texas.');
|
||||
assert.deepEqual(result.data.rows[0], {
|
||||
id: 'a',
|
||||
name: 'Place a',
|
||||
address: 'a Main St',
|
||||
type: 'cafe',
|
||||
lat: 30.27,
|
||||
lon: -97.74,
|
||||
distance_m: 120,
|
||||
});
|
||||
assert.equal(search.calls[0].query, 'coffee');
|
||||
assert.ok(Math.abs(search.calls[0].latitude - 30.31) < 1e-9);
|
||||
assert.equal(search.calls[0].radiusM, 29642);
|
||||
|
||||
const unconfigured = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
placeSearch: placeSearch({ configured: false, places: [] }),
|
||||
places,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
unconfigured.call('search_places', {
|
||||
query: 'coffee',
|
||||
area: { place: 'Austin' },
|
||||
}),
|
||||
(error) =>
|
||||
error.code === 'unavailable' && /Google Places key/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('nearby places use the given radius', async () => {
|
||||
const search = placeSearch({
|
||||
configured: true,
|
||||
places: [place('a', 30.27, -97.74)],
|
||||
});
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { placeSearch: search },
|
||||
});
|
||||
const result = await catalog.call('places_nearby', {
|
||||
location: { lat: 30.27, lon: -97.74 },
|
||||
radius_m: 400,
|
||||
});
|
||||
assert.equal(result.summary, '1 place within 400 m of 30.2700, -97.7400.');
|
||||
assert.deepEqual(search.calls[0], {
|
||||
latitude: 30.27,
|
||||
longitude: -97.74,
|
||||
radiusM: 400,
|
||||
});
|
||||
});
|
||||
|
||||
test('routes resolve endpoints, map modes and thin long paths', async () => {
|
||||
const requests = [];
|
||||
const geometry = Array.from({ length: 250 }, (_, index) => [
|
||||
-97.74 + index / 1000,
|
||||
30.27,
|
||||
]);
|
||||
const routing = {
|
||||
async route(points, profile) {
|
||||
requests.push({ points, profile });
|
||||
return {
|
||||
ok: true,
|
||||
profile,
|
||||
distanceM: 312400,
|
||||
durationS: 11220,
|
||||
geometry,
|
||||
};
|
||||
},
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { routing, places },
|
||||
});
|
||||
const result = await catalog.call('plan_route', {
|
||||
from: { place: 'Austin' },
|
||||
to: { lat: 32.78, lon: -96.8 },
|
||||
mode: 'drive',
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'Drive from Austin, Texas to 32.7800, -96.8000: 312.4 km, about 187 minutes.',
|
||||
);
|
||||
assert.equal(requests[0].profile, 'car');
|
||||
assert.deepEqual(requests[0].points[1], {
|
||||
label: '32.7800, -96.8000',
|
||||
lat: 32.78,
|
||||
lon: -96.8,
|
||||
});
|
||||
assert.equal(result.data.path_points, 250);
|
||||
assert.equal(result.data.path_lon_lat.length, 100);
|
||||
assert.deepEqual(result.data.path_lon_lat.at(-1), [-97.491, 30.27]);
|
||||
|
||||
const failing = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
routing: {
|
||||
route: async () => ({ ok: false, error: 'route leg too long' }),
|
||||
},
|
||||
places,
|
||||
},
|
||||
});
|
||||
await assert.rejects(
|
||||
failing.call('plan_route', {
|
||||
from: { place: 'Austin' },
|
||||
to: { place: 'Dallas' },
|
||||
}),
|
||||
/No walk route from Austin, Texas to Dallas, Texas: route leg too long/,
|
||||
);
|
||||
await assert.rejects(
|
||||
catalog.call('plan_route', {
|
||||
from: { place: 'Austin', lat: 1 },
|
||||
to: { lat: 1, lon: 2 },
|
||||
}),
|
||||
/either place, or lat and lon/,
|
||||
);
|
||||
});
|
||||
|
||||
test('the place and route services read the app routes and translate failures', async () => {
|
||||
const requested = [];
|
||||
const respond = (body, status = 200, headers = {}) =>
|
||||
new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json', ...headers },
|
||||
});
|
||||
const search = createPlaceSearchService({
|
||||
fetchImpl: async (url) => {
|
||||
requested.push(url);
|
||||
return url.includes('nearby')
|
||||
? respond({ configured: false, error: null, places: [] })
|
||||
: respond({ places: [place('a', 1, 2)] });
|
||||
},
|
||||
});
|
||||
assert.deepEqual(
|
||||
await search.search('tea', { latitude: 1, longitude: 2, radiusM: 300 }),
|
||||
{
|
||||
configured: true,
|
||||
places: [place('a', 1, 2)],
|
||||
},
|
||||
);
|
||||
assert.deepEqual(
|
||||
await search.nearby({ latitude: 1, longitude: 2, radiusM: 50 }),
|
||||
{ configured: false, places: [] },
|
||||
);
|
||||
assert.deepEqual(requested, [
|
||||
'/api/google/text-search?q=tea&lat=1&lon=2&radiusM=300',
|
||||
'/api/google/nearby-places?lat=1&lon=2&radiusM=50',
|
||||
]);
|
||||
|
||||
const limited = createPlaceSearchService({
|
||||
fetchImpl: async () =>
|
||||
respond({ error: 'Rate limit exceeded', places: [] }, 429, {
|
||||
'Retry-After': '30',
|
||||
}),
|
||||
});
|
||||
await assert.rejects(
|
||||
limited.nearby({ latitude: 1, longitude: 2, radiusM: 50 }),
|
||||
(error) => error.code === 'retry_later' && error.retryAfterSeconds === 30,
|
||||
);
|
||||
|
||||
const routes = [];
|
||||
const routing = createRouteService({
|
||||
fetchImpl: async (url) => {
|
||||
routes.push(url);
|
||||
if (routes.length === 1)
|
||||
return respond({
|
||||
ok: true,
|
||||
distanceM: 10,
|
||||
durationS: 5,
|
||||
geometry: [[2, 1]],
|
||||
});
|
||||
if (routes.length === 2)
|
||||
return respond({ ok: false, error: 'need 2-12 coordinates' }, 400);
|
||||
return respond({ ok: false, error: 'rate limited' }, 429, {
|
||||
'Retry-After': '5',
|
||||
});
|
||||
},
|
||||
});
|
||||
const points = [
|
||||
{ lat: 1, lon: 2 },
|
||||
{ lat: 1.5, lon: 2.5 },
|
||||
];
|
||||
assert.equal((await routing.route(points, 'foot')).distanceM, 10);
|
||||
assert.equal(
|
||||
routes[0],
|
||||
'/api/route?profile=foot&coords=2.000000%2C1.000000%3B2.500000%2C1.500000',
|
||||
);
|
||||
assert.deepEqual(await routing.route(points, 'foot'), {
|
||||
ok: false,
|
||||
error: 'need 2-12 coordinates',
|
||||
});
|
||||
await assert.rejects(
|
||||
routing.route(points, 'foot'),
|
||||
(error) => error.code === 'retry_later' && error.retryAfterSeconds === 5,
|
||||
);
|
||||
});
|
||||
|
||||
test('place answers say when the search limits may have left places out', async () => {
|
||||
const five = Array.from({ length: 5 }, (_, index) =>
|
||||
place(`p${index}`, 30.27, -97.74),
|
||||
);
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
placeSearch: placeSearch({ configured: true, places: five }),
|
||||
places,
|
||||
},
|
||||
});
|
||||
const capped = await catalog.call('search_places', {
|
||||
query: 'coffee',
|
||||
area: { place: 'Austin' },
|
||||
limit: 100,
|
||||
});
|
||||
assert.equal(capped.data.may_have_more, true);
|
||||
assert.match(capped.summary, /the search returns at most 5 matches\)\.$/);
|
||||
const wide = await catalog.call('search_places', {
|
||||
query: 'coffee',
|
||||
area: { lat: 30.27, lon: -97.74, radius_km: 200 },
|
||||
});
|
||||
assert.equal(wide.data.searched_radius_km, 50);
|
||||
assert.match(wide.summary, /searched within 50 km of the center/);
|
||||
});
|
||||
@@ -0,0 +1,202 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { composeCatalog, coreTools } from '../index.js';
|
||||
|
||||
const earthquakes = {
|
||||
getSnapshot: async () => [
|
||||
{
|
||||
stableId: 'a',
|
||||
usgsId: 'us1',
|
||||
lon: 121.5,
|
||||
lat: 24,
|
||||
depthKm: 10,
|
||||
mag: 5.1,
|
||||
place: 'near Hualien',
|
||||
time: Date.UTC(2026, 0, 1, 1),
|
||||
},
|
||||
{
|
||||
stableId: 'b',
|
||||
usgsId: 'us2',
|
||||
lon: -120,
|
||||
lat: 36,
|
||||
depthKm: 5,
|
||||
mag: 3.2,
|
||||
place: 'Central California',
|
||||
time: Date.UTC(2026, 0, 1, 2),
|
||||
},
|
||||
{
|
||||
stableId: 'c',
|
||||
usgsId: null,
|
||||
lon: 122,
|
||||
lat: 23.5,
|
||||
depthKm: null,
|
||||
mag: 2.6,
|
||||
place: null,
|
||||
time: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
const fires = (snapshot) => ({ getSnapshot: async () => snapshot });
|
||||
const launches = (payload) => ({ getLaunches: async () => payload });
|
||||
const taiwan = {
|
||||
resolve: async () => ({
|
||||
name: 'Taiwan',
|
||||
bounds: { west: 119, south: 21, east: 123, north: 26 },
|
||||
}),
|
||||
};
|
||||
|
||||
test('earthquakes are filtered, ordered strongest first and capped', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { earthquakes, places: taiwan },
|
||||
});
|
||||
const all = await catalog.call('get_earthquakes', {});
|
||||
assert.equal(
|
||||
all.summary,
|
||||
'3 earthquakes of M2.5+ in the last 24 hours worldwide; strongest M5.1 near Hualien.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
all.data.rows.map((row) => row.id),
|
||||
['us1', 'us2', 'c'],
|
||||
);
|
||||
assert.deepEqual(all.data.rows[0], {
|
||||
id: 'us1',
|
||||
magnitude: 5.1,
|
||||
place: 'near Hualien',
|
||||
time: '2026-01-01T01:00:00.000Z',
|
||||
lat: 24,
|
||||
lon: 121.5,
|
||||
depth_km: 10,
|
||||
});
|
||||
|
||||
const local = await catalog.call('get_earthquakes', {
|
||||
area: { place: 'Taiwan' },
|
||||
min_magnitude: 3,
|
||||
limit: 1,
|
||||
});
|
||||
assert.equal(
|
||||
local.summary,
|
||||
'1 earthquake of M3+ in the last 24 hours in Taiwan; strongest M5.1 near Hualien.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
{
|
||||
total: local.data.total,
|
||||
returned: local.data.returned,
|
||||
truncated: local.data.truncated,
|
||||
},
|
||||
{ total: 1, returned: 1, truncated: false },
|
||||
);
|
||||
|
||||
const capped = await catalog.call('get_earthquakes', { limit: 2 });
|
||||
assert.equal(capped.data.truncated, true);
|
||||
assert.equal(capped.data.total, 3);
|
||||
});
|
||||
|
||||
test('fires need an area and report a missing key as unavailable', async () => {
|
||||
const snapshot = {
|
||||
fires: [
|
||||
{
|
||||
lat: 24,
|
||||
lon: 121,
|
||||
frp: 12,
|
||||
confidence: 'h',
|
||||
acqDate: '2026-01-01',
|
||||
acqTime: '0130',
|
||||
satellite: 'N',
|
||||
daynight: 'N',
|
||||
},
|
||||
{ lat: 25, lon: 122, frp: 40 },
|
||||
{ lat: -33, lon: 151, frp: 99 },
|
||||
],
|
||||
};
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { fires: fires(snapshot), places: taiwan },
|
||||
});
|
||||
await assert.rejects(catalog.call('get_active_fires', {}), /missing area/);
|
||||
const result = await catalog.call('get_active_fires', {
|
||||
area: { place: 'Taiwan' },
|
||||
});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 fire detections in Taiwan in the last 24 hours.',
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.data.rows.map((row) => row.frp_mw),
|
||||
[40, 12],
|
||||
);
|
||||
assert.equal(result.data.rows[1].acquired, '2026-01-01 0130');
|
||||
|
||||
const keyless = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { fires: fires({ keyRequired: true }) },
|
||||
});
|
||||
await assert.rejects(
|
||||
keyless.call('get_active_fires', { area: { bbox: [0, 0, 1, 1] } }),
|
||||
(error) =>
|
||||
error.code === 'unavailable' && /NASA FIRMS key/.test(error.message),
|
||||
);
|
||||
});
|
||||
|
||||
test('recent launches are newest first with missing fields as null', async () => {
|
||||
const catalog = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: {
|
||||
launches: launches({
|
||||
results: [
|
||||
{
|
||||
id: 'old',
|
||||
name: 'Falcon 9 | Starlink',
|
||||
net: '2026-01-01T00:00:00Z',
|
||||
status: { name: 'Launch Successful' },
|
||||
},
|
||||
{
|
||||
id: 'new',
|
||||
name: 'Electron | Mission',
|
||||
net: '2026-01-05T00:00:00Z',
|
||||
launch_service_provider: { name: 'Rocket Lab' },
|
||||
rocket: { configuration: { full_name: 'Electron' } },
|
||||
pad: { name: 'LC-1A', location: { name: 'Mahia' } },
|
||||
mission: { name: 'Mission', orbit: { name: 'Low Earth Orbit' } },
|
||||
},
|
||||
{ id: 'unnamed' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
});
|
||||
const result = await catalog.call('get_recent_launches', {});
|
||||
assert.equal(
|
||||
result.summary,
|
||||
'2 launches in the last 30 days; latest Electron | Mission.',
|
||||
);
|
||||
assert.deepEqual(result.data.rows[0], {
|
||||
id: 'new',
|
||||
name: 'Electron | Mission',
|
||||
time: '2026-01-05T00:00:00Z',
|
||||
status: null,
|
||||
provider: 'Rocket Lab',
|
||||
rocket: 'Electron',
|
||||
pad: 'LC-1A',
|
||||
location: 'Mahia',
|
||||
mission: 'Mission',
|
||||
orbit: 'Low Earth Orbit',
|
||||
});
|
||||
const broken = composeCatalog({
|
||||
tools: coreTools,
|
||||
services: { launches: launches({}) },
|
||||
});
|
||||
await assert.rejects(
|
||||
broken.call('get_recent_launches', {}),
|
||||
(error) => error.code === 'malformed',
|
||||
);
|
||||
});
|
||||
|
||||
test('every core tool is a read-only query with a unique name', () => {
|
||||
const names = coreTools.map((tool) => tool.name);
|
||||
assert.equal(new Set(names).size, names.length);
|
||||
for (const tool of coreTools) {
|
||||
assert.equal(tool.kind, 'query', tool.name);
|
||||
assert.equal(tool.annotations.readOnlyHint, true, tool.name);
|
||||
assert.ok(tool.requires.length > 0, tool.name);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
/** Links that open God's Eye View at a view. */
|
||||
|
||||
import { VIEW_PROPERTIES, createView, viewUrl } from '../../view/index.js';
|
||||
import { defineTool, ToolError } from '../catalog.js';
|
||||
import { GLOBE_PANEL_URI } from '../globePanel.js';
|
||||
import { AREA_SCHEMA, resolveArea } from '../area.js';
|
||||
import { cameraForArea, cameraLookingAt } from '../views.js';
|
||||
|
||||
/** Tool arguments that describe a view: an area to frame, or a camera. */
|
||||
export const VIEW_ARGUMENTS = Object.freeze({
|
||||
view: Object.freeze({
|
||||
type: 'object',
|
||||
description:
|
||||
'A view another answer returned (its data.view), shown as it is; ' +
|
||||
'other arguments given with it change that part of it.',
|
||||
}),
|
||||
area: AREA_SCHEMA,
|
||||
...VIEW_PROPERTIES,
|
||||
});
|
||||
|
||||
// Altitude a view framed on a followed aircraft starts from.
|
||||
const FOLLOW_ALTITUDE_M = 20_000;
|
||||
|
||||
/** Where a followed aircraft is now, or null when no feed reports it. */
|
||||
async function followedAircraftPosition(follow, tools) {
|
||||
if (!['aircraft', 'military_aircraft'].includes(follow?.kind)) return null;
|
||||
if (!tools?.has('find_aircraft')) return null;
|
||||
try {
|
||||
const { data } = await tools.call('find_aircraft', { icao24: follow.id });
|
||||
const row = data?.rows?.find(
|
||||
(item) => Number.isFinite(item.lat) && Number.isFinite(item.lon),
|
||||
);
|
||||
return row ? { lat: row.lat, lon: row.lon } : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The view described by tool arguments. An area frames the camera straight
|
||||
* down over it; camera fields given alongside it override the framing. A
|
||||
* view that only follows an aircraft is framed where the aircraft is now.
|
||||
*/
|
||||
export async function resolveViewArguments(args, { services, signal, tools }) {
|
||||
const base = args.view && typeof args.view === 'object' ? args.view : {};
|
||||
let camera = args.area
|
||||
? { ...args.camera }
|
||||
: { ...base.camera, ...args.camera };
|
||||
let label = null;
|
||||
if (args.area) {
|
||||
const area = await resolveArea(args.area, { services, signal });
|
||||
label = area.label;
|
||||
const framed = cameraForArea(area);
|
||||
// A tilted view of an area looks at its center from behind, unless the
|
||||
// caller placed the camera itself.
|
||||
camera =
|
||||
camera.lat === undefined && camera.lon === undefined
|
||||
? cameraLookingAt(framed, {
|
||||
altitudeM: camera.altitude_m ?? framed.altitude_m,
|
||||
headingDeg: camera.heading_deg ?? 0,
|
||||
pitchDeg: camera.pitch_deg ?? -90,
|
||||
})
|
||||
: { ...framed, ...camera };
|
||||
}
|
||||
const follow = args.follow ?? base.follow ?? null;
|
||||
if (!Number.isFinite(camera.lat) || !Number.isFinite(camera.lon)) {
|
||||
const position = await followedAircraftPosition(follow, tools);
|
||||
if (!position)
|
||||
throw new ToolError(
|
||||
'invalid_arguments',
|
||||
follow
|
||||
? `${follow.id} is not reported now; give an area or a camera with lat and lon too`
|
||||
: 'Give a view, an area, or a camera with lat and lon',
|
||||
);
|
||||
camera = { altitude_m: FOLLOW_ALTITUDE_M, ...camera, ...position };
|
||||
label = `${follow.kind === 'military_aircraft' ? 'military aircraft' : 'aircraft'} ${follow.id}`;
|
||||
}
|
||||
let view;
|
||||
try {
|
||||
view = createView({
|
||||
camera,
|
||||
layers: args.layers ?? base.layers,
|
||||
style: args.style ?? base.style ?? null,
|
||||
map: args.map ?? base.map ?? null,
|
||||
follow,
|
||||
annotations: args.annotations ?? base.annotations ?? [],
|
||||
});
|
||||
} catch (error) {
|
||||
// A view passed through can hold anything; report it as bad arguments.
|
||||
throw new ToolError('invalid_arguments', error.message);
|
||||
}
|
||||
return {
|
||||
view,
|
||||
label:
|
||||
label ?? `${view.camera.lat.toFixed(3)}, ${view.camera.lon.toFixed(3)}`,
|
||||
};
|
||||
}
|
||||
|
||||
function appBase(services) {
|
||||
try {
|
||||
return new URL(services.app.baseUrl).href;
|
||||
} catch {
|
||||
throw new ToolError('unavailable', "The app's address is not configured");
|
||||
}
|
||||
}
|
||||
|
||||
export const showInGodsEyeView = defineTool({
|
||||
name: 'show_in_gods_eye_view',
|
||||
title: "Show in God's Eye View",
|
||||
description:
|
||||
"Shows a view in God's Eye View: in clients that display apps, the live " +
|
||||
"God's Eye View globe in the conversation; everywhere, a link that opens " +
|
||||
'it. Pass the view another answer returned, or describe one: an area ' +
|
||||
'framed from above or a camera position, with chosen data layers, visual ' +
|
||||
'style and map, optionally following an aircraft (or riding in its ' +
|
||||
'cockpit view) or a satellite, and with marks drawn on the map.',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: VIEW_ARGUMENTS,
|
||||
additionalProperties: false,
|
||||
},
|
||||
requires: ['app'],
|
||||
ui: { resourceUri: GLOBE_PANEL_URI },
|
||||
async run(args, { services, signal, tools }) {
|
||||
const base = appBase(services);
|
||||
const { view, label } = await resolveViewArguments(args, {
|
||||
services,
|
||||
signal,
|
||||
tools,
|
||||
});
|
||||
const url = viewUrl(base, view);
|
||||
return {
|
||||
summary: `Open ${label} in God's Eye View: ${url}`,
|
||||
data: { url, view },
|
||||
};
|
||||
},
|
||||
});
|
||||
Loaded 100 of 159 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user