Merge remote-tracking branch 'origin/main' into feat/alpr-visual-treatment

# Conflicts:
#	scripts/package-boundaries.json
This commit is contained in:
Sameh Khamis committed 2026-09-14 18:10:05 -07:00
commit fdf030cbc9
56 files changed
+10029 -47

No files matched your search

+389
View File
@@ -0,0 +1,389 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
calgaryCameraId,
calgaryCameraName,
calgaryCameraToSource,
loadCalgarySourcesFromOpenData,
normalizeCalgaryImageUrl,
} from '../../server/providers/cctv/sources.js';
import {
CALGARY_IMAGE_ORIGIN,
CALGARY_MAX_CATALOG_BYTES,
DEFAULT_CALGARY_ROWS_URL,
DEFAULT_CCTV_MAX_SOURCES,
} from '../../server/providers/cctv/constants.js';
import { CAMERA_CODE_MAX_CHARS } from '../../server/providers/cctv/normalize.js';
import { allocateSourceCap } from '../../server/providers/cctv/cap.js';
import { createCctvCatalog } from '../../server/providers/cctv/catalog.js';
import { directionToHeading } from './directionText.js';
/**
* A response whose body is a live stream, plus a flag that flips when the
* stream is cancelled. A rejection path that returns without cancelling holds
* the transport open, so the flag is what the refusal tests actually assert.
*/
const streamingResponse = (init = {}) => {
const state = { cancelled: false };
const body = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('['));
},
cancel() {
state.cancelled = true;
},
});
return { response: new Response(body, init), state };
};
/** One Open Calgary row, shaped like the live `k7p9-kppz` payload. */
const row = (overrides = {}) => ({
camera_url: {
url: 'http://trafficcam.calgary.ca/loc142.jpg',
description: 'Camera 143',
},
quadrant: 'SW',
camera_location: 'Bow Trail / 37 Street SW',
point: { type: 'Point', coordinates: [-114.1413616, 51.0453097] },
...overrides,
});
test('a Calgary row maps to a source on the pinned HTTPS frame host', () => {
const source = calgaryCameraToSource(row());
assert.equal(source.id, 'calgary-142');
assert.equal(source.name, 'Bow Trail / 37 Street SW');
assert.equal(source.cityId, 'calgary');
assert.equal(source.provider, 'The City of Calgary');
assert.equal(source.lat, 51.0453097);
assert.equal(source.lon, -114.1413616);
assert.equal(source.feedType, 'image');
assert.equal(source.sourceKind, 'calgary-open-data');
assert.equal(
source.license,
'Contains information licensed under the Open Government Licence – City of Calgary',
);
// The catalog publishes http://; the registered URL is the upgraded one.
assert.equal(source.url, 'https://trafficcam.calgary.ca/loc142.jpg');
assert.equal(source.url, source.snapshotUrl);
assert.ok(source.url.startsWith(CALGARY_IMAGE_ORIGIN));
});
test('no heading is derived from the quadrant or the address suffix', () => {
// Both fields parse as a confident compass bearing, and both would be wrong
// for every camera in the city: they are Calgary's address grid, not a
// camera facing. Nobody may "fix" this by wiring either one up.
assert.equal(directionToHeading('SW', true), 225);
assert.equal(directionToHeading('Bow Trail / 37 Street SW', true), 225);
for (const quadrant of ['NE', 'NW', 'SE', 'SW', 'NW/NE', 'S']) {
const source = calgaryCameraToSource(row({ quadrant }));
assert.equal(source.headingConfidence, 'low');
assert.ok(Number.isFinite(source.headingDeg));
// Same id, same fallback heading, whatever the quadrant says.
assert.equal(source.headingDeg, calgaryCameraToSource(row()).headingDeg);
}
});
test('rows outside Calgary, off-host frames and unusable geometry are dropped', () => {
// Toronto: a plausible lat/lon, but not a Calgary camera.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [-79.3832, 43.6532] } }),
),
null,
);
// Null island, and the string coordinates that would become it.
assert.equal(
calgaryCameraToSource(
row({ point: { type: 'Point', coordinates: [0, 0] } }),
),
null,
);
assert.equal(calgaryCameraToSource(row({ point: null })), null);
assert.equal(
calgaryCameraToSource(row({ point: { coordinates: [-114.14] } })),
null,
);
// A catalog edit cannot steer the frame proxy off the city host.
assert.equal(
calgaryCameraToSource(
row({ camera_url: { url: 'https://evil.example/loc1.jpg' } }),
),
null,
);
assert.equal(
calgaryCameraToSource(
row({
camera_url: { url: 'https://trafficcam.calgary.ca.evil.test/1.jpg' },
}),
),
null,
);
assert.equal(
calgaryCameraToSource(row({ camera_url: { url: 'file:///etc/passwd' } })),
null,
);
assert.equal(calgaryCameraToSource(row({ camera_url: null })), null);
assert.equal(calgaryCameraToSource(null), null);
});
test('frame URLs upgrade to HTTPS and ids stay stable', () => {
assert.equal(
normalizeCalgaryImageUrl('http://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(
normalizeCalgaryImageUrl('https://trafficcam.calgary.ca/loc86.jpg'),
'https://trafficcam.calgary.ca/loc86.jpg',
);
assert.equal(normalizeCalgaryImageUrl(''), null);
assert.equal(normalizeCalgaryImageUrl('not a url'), null);
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/loc86.jpg'),
'calgary-86',
);
// A filename-scheme change degrades to a still-stable slug, not a dropped camera.
assert.equal(
calgaryCameraId('https://trafficcam.calgary.ca/cams/deerfoot_16av.jpg'),
'calgary-cams-deerfoot-16av',
);
assert.equal(calgaryCameraId(''), null);
});
test('a nameless row still gets a label', () => {
assert.equal(
calgaryCameraName(
{ camera_location: ' 9 Avenue / 3 Street SE ' },
'calgary-1',
),
'9 Avenue / 3 Street SE',
);
assert.equal(
calgaryCameraName(
{ camera_url: { description: 'Camera 12' } },
'calgary-11',
),
'Camera 12',
);
assert.equal(calgaryCameraName({}, 'calgary-11'), 'Calgary Camera 11');
});
test('the loader reads the keyless catalog and collapses duplicate ids', async (t) => {
t.mock.method(console, 'log', () => {});
const requested = [];
t.mock.method(globalThis, 'fetch', async (url) => {
requested.push(String(url));
return Response.json([
row(),
// Same frame file twice: one camera, not two.
row({ camera_location: 'Bow Trail / 37 Street SW (duplicate)' }),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
row({ camera_url: { url: 'https://evil.example/x.jpg' } }),
]);
});
const cameras = await loadCalgarySourcesFromOpenData();
assert.deepEqual(requested, [DEFAULT_CALGARY_ROWS_URL]);
assert.deepEqual(
cameras.map((camera) => camera.id),
// Nearest downtown first: Bow Trail is inner-city, Stoney Trail is the ring road.
['calgary-142', 'calgary-86'],
);
assert.equal(cameras[0].name, 'Bow Trail / 37 Street SW');
});
test('an upstream failure yields an empty pack and releases the response', async (t) => {
t.mock.method(console, 'warn', () => {});
// A 503 can still arrive with a streaming body; returning without cancelling
// it would hold the connection until the socket times out.
const failed = streamingResponse({ status: 503 });
t.mock.method(globalThis, 'fetch', async () => failed.response);
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.equal(failed.state.cancelled, true, 'the failed body is cancelled');
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
throw new Error('network down');
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('the unselected label is the intersection, trimmed to the code width', () => {
assert.equal(calgaryCameraToSource(row()).code, 'BOW TRAIL / 37 STREET SW');
const long = calgaryCameraToSource(
row({
camera_location: 'Bow Trail & Old Banff Coach Rd SW / Strathcona Blvd SW',
}),
);
assert.equal(long.code.length, CAMERA_CODE_MAX_CHARS);
assert.ok(long.code.endsWith('…'));
assert.ok(long.code.startsWith('BOW TRAIL & OLD BANFF'));
});
test('the catalog fetch refuses redirects and oversized bodies', async (t) => {
t.mock.method(console, 'warn', () => {});
// A redirect is never followed: the list host cannot be steered. Its body is
// a live stream, so the test also proves the refusal releases the transport.
const seen = [];
const redirected = streamingResponse({
status: 302,
headers: { location: 'https://evil.example/rows.json' },
});
t.mock.method(globalThis, 'fetch', async (url, init) => {
seen.push([String(url), init.redirect]);
return redirected.response;
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
assert.deepEqual(seen, [[DEFAULT_CALGARY_ROWS_URL, 'manual']]);
assert.equal(
redirected.state.cancelled,
true,
'the redirect body is cancelled',
);
// A body over the cap is refused rather than buffered.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const body = JSON.stringify([row()]);
return new Response(body, {
headers: {
'Content-Type': 'application/json',
'content-length': String(CALGARY_MAX_CATALOG_BYTES + 1),
},
});
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
// So is a body that only declares its size once it is already too long.
t.mock.restoreAll();
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async () => {
const oversized = 'x'.repeat(CALGARY_MAX_CATALOG_BYTES + 1024);
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode(oversized));
controller.close();
},
}),
{ headers: { 'Content-Type': 'application/json' } },
);
});
assert.deepEqual(await loadCalgarySourcesFromOpenData(), []);
});
test('a reduced catalog cap thins every pack instead of dropping Calgary', () => {
// Calgary merges last in LIVE_PACKS, so a positional slice would delete it
// outright. The round-robin allocation must give it its share.
const lane = (name, count) => ({
name,
sources: Array.from({ length: count }, (_, i) => ({ id: `${name}-${i}` })),
});
const { sources, packs } = allocateSourceCap(
[lane('austin', 250), lane('nsw', 217), lane('calgary', 215)],
30,
);
assert.equal(sources.length, 30);
assert.deepEqual(
packs.map((p) => [p.name, p.kept]),
[
['austin', 10],
['nsw', 10],
['calgary', 10],
],
);
// Calgary contributes its own highest-priority cameras, in its own order.
assert.deepEqual(
sources.filter((s) => s.id.startsWith('calgary-')).map((s) => s.id),
Array.from({ length: 10 }, (_, i) => `calgary-${i}`),
);
});
/**
* Serve the Calgary catalog to the Calgary endpoint and an empty payload to
* every other pack, so one catalog refresh exercises the registration without
* reaching the network. Returns the URLs that were requested.
*/
const runCatalogWithMockedUpstreams = async (t) => {
const requested = [];
t.mock.method(console, 'log', () => {});
t.mock.method(console, 'warn', () => {});
t.mock.method(globalThis, 'fetch', async (url) => {
const href = String(url);
requested.push(href);
if (href.startsWith('https://data.calgary.ca/')) {
return Response.json([
row(),
row({
camera_url: { url: 'http://trafficcam.calgary.ca/loc86.jpg' },
camera_location: 'Stoney Trail / Deerfoot Trail SE',
point: { type: 'Point', coordinates: [-113.9766063, 50.9007257] },
}),
]);
}
return Response.json([]);
});
// A source root with no curated catalogs or ground-height sidecar, so the
// file-based packs contribute nothing and only the live lanes are in play.
const sources = await createCctvCatalog({ sourceRoot: '/nonexistent' })();
return { requested, sources };
};
test('the Calgary lane is wired into the catalog and its loader runs', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
delete process.env.CCTV_CALGARY_ENABLED;
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.ok(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
'the catalog refresh invokes the Calgary loader',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary').map((s) => s.id),
['calgary-142', 'calgary-86'],
'Calgary cameras reach the served catalog through the registered lane',
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('CCTV_CALGARY_ENABLED=0 keeps the lane from being loaded at all', async (t) => {
const saved = { ...process.env };
try {
delete process.env.CCTV_SOURCES_FILE;
delete process.env.CCTV_SOURCES_JSON;
process.env.CCTV_CALGARY_ENABLED = '0';
const { requested, sources } = await runCatalogWithMockedUpstreams(t);
assert.equal(
requested.includes(DEFAULT_CALGARY_ROWS_URL),
false,
'the disabled lane never reaches its upstream',
);
assert.deepEqual(
sources.filter((s) => s.cityId === 'calgary'),
[],
);
} finally {
for (const key of Object.keys(process.env)) {
if (!(key in saved)) delete process.env[key];
}
Object.assign(process.env, saved);
}
});
test('the shipped catalog ceiling is not raised to make room for this pack', () => {
assert.equal(DEFAULT_CCTV_MAX_SOURCES, 4000);
});
+3
View File
@@ -24,6 +24,7 @@
* invariant is untouched.
*/
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
// Grazing guard (spec §5): reject plane intersections when the view ray is
// nearly parallel to the constraint plane — the hit point races to infinity
@@ -506,6 +507,8 @@ export function createCalibrationGizmo({ viewer, getActiveRecord, applyPatch, en
}
handler.setInputAction((event) => {
// A tool owns the pointer (src/data/inputOwnership.js): yield the press.
if (!isPointerFree()) return;
if (!enabled) return;
const part = pickGizmoPart(event.position);
debugLog('LEFT_DOWN', event.position, 'part:', part);
+588
View File
@@ -0,0 +1,588 @@
// Client `Range` handling for the CCTV media proxy. The route used to relay
// req.headers.range verbatim to a third-party upstream; these pin what is now
// allowed through, both as string policy and through the mounted route with an
// injected upstream.
//
// Run with: npm test (node --test)
import { test } from 'node:test';
import assert from 'node:assert/strict';
import http from 'node:http';
import net from 'node:net';
import { Readable } from 'node:stream';
import { cctvProxy } from '../../server/providers/cctv.js';
import { sanitizeCctvRangeHeader } from '../../server/providers/cctv/range.js';
import { CCTV_MEDIA_MAX_BODY_BYTES as CAP } from '../../server/providers/cctv/constants.js';
test('single well-formed byte ranges pass through canonicalized', () => {
assert.equal(sanitizeCctvRangeHeader('bytes=0-1023'), 'bytes=0-1023');
// A single range that names the same byte twice is legal.
assert.equal(sanitizeCctvRangeHeader('bytes=7-7'), 'bytes=7-7');
// The unit is case-insensitive (RFC 7233 §2.1); surrounding space is legal.
assert.equal(sanitizeCctvRangeHeader(' BYTES=0-99 '), 'bytes=0-99');
});
test('multi-range is dropped so the upstream never answers multipart/byteranges', () => {
// The relay forwards Content-Length/Content-Range and caps on a single
// declared body; a multipart response defeats that accounting.
assert.equal(sanitizeCctvRangeHeader('bytes=0-1,2-3'), '');
assert.equal(sanitizeCctvRangeHeader('bytes=0-1, 100-200, 300-'), '');
});
test('malformed, non-byte and CR/LF-bearing values are dropped, not forwarded', () => {
for (const bad of [
'bytes=abc-def',
'bytes=',
'bytes=-',
'bytes=1-2-3',
'0-100', // no unit
'items=0-100', // a unit the proxy does not understand
'bytes=1.5-2',
'bytes=-0', // zero-length suffix is unsatisfiable
'bytes=0x10-0x20',
'bytes=+5-10',
'bytes=-5-10',
// An outbound request refuses to carry these, and the failure used to be
// written into the camera's health message.
'bytes=0-10\r\nX-Injected: 1',
'bytes=0-10\nHost: evil.example',
]) {
assert.equal(
sanitizeCctvRangeHeader(bad),
'',
`expected drop: ${JSON.stringify(bad)}`,
);
}
});
test('non-string and empty inputs yield no Range header', () => {
// req.headers.range is undefined on a normal GET, and Node hands back an
// array when a client sends the header twice.
assert.equal(sanitizeCctvRangeHeader(undefined), '');
assert.equal(sanitizeCctvRangeHeader(null), '');
assert.equal(sanitizeCctvRangeHeader(''), '');
assert.equal(sanitizeCctvRangeHeader(' '), '');
assert.equal(sanitizeCctvRangeHeader(['bytes=0-1', 'bytes=2-3']), '');
assert.equal(sanitizeCctvRangeHeader(42), '');
});
test('every accepted form is bounded to the relay body cap', () => {
// Explicit span.
assert.equal(
sanitizeCctvRangeHeader('bytes=0-999999999999'),
`bytes=0-${CAP - 1}`,
);
assert.equal(
sanitizeCctvRangeHeader('bytes=1000-999999999999'),
`bytes=1000-${1000 + CAP - 1}`,
);
// Open-ended: bounded too, rather than asking an upstream for a whole file
// of unknown size.
assert.equal(
sanitizeCctvRangeHeader('bytes=500-'),
`bytes=500-${500 + CAP - 1}`,
);
// Suffix: bounded to one span of the tail.
assert.equal(sanitizeCctvRangeHeader('bytes=-500'), 'bytes=-500');
assert.equal(sanitizeCctvRangeHeader('bytes=-999999999999'), `bytes=-${CAP}`);
// Exactly at the cap, and comfortably under it, are untouched.
assert.equal(
sanitizeCctvRangeHeader(`bytes=0-${CAP - 1}`),
`bytes=0-${CAP - 1}`,
);
assert.equal(sanitizeCctvRangeHeader('bytes=0-1048575'), 'bytes=0-1048575');
});
test('inverted and unsafe-integer positions are dropped', () => {
assert.equal(sanitizeCctvRangeHeader('bytes=500-100'), '');
// Beyond Number.MAX_SAFE_INTEGER the arithmetic silently loses precision.
assert.equal(sanitizeCctvRangeHeader('bytes=99999999999999999-'), '');
// Absurd digit counts are refused before any Number() conversion.
assert.equal(sanitizeCctvRangeHeader(`bytes=0-${'9'.repeat(64)}`), '');
});
// ---------------------------------------------------------------------------
// The mounted route, with the upstream injected through globalThis.fetch.
// ---------------------------------------------------------------------------
const CAMERA = {
id: 'range-fixture',
name: 'Range fixture',
lat: 30.27,
lon: -97.74,
feedType: 'video',
url: 'https://upstream.invalid/live.mp4',
};
/** A minimal upstream Response with a streaming body. */
function upstreamResponse({ status = 206, headers = {}, body = 'payload' }) {
const store = new Map(
Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]),
);
return {
ok: status >= 200 && status < 300,
status,
headers: { get: (name) => store.get(String(name).toLowerCase()) ?? null },
body:
body === null
? null
: Readable.toWeb(Readable.from([Buffer.from(String(body))])),
arrayBuffer: async () => Buffer.from(String(body)),
};
}
/** Collects what a handler wrote, the way a Node ServerResponse would. */
function recordingResponse() {
const chunks = [];
let finished;
const done = new Promise((resolve) => {
finished = resolve;
});
const res = {
statusCode: 0,
headers: {},
writableEnded: false,
body: '',
writeHead(status, headers) {
res.statusCode = status;
res.headers = headers || {};
},
write(chunk) {
chunks.push(Buffer.from(chunk));
return true;
},
end(chunk) {
if (chunk) chunks.push(Buffer.from(chunk));
res.writableEnded = true;
res.body = Buffer.concat(chunks).toString('utf8');
finished();
},
on() {},
once() {},
emit() {},
destroy() {},
};
return { res, done };
}
/**
* Mount the real provider, serve one camera, and answer its upstream from the
* supplied function.
*/
function mount(t, respond) {
const before = {
json: process.env.CCTV_SOURCES_JSON,
file: process.env.CCTV_SOURCES_FILE,
austin: process.env.CCTV_FORCE_AUSTIN,
};
process.env.CCTV_SOURCES_JSON = JSON.stringify([CAMERA]);
process.env.CCTV_SOURCES_FILE = 'absent-source-file.json';
process.env.CCTV_FORCE_AUSTIN = '0';
const nativeFetch = globalThis.fetch;
const requests = [];
globalThis.fetch = async (url, init = {}) => {
requests.push({
url: String(url),
headers: init.headers || {},
signal: init.signal || null,
});
return respond({ url: String(url), init });
};
t.after(() => {
globalThis.fetch = nativeFetch;
for (const [name, value] of [
['CCTV_SOURCES_JSON', before.json],
['CCTV_SOURCES_FILE', before.file],
['CCTV_FORCE_AUSTIN', before.austin],
]) {
if (value === undefined) delete process.env[name];
else process.env[name] = value;
}
});
let handler = null;
const plugin = cctvProxy();
plugin.configureServer({
middlewares: {
use: (_route, fn) => {
handler = fn;
},
},
});
assert.ok(handler, 'the CCTV provider must mount a middleware');
/** Drive one request through the mounted handler. */
const call = async (path, headers = {}) => {
const { res, done } = recordingResponse();
await handler({ url: path, headers, method: 'GET', on() {} }, res);
await done;
return res;
};
/** Read the camera's current health entry. */
const health = async () => {
const res = await call('/health');
return JSON.parse(res.body).cameras.find(({ id }) => id === CAMERA.id);
};
return { call, health, requests, handler: (req, res) => handler(req, res) };
}
test('a canonical range reaches the upstream and its 206 is passed through', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-range': 'bytes 0-1023/4096',
'content-length': '1024',
'accept-ranges': 'bytes',
},
body: 'partial',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
assert.equal(res.statusCode, 206);
assert.equal(res.headers['Content-Range'], 'bytes 0-1023/4096');
assert.equal(res.headers['Accept-Ranges'], 'bytes');
assert.equal(res.body, 'partial');
});
test('an unbounded seek is bounded before it is forwarded', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: { 'content-type': 'video/mp4' },
body: 'tail',
}),
);
await app.call(`/media/${CAMERA.id}`, { range: 'bytes=500-' });
assert.equal(app.requests.at(-1).headers.Range, `bytes=500-${500 + CAP - 1}`);
});
test('a rejected range is dropped and the request proceeds without one', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-10\r\nX-Injected: 1',
});
// Documented choice: drop the header rather than answer 400, so a client
// that sends something this proxy will not carry still gets its media.
assert.equal(app.requests.at(-1).headers.Range, undefined);
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'whole body');
});
test('a rejected range leaves the camera health report untouched', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-10\r\nX-Injected: 1',
});
const entry = await app.health();
assert.equal(entry.status, 'ok');
// The client's own string must never appear in a report other callers read.
assert.doesNotMatch(JSON.stringify(entry), /X-Injected/);
});
test('a multi-range request is served whole rather than as multipart', async (t) => {
const app = mount(t, ({ init }) =>
upstreamResponse({
status: init?.headers?.Range ? 206 : 200,
headers: {
'content-type': init?.headers?.Range
? 'multipart/byteranges; boundary=x'
: 'video/mp4',
},
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=0-1,2-3',
});
assert.equal(app.requests.at(-1).headers.Range, undefined);
assert.equal(res.statusCode, 200);
assert.equal(res.headers['Content-Type'], 'video/mp4');
});
test('an upstream 416 is reported as the upstream status, not as a proxy error', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 416,
headers: { 'content-range': 'bytes */1024' },
body: null,
}),
);
const res = await app.call(`/media/${CAMERA.id}`, {
range: `bytes=${2 ** 40}-${2 ** 40 + 10}`,
});
assert.equal(res.statusCode, 416);
assert.match(res.body, /416/);
const entry = await app.health();
assert.equal(entry.status, 'degraded');
assert.equal(entry.message, 'Upstream HTTP 416');
});
test('an upstream that ignores the range still serves its whole body', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4', 'content-length': '10' },
body: 'whole body',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(app.requests.at(-1).headers.Range, 'bytes=0-1023');
assert.equal(res.statusCode, 200);
assert.equal(res.body, 'whole body');
});
test('an oversized declared body is refused even when a bounded range asked for it', async (t) => {
const app = mount(t, () =>
upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-length': String(CAP + 1),
},
body: 'too big',
}),
);
const res = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-1023' });
assert.equal(res.statusCode, 502);
assert.match(res.body, /exceeds size cap/);
});
test('a client that disconnects mid-stream stops the upstream read', async (t) => {
// A live feed with no end of its own, counting what it is asked to produce.
let produced = 0;
let stopped = false;
const endlessBody = () =>
Readable.toWeb(
Readable.from(
(async function* () {
try {
for (;;) {
produced += 1;
yield Buffer.from('frame-chunk-');
await new Promise((resolve) => setTimeout(resolve, 10));
}
} finally {
stopped = true;
}
})(),
),
);
const app = mount(t, () => ({
ok: true,
status: 206,
headers: {
get: (name) =>
String(name).toLowerCase() === 'content-type' ? 'video/mp4' : null,
},
body: endlessBody(),
arrayBuffer: async () => Buffer.alloc(0),
}));
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const failures = [];
const onUncaught = (error) => failures.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
await new Promise((resolve, reject) => {
const request = http.get(
{
host: '127.0.0.1',
port,
path: `/media/${CAMERA.id}`.replace(/^/, '/api/cctv'),
headers: { Range: 'bytes=0-1023' },
},
(response) => {
response.once('data', () => {
// Walk away mid-body, the way a browser does when the operator
// switches camera.
request.destroy();
resolve();
});
response.on('error', () => resolve());
},
);
request.on('error', reject);
});
await new Promise((resolve) => setTimeout(resolve, 150));
const afterDisconnect = produced;
await new Promise((resolve) => setTimeout(resolve, 400));
// The point: the camera host is no longer being read from. Without the
// release this counter keeps climbing for as long as the upstream will feed.
assert.equal(
produced,
afterDisconnect,
`the upstream was still being read after the client left (${afterDisconnect} → ${produced} chunks)`,
);
assert.equal(stopped, true, 'the upstream body was never released');
assert.deepEqual(
failures.map((error) => error?.message),
[],
'a client walking away must not fault the server',
);
const entry = await app.health();
// The camera was serving fine; the client left. Nothing about that is a
// camera fault other viewers should see.
assert.equal(entry.status, 'ok');
});
test('a client that leaves before the headers arrive cancels the upstream request', async (t) => {
// The camera is slow, or dead: nothing comes back until the request is
// cancelled. Everything worth testing here happens before any header.
let aborted = false;
const app = mount(t, ({ init }) => {
return new Promise((_resolve, reject) => {
init.signal.addEventListener('abort', () => {
aborted = true;
reject(Object.assign(new Error('aborted'), { name: 'AbortError' }));
});
});
});
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
const failures = [];
const onUncaught = (error) => failures.push(error);
process.on('uncaughtException', onUncaught);
t.after(() => process.off('uncaughtException', onUncaught));
const request = http.get({
host: '127.0.0.1',
port,
path: `/api/cctv/media/${CAMERA.id}`,
headers: { Range: 'bytes=0-1023' },
});
request.on('error', () => {});
// Let the route reach its upstream request, then walk away before it answers.
await new Promise((resolve) => setTimeout(resolve, 150));
assert.equal(app.requests.length, 1, 'the upstream request went out');
assert.equal(aborted, false, 'nothing was cancelled while the client waited');
request.destroy();
await new Promise((resolve) => setTimeout(resolve, 250));
assert.equal(aborted, true, 'the upstream request outlived the client');
assert.deepEqual(
failures.map((error) => error?.message),
[],
'a client leaving early must not fault the server',
);
// The camera did nothing wrong; the viewer left.
const entry = await app.health();
assert.notEqual(entry?.status, 'degraded');
});
test('a folded CR/LF range never reaches the upstream through a real client', async (t) => {
// Node's own parser, not a hand-built headers object, so the reachability of
// this shape through an ordinary HTTP request is shown rather than assumed.
const app = mount(t, () =>
upstreamResponse({
status: 200,
headers: { 'content-type': 'video/mp4' },
body: 'whole body',
}),
);
const server = http.createServer((req, res) => {
req.url = req.url.replace('/api/cctv', '') || '/';
Promise.resolve(app.handler(req, res)).catch(() => {});
});
t.after(() => new Promise((resolve) => server.close(resolve)));
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
const { port } = server.address();
// A client can fold a value across a continuation line; Node's parser
// rejoins it, so what the route sees contains whitespace a header cannot
// carry back out.
const raw = await new Promise((resolve, reject) => {
const socket = net.connect(port, '127.0.0.1', () => {
socket.write(
`GET /api/cctv/media/${CAMERA.id} HTTP/1.1\r\n` +
'Host: 127.0.0.1\r\n' +
'Range: bytes=0-10\r\n\tX-Injected: 1\r\n' +
'Connection: close\r\n\r\n',
);
});
let text = '';
socket.on('data', (chunk) => {
text += chunk.toString('utf8');
});
socket.on('end', () => resolve(text));
socket.on('error', reject);
});
// Either Node's parser refuses the request outright, or it reaches the route
// and the route drops the value — but it must never be forwarded.
const reachedRoute = app.requests.length > 0;
assert.match(raw, /^HTTP\/1\.1 (200|400)/);
if (reachedRoute) {
assert.equal(
app.requests.at(-1).headers.Range,
undefined,
'nothing was forwarded',
);
assert.match(raw, /^HTTP\/1\.1 200/);
} else {
assert.match(raw, /^HTTP\/1\.1 400/);
}
console.log(
` [range] folded CR/LF request ${reachedRoute ? 'reached the route and was dropped' : 'was refused by the HTTP parser'}`,
);
const entry = await app.health();
assert.doesNotMatch(JSON.stringify(entry ?? {}), /X-Injected/);
});
test('two sequential seeks are served as two partial responses', async (t) => {
const app = mount(t, ({ init }) => {
const range = init?.headers?.Range || '';
const [, first, last] = /bytes=(\d+)-(\d+)/.exec(range) || [];
return upstreamResponse({
status: 206,
headers: {
'content-type': 'video/mp4',
'content-range': `bytes ${first}-${last}/4096`,
'content-length': String(Number(last) - Number(first) + 1),
'accept-ranges': 'bytes',
},
body: `chunk-${first}`,
});
});
const first = await app.call(`/media/${CAMERA.id}`, { range: 'bytes=0-99' });
const second = await app.call(`/media/${CAMERA.id}`, {
range: 'bytes=100-199',
});
assert.equal(app.requests[0].headers.Range, 'bytes=0-99');
assert.equal(app.requests[1].headers.Range, 'bytes=100-199');
assert.equal(first.statusCode, 206);
assert.equal(second.statusCode, 206);
assert.equal(first.headers['Content-Range'], 'bytes 0-99/4096');
assert.equal(second.headers['Content-Range'], 'bytes 100-199/4096');
assert.equal(first.body, 'chunk-0');
assert.equal(second.body, 'chunk-100');
});
+6
View File
@@ -163,6 +163,12 @@ export const DATA_CREDITS = [
'<a href="https://www.digitraffic.fi/en/" target="_blank" rel="noopener">digitraffic.fi</a>, ' +
'license CC BY 4.0',
},
{
key: 'calgary-cctv',
html:
'Traffic cameras (Calgary): contains information licensed under the ' +
'<a href="https://data.calgary.ca/stories/s/Open-Calgary-Terms-of-Use/u45n-7awa" target="_blank" rel="noopener">Open Government Licence – City of Calgary</a>',
},
{
key: 'gbfs',
html: 'Bikeshare availability: GBFS operator feeds (e.g. Austin BCycle)',
+104
View File
@@ -0,0 +1,104 @@
/**
* Who owns the pointer over the globe.
*
* Most layers bind their own `ScreenSpaceEventHandler` to the same canvas and
* select whatever is under a left click. That is right when nothing else is
* going on, and wrong the moment a tool needs the pointer for itself: a draw
* session placing a vertex on top of an aircraft must not also track the
* aircraft, and the next such tool must not have to be added to a list inside
* every layer.
*
* So there is one claim, held by at most one owner at a time:
*
* - A TOOL that needs the pointer calls `claimPointer('draw')` when it turns on
* and gets back a LEASE — an opaque token. It releases with
* `releasePointer(lease)` when it turns off. It does not consult anyone;
* holding the claim is what makes it the owner.
* - An AMBIENT SELECTION HANDLER — anything that picks an entity because the
* user clicked — early-returns while `isPointerFree()` is false. It never
* claims.
*
* Claims do not stack and are never stolen: while one owner holds the pointer,
* a second `claimPointer` returns null rather than displacing it — and so does
* a second claim under the SAME name, because two live instances of one tool
* (an old one mid-teardown, its replacement already running) are two owners,
* not one. The lease is what tells them apart: releasing takes the token that
* was issued, so a superseded instance calling `releasePointer` with its own
* stale lease frees nothing and its successor keeps the pointer.
*
* Ownership is page-scoped, like the layers and handlers it arbitrates.
*/
/** @typedef {{owner: string, id: number}} PointerLease */
/** @type {PointerLease|null} */
let currentLease = null;
let nextLeaseId = 1;
/**
* Take the pointer for `owner`.
* @param {string} owner Stable identifier for the claiming tool, e.g. 'draw'.
* @returns {PointerLease|null} The lease to release with, or null if the
* pointer is already held — including by another instance of the same tool.
*/
export function claimPointer(owner) {
const name = normalizeOwner(owner);
if (!name) return null;
if (currentLease !== null) return null;
currentLease = Object.freeze({ owner: name, id: nextLeaseId++ });
return currentLease;
}
/**
* Give the pointer back. Only the exact lease that took it can release it, so a
* superseded instance cannot free the claim its replacement now holds.
* @param {PointerLease|null} lease The token `claimPointer` returned.
* @returns {boolean} Whether this call released the claim.
*/
export function releasePointer(lease) {
if (!currentLease || !lease || lease.id !== currentLease.id) return false;
currentLease = null;
return true;
}
/** @returns {string|null} The current owner's name, or null when free. */
export function pointerOwner() {
return currentLease ? currentLease.owner : null;
}
/** @returns {boolean} True when no tool holds the pointer. */
export function isPointerFree() {
return currentLease === null;
}
/**
* @param {string} owner
* @returns {boolean} True when a tool of that name is holding the pointer.
*/
export function isPointerOwnedBy(owner) {
const name = normalizeOwner(owner);
return Boolean(name) && pointerOwner() === name;
}
/**
* @param {PointerLease|null} lease
* @returns {boolean} True when THIS lease is the live one.
*/
export function isLeaseCurrent(lease) {
return Boolean(currentLease && lease && lease.id === currentLease.id);
}
/**
* Drop any claim. For tests and for application teardown, which cannot rely on
* a half-disposed tool to release its own lease.
* @returns {string|null} The owner that was holding it, if any.
*/
export function resetPointerOwnership() {
const previous = pointerOwner();
currentLease = null;
return previous;
}
function normalizeOwner(owner) {
return typeof owner === 'string' && owner.trim() ? owner.trim() : null;
}
+176
View File
@@ -0,0 +1,176 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import {
claimPointer,
isLeaseCurrent,
isPointerFree,
isPointerOwnedBy,
pointerOwner,
releasePointer,
resetPointerOwnership,
} from './inputOwnership.js';
const ROOT = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'..',
'..',
);
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
test.beforeEach(() => resetPointerOwnership());
test('the pointer starts free and a claim hands back a lease', () => {
assert.equal(isPointerFree(), true);
assert.equal(pointerOwner(), null);
const lease = claimPointer('draw');
assert.ok(lease, 'a successful claim returns a lease, not a boolean');
assert.equal(isPointerFree(), false);
assert.equal(pointerOwner(), 'draw');
assert.equal(isPointerOwnedBy('draw'), true);
assert.equal(isPointerOwnedBy('directions'), false);
assert.equal(isLeaseCurrent(lease), true);
});
test('a claim is never stolen — not even by the same tool name', () => {
const first = claimPointer('draw');
assert.ok(first);
assert.equal(
claimPointer('directions'),
null,
'another tool must not displace it',
);
assert.equal(
claimPointer('draw'),
null,
'a SECOND instance of the same tool is a second owner, not a no-op',
);
assert.equal(pointerOwner(), 'draw');
assert.equal(isLeaseCurrent(first), true);
});
test('a replaced instance cannot free its successor’s claim', () => {
// The bug this rule exists for. An old Draw instance is torn down while a new
// one is already running; the old teardown calls releasePointer. Under
// release-by-name that freed the LIVE claim and every layer started selecting
// through the new instance's vertices.
const stale = claimPointer('draw');
assert.ok(releasePointer(stale), 'the old instance gives the pointer back');
const live = claimPointer('draw');
assert.ok(live, 'the replacement takes it');
assert.notEqual(live.id, stale.id, 'a fresh claim is a fresh lease');
assert.equal(releasePointer(stale), false, 'the stale lease frees nothing');
assert.equal(pointerOwner(), 'draw', 'the replacement still holds it');
assert.equal(isLeaseCurrent(stale), false);
assert.equal(isLeaseCurrent(live), true);
assert.equal(releasePointer(live), true);
assert.equal(isPointerFree(), true);
});
test('release ignores anything that is not the live lease', () => {
const lease = claimPointer('draw');
for (const bad of [
null,
undefined,
'draw',
{},
{ owner: 'draw', id: lease.id + 99 },
]) {
assert.equal(releasePointer(bad), false, JSON.stringify(bad));
assert.equal(pointerOwner(), 'draw');
}
assert.equal(releasePointer(lease), true);
assert.equal(releasePointer(lease), false, 'releasing twice changes nothing');
});
test('a claim needs a real owner id', () => {
for (const bad of ['', ' ', null, undefined, 7, {}]) {
assert.equal(claimPointer(bad), null, JSON.stringify(bad));
assert.equal(isPointerFree(), true);
}
const lease = claimPointer(' draw ');
assert.ok(lease, 'ids are trimmed, not rejected for padding');
assert.equal(pointerOwner(), 'draw');
assert.equal(releasePointer(lease), true);
});
test('reset reports who was holding it, for teardown', () => {
assert.equal(resetPointerOwnership(), null);
const lease = claimPointer('draw');
assert.equal(resetPointerOwnership(), 'draw');
assert.equal(isPointerFree(), true);
assert.equal(isLeaseCurrent(lease), false);
});
test('every scene click handler consults ownership before it picks', () => {
// This is the whole point of a shared contract: a new tool must not have to
// find and edit each of these. If a layer grows a click handler it has to
// appear here too.
const guarded = [
['src/data/trackingClickGesture.js', 'onClick(click, gesture);'],
['src/data/localGeojsonCore.js', 'viewer.scene.pick(click.position)'],
['src/data/cctvGizmo.js', 'pickGizmoPart(event.position)'],
['src/layers/alpr/presentation.js', 'viewer.scene.pick(click.position)'],
['src/layers/bikeshare/selection.js', 'viewer.scene.pick(click.position)'],
['src/layers/firms/selection.js', 'scene.pick(click.position)'],
[
'src/layers/installations/selection.js',
'viewer.scene.pick(click.position)',
],
['src/layers/launches/lifecycle.js', 'drillPick(movement.position'],
['src/layers/radio/interaction.js', 'pickedRadioStationAt(click.position)'],
[
'src/layers/satellites/interaction.js',
'viewer.scene.pick(click.position)',
],
[
'src/layers/submarineCables/interaction.js',
'viewer.scene.pick(click.position)',
],
['src/layers/vessels/selection.js', 'viewer.scene.pick(click.position)'],
];
for (const [file, firstPick] of guarded) {
const source = read(file);
assert.match(
source,
/import \{ isPointerFree \} from '[^']*inputOwnership\.js';/,
`${file} must consult the shared pointer claim`,
);
const guardAt = source.indexOf('if (!isPointerFree()) return;');
const pickAt = source.indexOf(firstPick);
assert.ok(guardAt >= 0, `${file} is missing the guard`);
assert.ok(
pickAt >= 0,
`${file}: could not find its pick call (${firstPick})`,
);
assert.ok(
guardAt < pickAt,
`${file} must yield before it picks, not after`,
);
}
});
test('ambient selection handlers never claim the pointer themselves', () => {
// Claiming from a selection handler would deadlock every other layer the
// moment a user clicked anything. Only tools claim.
for (const file of [
'src/data/trackingClickGesture.js',
'src/data/localGeojsonCore.js',
'src/data/cctvGizmo.js',
'src/layers/vessels/selection.js',
'src/layers/satellites/interaction.js',
]) {
assert.doesNotMatch(
read(file),
/claimPointer\(/,
`${file} must not claim the pointer`,
);
}
});
+3
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
import {
selectInfraLod,
applyInfraEvictionGrace,
@@ -768,6 +769,8 @@ export function createLocalGeoJsonLayer(
viewer.scene.canvas,
);
_clickHandler.setInputAction((click) => {
// A tool owns the pointer (src/data/inputOwnership.js).
if (!isPointerFree()) return;
if (!_enabled) return;
const picked = viewer.scene.pick(click.position);
File diff suppressed because it is too large. Load diff
+4
View File
@@ -1,4 +1,5 @@
import * as Cesium from 'cesium';
import { isPointerFree } from './inputOwnership.js';
export const MAX_TRACKING_CLICK_TRAVEL_PX = 6;
export const MAX_TRACKING_CLICK_DURATION_MS = 400;
@@ -95,6 +96,9 @@ export function bindTrackingClickGesture(handler, onClick, options = {}) {
if (pressActive) finishPress(click?.position);
const gesture = completedGesture || { travelPx: 0, durationMs: 0 };
completedGesture = null;
// A tool owns the pointer: a draw vertex placed over an aircraft is a
// vertex, not a track request (src/data/inputOwnership.js).
if (!isPointerFree()) return;
onClick(click, gesture);
}, eventTypes.LEFT_CLICK);
}