mirror of
https://github.com/bilawalsidhu/gods-eye-view.git
synced 2026-11-04 10:24:02 +00:00
Merge branch 'main' into fix/cctv-missing-numbers
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
ccd38c0c44
464 files changed
+35874
-4695
No files matched your search
@@ -4,6 +4,8 @@ const STATUS_URL = 'https://www.nhc.noaa.gov/CurrentStorms.json';
|
||||
const GIS =
|
||||
'https://mapservices.weather.noaa.gov/tropical/rest/services/tropical/NHC_tropical_weather_summary/MapServer';
|
||||
const HOUR = 3600_000;
|
||||
// Data, not UI copy: src/layers/cyclones/index.js keeps the same English in
|
||||
// msg() and translates it when shown (likewise 'Cyclone data unavailable').
|
||||
const COVERAGE =
|
||||
'Atlantic and eastern/central North Pacific; not worldwide cyclone coverage.';
|
||||
const LAYERS = [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { readResponseTextCapped } from './common/http.js';
|
||||
import { makeRateLimiter, clientKey } from './common/rate-limit.js';
|
||||
import {
|
||||
isAllowedGbfsHost,
|
||||
isAllowedGbfsPath,
|
||||
@@ -17,6 +18,19 @@ export const GBFS_CLIENT_IDENTIFIER = 'gods-eye-view';
|
||||
|
||||
export const GBFS_MAX_BODY_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
|
||||
/**
|
||||
* Requests/minute/IP admitted by the proxy.
|
||||
*
|
||||
* The bikeshare layer's own demand is small and known: `src/layers/bikeshare/
|
||||
* registry.js` catalogues 15 systems, station information is cached per city
|
||||
* after its first fetch, and station status refreshes once per city every
|
||||
* `STATUS_POLL_MS` (60 s) with in-flight requests deduplicated. Even with every
|
||||
* catalogued system in camera range at once that is about 15 requests a minute,
|
||||
* so 120 is eight times a maximal real session while still capping a caller
|
||||
* using this proxy as an open relay against a third-party GBFS host.
|
||||
*/
|
||||
export const GBFS_RATE_PER_MIN = 120;
|
||||
|
||||
function gbfsRedirectHost(location, requestUrl) {
|
||||
if (!location) return '';
|
||||
try {
|
||||
@@ -121,7 +135,12 @@ export async function fetchGbfsUpstream(
|
||||
*
|
||||
* @returns {import('vite').Plugin}
|
||||
*/
|
||||
export function gbfsProxy() {
|
||||
export function gbfsProxy({ ratePerMinute = GBFS_RATE_PER_MIN } = {}) {
|
||||
const allow = makeRateLimiter({
|
||||
windowMs: 60_000,
|
||||
max: ratePerMinute,
|
||||
globalMax: ratePerMinute * 3,
|
||||
});
|
||||
const installMiddleware = (server) => {
|
||||
server.middlewares.use('/api/gbfs', async (req, res) => {
|
||||
try {
|
||||
@@ -134,6 +153,19 @@ export function gbfsProxy() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Frequency, checked before the target is even parsed: the allowlist
|
||||
// already says WHERE this proxy may go, nothing said HOW OFTEN. A
|
||||
// refused request performs no outbound fetch and starts no timer.
|
||||
if (!allow(clientKey(req))) {
|
||||
res.writeHead(429, {
|
||||
'Content-Type': 'application/json',
|
||||
'Cache-Control': 'no-store',
|
||||
'Retry-After': '10',
|
||||
});
|
||||
res.end(JSON.stringify({ error: 'Rate limit exceeded' }));
|
||||
return;
|
||||
}
|
||||
|
||||
const url = new URL(req.url || '/', 'http://localhost');
|
||||
const encodedTarget = url.pathname.replace(/^\/+/, '');
|
||||
if (!encodedTarget) {
|
||||
|
||||
@@ -14,7 +14,7 @@ import { simplifyOverpassPayloadBody } from './geometry.js';
|
||||
* Checks for known rate-limit phrases in the body text regardless of
|
||||
* HTTP status code, since some mirrors return 200 with an error payload.
|
||||
*
|
||||
* @param {string} bodyText - Upstream response body.
|
||||
* @param {string} bodyText - The remark of a data reply, or a non-data body.
|
||||
* @returns {boolean} True if the body looks rate-limited.
|
||||
*/
|
||||
function overpassLooksRateLimited(bodyText) {
|
||||
@@ -41,6 +41,14 @@ function overpassLooksRuntimeError(bodyText) {
|
||||
);
|
||||
}
|
||||
|
||||
function parseJson(text) {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* True only for an upstream response that is actually Overpass data.
|
||||
*
|
||||
@@ -151,15 +159,22 @@ async function fetchOverpassPayload(
|
||||
signal: controller.signal,
|
||||
});
|
||||
const responseBody = await readBody(upstream, maxResponseBytes);
|
||||
const parsed = parseJson(responseBody);
|
||||
// An Overpass data reply reports failure in its remark; its element
|
||||
// tags are data and can contain any phrase. Any other body (an error
|
||||
// page, or JSON without elements) is matched whole.
|
||||
const failureText = Array.isArray(parsed?.elements)
|
||||
? String(parsed.remark || '')
|
||||
: responseBody;
|
||||
const rateLimited =
|
||||
upstream.status === 429 ||
|
||||
upstream.status === 406 ||
|
||||
overpassLooksRateLimited(responseBody);
|
||||
overpassLooksRateLimited(failureText);
|
||||
if (
|
||||
rateLimited ||
|
||||
upstream.status < 200 ||
|
||||
upstream.status >= 300 ||
|
||||
overpassLooksRuntimeError(responseBody)
|
||||
overpassLooksRuntimeError(failureText)
|
||||
) {
|
||||
const failures = (previous?.failures || 0) + 1;
|
||||
const delay = retryDelay(
|
||||
@@ -178,7 +193,6 @@ async function fetchOverpassPayload(
|
||||
failure = refusal(status, delay);
|
||||
continue;
|
||||
}
|
||||
const parsed = JSON.parse(responseBody);
|
||||
if (!Array.isArray(parsed?.elements) || parsed.remark)
|
||||
throw new Error('Malformed Overpass response');
|
||||
cooldowns.delete(endpoint);
|
||||
|
||||
@@ -179,3 +179,26 @@ test('snapshot capacity pressure preserves members not yet visited and discloses
|
||||
assert.equal(recreated.fixes.length, 1);
|
||||
assert.equal(recreated.truncated, true);
|
||||
});
|
||||
test('a feed fleet-number mode outranks its route mode in the epoch context', (t) => {
|
||||
const h = history(t);
|
||||
const ttc = {
|
||||
id: 'ttc',
|
||||
historyRetention: true,
|
||||
defaultMode: 'bus',
|
||||
routeMode: (route) => (/^5\d\d$/.test(route ?? '') ? 'tram' : 'bus'),
|
||||
vehicleMode: (id) =>
|
||||
/^4[4-6]\d\d$/.test(id) ? 'tram' : /^\d{4}$/.test(id) ? 'bus' : 'unknown',
|
||||
};
|
||||
h.ingest(
|
||||
ttc,
|
||||
[
|
||||
row(100000, '8515', { routeId: '506' }),
|
||||
row(100000, '4507', { routeId: null }),
|
||||
row(100000, 'x1', { routeId: '501' }),
|
||||
],
|
||||
100000,
|
||||
);
|
||||
assert.equal(h.get('ttc', '8515', 100000).epochs[0].mode, 'bus');
|
||||
assert.equal(h.get('ttc', '4507', 100000).epochs[0].mode, 'tram');
|
||||
assert.equal(h.get('ttc', 'x1', 100000).epochs[0].mode, 'tram');
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import { fetchIfsWind } from './wind/ifs.js';
|
||||
import { decodeWindGribMessage } from './wind/decode.js';
|
||||
import { weatherScalarMetadata, weatherScalarError } from './wind/grid.js';
|
||||
|
||||
// Data, not UI copy: the client (src/layers/wind/index.js) marks this exact
|
||||
// English with msg() and translates it when shown, so keep the two in sync.
|
||||
const UPSTREAM_UNAVAILABLE = 'Wind upstream unavailable';
|
||||
|
||||
/** Serve bounded, single-flight, per-model/overlay forecast snapshots from fixed providers. */
|
||||
export function windProxy({
|
||||
fetchImpl = fetch,
|
||||
@@ -25,7 +29,7 @@ export function windProxy({
|
||||
...(overlay === 'none' ? {} : { overlay }),
|
||||
unavailable: true,
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
reason: UPSTREAM_UNAVAILABLE,
|
||||
},
|
||||
});
|
||||
const sendJson = (res, value, status = 200) => {
|
||||
@@ -139,7 +143,7 @@ export function windProxy({
|
||||
old.manifest = {
|
||||
...old.manifest,
|
||||
stale: true,
|
||||
reason: 'Wind upstream unavailable',
|
||||
reason: UPSTREAM_UNAVAILABLE,
|
||||
};
|
||||
return old;
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user