@signalk/charts-plugin 3.3.0 → 3.5.0-beta
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.mocharc.cjs +5 -0
- package/.prettierignore +7 -0
- package/README.md +45 -16
- package/package.json +27 -24
- package/plugin/chartDownloader.d.ts +4 -7
- package/plugin/chartDownloader.d.ts.map +1 -0
- package/plugin/chartDownloader.js +88 -51
- package/plugin/chartDownloader.js.map +1 -0
- package/plugin/charts.d.ts +5 -1
- package/plugin/charts.d.ts.map +1 -0
- package/plugin/charts.js +152 -67
- package/plugin/charts.js.map +1 -0
- package/plugin/constants.d.ts +1 -0
- package/plugin/constants.d.ts.map +1 -0
- package/plugin/constants.js +1 -0
- package/plugin/constants.js.map +1 -0
- package/plugin/index.d.ts +13 -1
- package/plugin/index.d.ts.map +1 -0
- package/plugin/index.js +375 -102
- package/plugin/index.js.map +1 -0
- package/plugin/projection.d.ts +7 -0
- package/plugin/projection.d.ts.map +1 -0
- package/plugin/projection.js +42 -0
- package/plugin/projection.js.map +1 -0
- package/plugin/types.d.ts +1 -0
- package/plugin/types.d.ts.map +1 -0
- package/plugin/types.js +1 -0
- package/plugin/types.js.map +1 -0
- package/public/index.html +106 -83
- package/public/index.js +274 -267
- package/public/style.css +65 -63
- package/stryker.conf.json +26 -0
- package/tsconfig.build.json +17 -0
package/plugin/index.js
CHANGED
|
@@ -15,18 +15,26 @@ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (
|
|
|
15
15
|
}) : function(o, v) {
|
|
16
16
|
o["default"] = v;
|
|
17
17
|
});
|
|
18
|
-
var __importStar = (this && this.__importStar) || function (
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
};
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
25
35
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
26
36
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
27
37
|
};
|
|
28
|
-
Object.defineProperty(exports, "__esModule", { value: true });
|
|
29
|
-
const bluebird = __importStar(require("bluebird"));
|
|
30
38
|
const path_1 = __importDefault(require("path"));
|
|
31
39
|
const fs_1 = __importDefault(require("fs"));
|
|
32
40
|
const _ = __importStar(require("lodash"));
|
|
@@ -36,9 +44,19 @@ const chartDownloader_1 = require("./chartDownloader");
|
|
|
36
44
|
const MIN_ZOOM = 1;
|
|
37
45
|
const MAX_ZOOM = 24;
|
|
38
46
|
const chartTilesPath = '/signalk/chart-tiles';
|
|
39
|
-
|
|
47
|
+
// Debounce window used to collapse FSWatcher bursts during rename / atomic-save
|
|
48
|
+
// sequences into one reload. Overridable via env var so tests don't have to
|
|
49
|
+
// wait the full 5s on every watcher assertion.
|
|
50
|
+
const RELOAD_DEBOUNCE_MS = Number(process.env.SK_CHARTS_RELOAD_DEBOUNCE_MS) || 5000;
|
|
51
|
+
const createPlugin = (app) => {
|
|
40
52
|
let chartProviders = {};
|
|
53
|
+
// Pre-computed per-version views of chartProviders, rebuilt on every reload.
|
|
54
|
+
// The HTTP handlers serve directly from here so tile-list requests don't pay
|
|
55
|
+
// a deep clone per provider on the hot path.
|
|
56
|
+
let sanitizedV1 = {};
|
|
57
|
+
let sanitizedV2 = {};
|
|
41
58
|
let pluginStarted = false;
|
|
59
|
+
let providerRegistered = false;
|
|
42
60
|
let props = {
|
|
43
61
|
chartPaths: [],
|
|
44
62
|
cachePath: '',
|
|
@@ -48,15 +66,32 @@ module.exports = (app) => {
|
|
|
48
66
|
const configBasePath = app.config.configPath;
|
|
49
67
|
const defaultChartsPath = path_1.default.join(configBasePath, '/charts');
|
|
50
68
|
const serverMajorVersion = app.config.version
|
|
51
|
-
? parseInt(app.config.version.split('.')[0])
|
|
52
|
-
:
|
|
53
|
-
ensureDirectoryExists(defaultChartsPath);
|
|
69
|
+
? parseInt(app.config.version.split('.')[0] ?? '0')
|
|
70
|
+
: 1;
|
|
54
71
|
let cachePath = defaultChartsPath;
|
|
72
|
+
// Chart-folder watcher state, plugin-scoped so stop()/start() cycles reset cleanly.
|
|
73
|
+
// activeChartPaths / activeOnlineProviders hold the last-known config so a
|
|
74
|
+
// watcher-triggered reload can reuse it without re-running doStartup.
|
|
75
|
+
const watchers = [];
|
|
76
|
+
let reloadTimer;
|
|
77
|
+
let activeChartPaths = [];
|
|
78
|
+
let activeOnlineProviders = {};
|
|
79
|
+
// Check Node version for schema
|
|
80
|
+
const nodeVersion = process.versions.node;
|
|
81
|
+
const nodeMajorVersion = parseInt(nodeVersion.split('.')[0] ?? '0');
|
|
55
82
|
// ******** REQUIRED PLUGIN DEFINITION *******
|
|
56
83
|
const CONFIG_SCHEMA = {
|
|
57
84
|
title: 'Signal K Charts',
|
|
58
85
|
type: 'object',
|
|
59
86
|
properties: {
|
|
87
|
+
...(nodeMajorVersion < 22 && {
|
|
88
|
+
versionWarning: {
|
|
89
|
+
type: 'string',
|
|
90
|
+
title: 'REQUIRES NODE VERSION >=22',
|
|
91
|
+
description: 'Starting with version 4 this plugin will not work with Node versions older than 22. You can install an older plugin version from the App store.',
|
|
92
|
+
default: ''
|
|
93
|
+
}
|
|
94
|
+
}),
|
|
60
95
|
chartPaths: {
|
|
61
96
|
type: 'array',
|
|
62
97
|
title: 'Chart paths',
|
|
@@ -175,52 +210,220 @@ module.exports = (app) => {
|
|
|
175
210
|
return doStartup(settings); // return required for tests
|
|
176
211
|
},
|
|
177
212
|
stop: () => {
|
|
213
|
+
stopWatchers();
|
|
214
|
+
// Cancel any running seeding jobs so a disabled plugin doesn't keep
|
|
215
|
+
// pulling tiles from remote providers in the background.
|
|
216
|
+
chartDownloader_1.ChartSeedingManager.cancelAll();
|
|
217
|
+
// Close open SQLite connections so the user can move or delete chart
|
|
218
|
+
// files while the plugin is stopped (Windows blocks deletion on open
|
|
219
|
+
// handles). Restart re-opens fresh via findCharts.
|
|
220
|
+
for (const p of Object.values(chartProviders)) {
|
|
221
|
+
if (p?._mbtilesHandle)
|
|
222
|
+
closeMbtilesHandle(p._mbtilesHandle);
|
|
223
|
+
}
|
|
224
|
+
chartProviders = {};
|
|
225
|
+
sanitizedV1 = {};
|
|
226
|
+
sanitizedV2 = {};
|
|
178
227
|
app.setPluginStatus('stopped');
|
|
179
228
|
}
|
|
180
229
|
};
|
|
181
|
-
const doStartup = (config) => {
|
|
230
|
+
const doStartup = async (config) => {
|
|
231
|
+
// Check Node version
|
|
232
|
+
const nodeVersion = process.versions.node;
|
|
233
|
+
const majorVersion = parseInt(nodeVersion.split('.')[0] ?? '0');
|
|
234
|
+
if (majorVersion < 22) {
|
|
235
|
+
const errorMsg = `Node version ${nodeVersion} is not supported. This plugin requires Node version 22 or higher. Please upgrade Node or install an older plugin version.`;
|
|
236
|
+
app.setPluginError(errorMsg);
|
|
237
|
+
app.debug(errorMsg);
|
|
238
|
+
return Promise.reject(new Error(errorMsg));
|
|
239
|
+
}
|
|
182
240
|
app.debug(`** loaded config: ${config}`);
|
|
183
241
|
props = { ...config };
|
|
184
242
|
urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${'getExternalPort' in app.config ? app.config.getExternalPort() : 3000}`;
|
|
185
243
|
app.debug(`**urlBase** ${urlBase}`);
|
|
186
|
-
|
|
244
|
+
activeChartPaths = !props.chartPaths?.length
|
|
187
245
|
? [defaultChartsPath]
|
|
188
246
|
: resolveUniqueChartPaths(props.chartPaths, configBasePath);
|
|
189
247
|
cachePath = props.cachePath || defaultChartsPath;
|
|
190
|
-
|
|
191
|
-
|
|
248
|
+
// Both paths commonly coincide on a fresh install; ensure they exist once
|
|
249
|
+
// here rather than at plugin construction time, which kept us off the
|
|
250
|
+
// sync-fs path at module load.
|
|
251
|
+
await ensureDirectoryExists(defaultChartsPath);
|
|
252
|
+
if (cachePath !== defaultChartsPath) {
|
|
253
|
+
await ensureDirectoryExists(cachePath);
|
|
254
|
+
}
|
|
255
|
+
activeOnlineProviders = {};
|
|
256
|
+
for (const data of props.onlineChartProviders ?? []) {
|
|
192
257
|
const provider = convertOnlineProviderConfig(data);
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
258
|
+
if (activeOnlineProviders[provider.identifier]) {
|
|
259
|
+
app.debug(`Duplicate online provider identifier "${provider.identifier}" ` +
|
|
260
|
+
`(from name "${data.name}"); the later entry wins. ` +
|
|
261
|
+
`Rename one of the providers to avoid the collision.`);
|
|
262
|
+
}
|
|
263
|
+
activeOnlineProviders[provider.identifier] = provider;
|
|
264
|
+
}
|
|
265
|
+
app.debug(`Start charts plugin. Chart paths: ${activeChartPaths.join(', ')}, online charts: ${Object.keys(activeOnlineProviders).length}`);
|
|
266
|
+
// Routes and the v2 provider registration are idempotent — Signal K can
|
|
267
|
+
// call start() again after a config change, but re-registering would
|
|
268
|
+
// either throw or duplicate the handler.
|
|
269
|
+
if (!pluginStarted)
|
|
270
|
+
registerRoutes();
|
|
199
271
|
pluginStarted = true;
|
|
200
|
-
|
|
201
|
-
if (serverMajorVersion === 2) {
|
|
272
|
+
if (serverMajorVersion === 2 && !providerRegistered) {
|
|
202
273
|
app.debug('** Registering v2 API paths **');
|
|
203
274
|
registerAsProvider();
|
|
275
|
+
providerRegistered = true;
|
|
204
276
|
}
|
|
205
277
|
app.setPluginStatus('Started');
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
278
|
+
startWatchers();
|
|
279
|
+
return loadChartProviders();
|
|
280
|
+
};
|
|
281
|
+
const loadChartProviders = async () => {
|
|
282
|
+
// Scan configured chart paths in parallel. findCharts already bounds its
|
|
283
|
+
// own per-file concurrency internally, so kicking off multiple roots at
|
|
284
|
+
// once just overlaps their directory reads.
|
|
285
|
+
let results;
|
|
286
|
+
try {
|
|
287
|
+
results = await Promise.all(activeChartPaths.map((chartPath) => (0, charts_1.findCharts)(chartPath)));
|
|
288
|
+
}
|
|
289
|
+
catch (e) {
|
|
290
|
+
// Keep the last-good chartProviders instead of wiping everything - a
|
|
291
|
+
// transient read error (file locked during copy, EBUSY, etc.) shouldn't
|
|
292
|
+
// blank the service out until the next filesystem event.
|
|
215
293
|
console.error(`Error loading chart providers`, e.message);
|
|
216
|
-
chartProviders = {};
|
|
217
294
|
app.setPluginError(`Error loading chart providers`);
|
|
218
|
-
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
// Identifier is the source of uniqueness: a deep-merge here would cost
|
|
298
|
+
// O(N²) property copies for identical keys while adding nothing over a
|
|
299
|
+
// plain shallow assignment.
|
|
300
|
+
const newCharts = {};
|
|
301
|
+
for (const r of results) {
|
|
302
|
+
if (!r)
|
|
303
|
+
continue;
|
|
304
|
+
for (const [id, chart] of Object.entries(r)) {
|
|
305
|
+
if (newCharts[id]) {
|
|
306
|
+
app.debug(`Duplicate chart identifier "${id}" from multiple chart paths; ` +
|
|
307
|
+
`the later one wins.`);
|
|
308
|
+
}
|
|
309
|
+
newCharts[id] = chart;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
app.debug(`Chart plugin: Found ${Object.keys(newCharts).length} charts from ${activeChartPaths.join(', ')}.`);
|
|
313
|
+
reconcileMbtilesHandles(chartProviders);
|
|
314
|
+
// Shallow assign is enough: newCharts and activeOnlineProviders both
|
|
315
|
+
// have unique ids per entry; the values themselves are used by reference.
|
|
316
|
+
chartProviders = { ...newCharts };
|
|
317
|
+
for (const [id, provider] of Object.entries(activeOnlineProviders)) {
|
|
318
|
+
if (chartProviders[id]) {
|
|
319
|
+
app.debug(`Online provider identifier "${id}" collides with a local chart; ` +
|
|
320
|
+
`the online provider wins.`);
|
|
321
|
+
}
|
|
322
|
+
chartProviders[id] = provider;
|
|
323
|
+
}
|
|
324
|
+
buildSanitizedCache();
|
|
325
|
+
app.setPluginStatus(`Started - ${Object.keys(chartProviders).length} chart(s) loaded`);
|
|
326
|
+
};
|
|
327
|
+
// Rebuilds the per-version sanitized views. Called once per reload so the
|
|
328
|
+
// HTTP handlers can hand out pre-built dictionaries instead of deep-cloning
|
|
329
|
+
// every provider on every metadata request.
|
|
330
|
+
const buildSanitizedCache = () => {
|
|
331
|
+
sanitizedV1 = {};
|
|
332
|
+
sanitizedV2 = {};
|
|
333
|
+
for (const [id, provider] of Object.entries(chartProviders)) {
|
|
334
|
+
sanitizedV1[id] = sanitizeProvider(provider, 1);
|
|
335
|
+
sanitizedV2[id] = sanitizeProvider(provider, 2);
|
|
336
|
+
}
|
|
337
|
+
};
|
|
338
|
+
// Close every old MBTiles handle after a reload. findCharts opened fresh
|
|
339
|
+
// handles for every file that still exists, so the NEW set reflects current
|
|
340
|
+
// content — including files that were replaced in place (same filename,
|
|
341
|
+
// new content). Reusing the old handle in that case would serve stale tiles
|
|
342
|
+
// from SQLite's cached pages. Close is delayed so an in-flight tile request
|
|
343
|
+
// that captured a reference has time to complete before the handle goes
|
|
344
|
+
// away; 1s is well above realistic tile-serve latency.
|
|
345
|
+
const MBTILES_CLOSE_DELAY_MS = 1000;
|
|
346
|
+
const reconcileMbtilesHandles = (oldSet) => {
|
|
347
|
+
for (const old of Object.values(oldSet)) {
|
|
348
|
+
if (old?._mbtilesHandle) {
|
|
349
|
+
const handle = old._mbtilesHandle;
|
|
350
|
+
setTimeout(() => closeMbtilesHandle(handle), MBTILES_CLOSE_DELAY_MS);
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
};
|
|
354
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
355
|
+
const closeMbtilesHandle = (handle) => {
|
|
356
|
+
if (typeof handle?.close !== 'function')
|
|
357
|
+
return;
|
|
358
|
+
try {
|
|
359
|
+
handle.close((err) => {
|
|
360
|
+
if (err)
|
|
361
|
+
app.debug(`MBTiles close error: ${err.message}`);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
catch (err) {
|
|
365
|
+
app.debug(`MBTiles close threw: ${err.message}`);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
// Chart folders are watched so new/renamed/deleted files become visible
|
|
369
|
+
// without a plugin restart. FSWatcher bursts events during rename and
|
|
370
|
+
// atomic-save sequences; the debounce collapses a burst into one reload.
|
|
371
|
+
const scheduleReload = () => {
|
|
372
|
+
if (reloadTimer)
|
|
373
|
+
clearTimeout(reloadTimer);
|
|
374
|
+
reloadTimer = setTimeout(() => {
|
|
375
|
+
reloadTimer = undefined;
|
|
376
|
+
app.debug('Reloading charts after filesystem change');
|
|
377
|
+
loadChartProviders();
|
|
378
|
+
}, RELOAD_DEBOUNCE_MS);
|
|
379
|
+
};
|
|
380
|
+
const startWatchers = () => {
|
|
381
|
+
stopWatchers();
|
|
382
|
+
for (const p of activeChartPaths) {
|
|
383
|
+
watchers.push(...createWatchers(p));
|
|
384
|
+
}
|
|
385
|
+
};
|
|
386
|
+
// recursive:true is the common path on macOS / Windows / Linux (Node 22+).
|
|
387
|
+
// If the platform or filesystem doesn't support it (some network mounts,
|
|
388
|
+
// older Linux), fall back to a non-recursive watch so at least top-level
|
|
389
|
+
// changes are picked up.
|
|
390
|
+
const createWatchers = (p) => {
|
|
391
|
+
const handlers = [];
|
|
392
|
+
try {
|
|
393
|
+
const watcher = fs_1.default.watch(p, { encoding: 'utf8', recursive: true }, () => scheduleReload());
|
|
394
|
+
watcher.on('error', (err) => app.debug(`Watcher error on ${p}: ${err.message}`));
|
|
395
|
+
handlers.push(watcher);
|
|
396
|
+
app.debug(`Watching chart folder recursively: ${p}`);
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
app.debug(`Recursive watch unavailable for ${p} (${err.message}); falling back to top-level watch`);
|
|
400
|
+
try {
|
|
401
|
+
const watcher = fs_1.default.watch(p, { encoding: 'utf8' }, () => scheduleReload());
|
|
402
|
+
watcher.on('error', (e) => app.debug(`Watcher error on ${p}: ${e.message}`));
|
|
403
|
+
handlers.push(watcher);
|
|
404
|
+
}
|
|
405
|
+
catch (e) {
|
|
406
|
+
app.debug(`Unable to watch ${p}: ${e.message}`);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return handlers;
|
|
410
|
+
};
|
|
411
|
+
const stopWatchers = () => {
|
|
412
|
+
if (reloadTimer) {
|
|
413
|
+
clearTimeout(reloadTimer);
|
|
414
|
+
reloadTimer = undefined;
|
|
415
|
+
}
|
|
416
|
+
while (watchers.length) {
|
|
417
|
+
watchers.pop()?.close();
|
|
418
|
+
}
|
|
219
419
|
};
|
|
220
420
|
const registerRoutes = () => {
|
|
221
421
|
app.debug('** Registering API paths **');
|
|
222
422
|
app.get(`${chartTilesPath}/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, async (req, res) => {
|
|
223
423
|
const { identifier, z, x, y } = req.params;
|
|
424
|
+
if (!identifier || !z || !x || !y) {
|
|
425
|
+
return res.sendStatus(404);
|
|
426
|
+
}
|
|
224
427
|
const ix = parseInt(x);
|
|
225
428
|
const iy = parseInt(y);
|
|
226
429
|
const iz = parseInt(z);
|
|
@@ -245,21 +448,39 @@ module.exports = (app) => {
|
|
|
245
448
|
});
|
|
246
449
|
app.post(`${chartTilesPath}/cache/:identifier`, async (req, res) => {
|
|
247
450
|
const { identifier } = req.params;
|
|
451
|
+
if (!identifier) {
|
|
452
|
+
return res.sendStatus(404);
|
|
453
|
+
}
|
|
248
454
|
const { regionGUID, tile, bbox, maxZoom } = req.body;
|
|
249
455
|
const provider = chartProviders[identifier];
|
|
250
456
|
if (!provider) {
|
|
251
|
-
return res.
|
|
457
|
+
return res.status(404).send('Provider not found');
|
|
252
458
|
}
|
|
253
459
|
if (!maxZoom) {
|
|
254
460
|
return res.status(400).send('maxZoom parameter is required');
|
|
255
461
|
}
|
|
462
|
+
if (!regionGUID && !bbox && !tile) {
|
|
463
|
+
return res
|
|
464
|
+
.status(400)
|
|
465
|
+
.send('Request must include regionGUID, bbox, or tile');
|
|
466
|
+
}
|
|
256
467
|
const maxZoomParsed = parseInt(maxZoom);
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
468
|
+
try {
|
|
469
|
+
const job = await chartDownloader_1.ChartSeedingManager.createJob(app.resourcesApi, cachePath, provider, maxZoomParsed, regionGUID, bbox
|
|
470
|
+
? [bbox.minLon, bbox.minLat, bbox.maxLon, bbox.maxLat]
|
|
471
|
+
: undefined, tile);
|
|
472
|
+
// Job is registered and its tile set is known, but nothing has been
|
|
473
|
+
// downloaded yet — the caller starts it with
|
|
474
|
+
// POST /cache/jobs/:id { action: 'start' }.
|
|
475
|
+
return res.status(202).json(job.info());
|
|
476
|
+
}
|
|
477
|
+
catch (err) {
|
|
478
|
+
return res
|
|
479
|
+
.status(500)
|
|
480
|
+
.send(`Failed to create seeding job: ${err.message}`);
|
|
481
|
+
}
|
|
261
482
|
});
|
|
262
|
-
app.get(`${chartTilesPath}/cache/jobs`, (
|
|
483
|
+
app.get(`${chartTilesPath}/cache/jobs`, (_req, res) => {
|
|
263
484
|
const jobs = Object.values(chartDownloader_1.ChartSeedingManager.ActiveJobs).map((job) => {
|
|
264
485
|
return job.info();
|
|
265
486
|
});
|
|
@@ -267,42 +488,57 @@ module.exports = (app) => {
|
|
|
267
488
|
});
|
|
268
489
|
app.post(`${chartTilesPath}/cache/jobs/:id`, (req, res) => {
|
|
269
490
|
const { id } = req.params;
|
|
491
|
+
if (!id) {
|
|
492
|
+
return res.sendStatus(404);
|
|
493
|
+
}
|
|
270
494
|
const { action } = req.body;
|
|
495
|
+
if (!id) {
|
|
496
|
+
return res.status(400).send('Missing job id');
|
|
497
|
+
}
|
|
271
498
|
const parsedId = parseInt(id);
|
|
499
|
+
if (!Number.isFinite(parsedId)) {
|
|
500
|
+
return res.status(400).send(`Invalid job id: ${id}`);
|
|
501
|
+
}
|
|
272
502
|
const job = chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
|
|
273
|
-
if (job
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
return res.status(404).send(`Job ${parsedId} not found`);
|
|
288
|
-
}
|
|
289
|
-
return res.status(200).send(`Job ${parsedId} ${action}ed`);
|
|
503
|
+
if (!job) {
|
|
504
|
+
return res.status(404).send(`Job ${parsedId} not found`);
|
|
505
|
+
}
|
|
506
|
+
if (!action) {
|
|
507
|
+
return res.status(400).send('action parameter is required');
|
|
508
|
+
}
|
|
509
|
+
if (action === 'start') {
|
|
510
|
+
job.seedCache();
|
|
511
|
+
}
|
|
512
|
+
else if (action === 'stop') {
|
|
513
|
+
job.cancelJob();
|
|
514
|
+
}
|
|
515
|
+
else if (action === 'delete') {
|
|
516
|
+
job.deleteCache();
|
|
290
517
|
}
|
|
518
|
+
else if (action === 'remove') {
|
|
519
|
+
delete chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
|
|
520
|
+
}
|
|
521
|
+
else {
|
|
522
|
+
return res.status(400).send(`Unknown action: ${action}`);
|
|
523
|
+
}
|
|
524
|
+
return res.status(200).send(`Job ${parsedId} ${action}ed`);
|
|
291
525
|
});
|
|
292
526
|
app.debug('** Registering v1 API paths **');
|
|
293
527
|
app.get(constants_1.apiRoutePrefix[1] + '/charts/:identifier', (req, res) => {
|
|
294
528
|
const { identifier } = req.params;
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
529
|
+
if (!identifier) {
|
|
530
|
+
return res.sendStatus(404);
|
|
531
|
+
}
|
|
532
|
+
const view = sanitizedV1[identifier];
|
|
533
|
+
if (view) {
|
|
534
|
+
return res.json(view);
|
|
298
535
|
}
|
|
299
536
|
else {
|
|
300
537
|
return res.status(404).send('Not found');
|
|
301
538
|
}
|
|
302
539
|
});
|
|
303
|
-
app.get(constants_1.apiRoutePrefix[1] + '/charts', (
|
|
304
|
-
|
|
305
|
-
res.json(sanitized);
|
|
540
|
+
app.get(constants_1.apiRoutePrefix[1] + '/charts', (_req, res) => {
|
|
541
|
+
res.json(sanitizedV1);
|
|
306
542
|
});
|
|
307
543
|
};
|
|
308
544
|
// Resources API provider registration
|
|
@@ -314,13 +550,13 @@ module.exports = (app) => {
|
|
|
314
550
|
methods: {
|
|
315
551
|
listResources: (params) => {
|
|
316
552
|
app.debug(`** listResources() ${params}`);
|
|
317
|
-
return Promise.resolve(
|
|
553
|
+
return Promise.resolve(sanitizedV2);
|
|
318
554
|
},
|
|
319
555
|
getResource: (id) => {
|
|
320
556
|
app.debug(`** getResource() ${id}`);
|
|
321
|
-
const
|
|
322
|
-
if (
|
|
323
|
-
return Promise.resolve(
|
|
557
|
+
const view = sanitizedV2[id];
|
|
558
|
+
if (view) {
|
|
559
|
+
return Promise.resolve(view);
|
|
324
560
|
}
|
|
325
561
|
else {
|
|
326
562
|
throw new Error('Chart not found!');
|
|
@@ -337,7 +573,7 @@ module.exports = (app) => {
|
|
|
337
573
|
});
|
|
338
574
|
}
|
|
339
575
|
catch (error) {
|
|
340
|
-
app.
|
|
576
|
+
app.setPluginError(`Failed to register as charts resource provider: ${error.message}`);
|
|
341
577
|
}
|
|
342
578
|
};
|
|
343
579
|
const serveTileFromCacheOrRemote = async (res, provider, z, x, y) => {
|
|
@@ -356,9 +592,16 @@ const responseHttpOptions = {
|
|
|
356
592
|
'Cache-Control': 'public, max-age=7776000' // 90 days
|
|
357
593
|
}
|
|
358
594
|
};
|
|
595
|
+
// Allowed tile file formats. Add new formats here when supporting them.
|
|
596
|
+
const ALLOWED_TILE_FORMATS = new Set(['png', 'jpg', 'jpeg', 'pbf']);
|
|
597
|
+
const isAllowedTileFormat = (format) => {
|
|
598
|
+
if (!format)
|
|
599
|
+
return false;
|
|
600
|
+
return ALLOWED_TILE_FORMATS.has(format.toLowerCase());
|
|
601
|
+
};
|
|
359
602
|
const resolveUniqueChartPaths = (chartPaths, configBasePath) => {
|
|
360
|
-
const paths =
|
|
361
|
-
return
|
|
603
|
+
const paths = chartPaths.map((chartPath) => path_1.default.resolve(configBasePath, chartPath));
|
|
604
|
+
return [...new Set(paths)];
|
|
362
605
|
};
|
|
363
606
|
const convertOnlineProviderConfig = (provider) => {
|
|
364
607
|
const id = _.kebabCase(_.deburr(provider.name));
|
|
@@ -405,48 +648,76 @@ const convertOnlineProviderConfig = (provider) => {
|
|
|
405
648
|
};
|
|
406
649
|
return data;
|
|
407
650
|
};
|
|
651
|
+
// Builds the outward-facing view of a provider for either the v1 or v2 API.
|
|
652
|
+
// Copies non-private top-level fields, then overlays the version-specific
|
|
653
|
+
// block (v1 has tilemapUrl/chartLayers, v2 has url/layers), rewriting the
|
|
654
|
+
// tile-path placeholder. Intentionally a single shallow walk — the previous
|
|
655
|
+
// implementation did three deep clones per call and ran per metadata request.
|
|
408
656
|
const sanitizeProvider = (provider, version = 1) => {
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
657
|
+
const out = {};
|
|
658
|
+
for (const [key, value] of Object.entries(provider)) {
|
|
659
|
+
if (key.startsWith('_') || key === 'v1' || key === 'v2')
|
|
660
|
+
continue;
|
|
661
|
+
out[key] = value;
|
|
662
|
+
}
|
|
663
|
+
const v = version === 1 ? provider.v1 : provider.v2;
|
|
664
|
+
if (v) {
|
|
665
|
+
for (const [key, value] of Object.entries(v)) {
|
|
666
|
+
out[key] = value;
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
if (version === 1 && typeof out.tilemapUrl === 'string') {
|
|
670
|
+
out.tilemapUrl = out.tilemapUrl.replace('~tilePath~', chartTilesPath);
|
|
671
|
+
}
|
|
672
|
+
else if (version === 2 && typeof out.url === 'string') {
|
|
673
|
+
out.url = out.url.replace('~tilePath~', chartTilesPath);
|
|
413
674
|
}
|
|
414
675
|
else if (version === 2) {
|
|
415
|
-
|
|
416
|
-
v.url = v.url ? v.url.replace('~tilePath~', chartTilesPath) : '';
|
|
676
|
+
out.url = '';
|
|
417
677
|
}
|
|
418
|
-
|
|
419
|
-
'_filePath',
|
|
420
|
-
'_fileFormat',
|
|
421
|
-
'_mbtilesHandle',
|
|
422
|
-
'_flipY',
|
|
423
|
-
'v1',
|
|
424
|
-
'v2'
|
|
425
|
-
]);
|
|
426
|
-
return _.merge(provider, v);
|
|
678
|
+
return out;
|
|
427
679
|
};
|
|
428
|
-
const ensureDirectoryExists = (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
}
|
|
680
|
+
const ensureDirectoryExists = async (p) => {
|
|
681
|
+
// mkdir with recursive:true is idempotent and skips the existsSync probe,
|
|
682
|
+
// keeping startup off the sync-fs path.
|
|
683
|
+
await fs_1.default.promises.mkdir(p, { recursive: true });
|
|
432
684
|
};
|
|
433
|
-
const serveTileFromFilesystem = (res, provider, z, x, y) => {
|
|
685
|
+
const serveTileFromFilesystem = async (res, provider, z, x, y) => {
|
|
434
686
|
const { format, _flipY, _filePath } = provider;
|
|
687
|
+
const normalizedFormat = format?.toLowerCase() ?? '';
|
|
688
|
+
if (!_filePath || !ALLOWED_TILE_FORMATS.has(normalizedFormat)) {
|
|
689
|
+
res.sendStatus(404);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
435
692
|
const flippedY = Math.pow(2, z) - 1 - y;
|
|
436
|
-
const file = _filePath
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
res.sendFile(file, responseHttpOptions, (err) => {
|
|
441
|
-
if (err && err.code === 'ENOENT') {
|
|
693
|
+
const file = path_1.default.resolve(_filePath, `${z}/${x}/${_flipY ? flippedY : y}.${normalizedFormat}`);
|
|
694
|
+
try {
|
|
695
|
+
const stats = await fs_1.default.promises.stat(file);
|
|
696
|
+
if (!stats.isFile()) {
|
|
442
697
|
res.sendStatus(404);
|
|
698
|
+
return;
|
|
443
699
|
}
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
700
|
+
await fs_1.default.promises.access(file, fs_1.default.constants.R_OK);
|
|
701
|
+
}
|
|
702
|
+
catch {
|
|
703
|
+
res.sendStatus(404);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
res.sendFile(file, responseHttpOptions);
|
|
448
707
|
};
|
|
449
708
|
const serveTileFromMbtiles = (res, provider, z, x, y) => {
|
|
709
|
+
if (!isAllowedTileFormat(provider.format)) {
|
|
710
|
+
res.sendStatus(404);
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
// Guard against a provider whose MBTiles handle is missing: openMbtilesFile
|
|
714
|
+
// may have failed post-reconcile, or the handle was closed while a request
|
|
715
|
+
// was in flight. Without this check, `.getTile` throws synchronously and
|
|
716
|
+
// Express never answers the client.
|
|
717
|
+
if (!provider._mbtilesHandle) {
|
|
718
|
+
res.sendStatus(500);
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
450
721
|
provider._mbtilesHandle.getTile(z, x, y, (err, tile, headers) => {
|
|
451
722
|
if (err && err.message && err.message === 'Tile does not exist') {
|
|
452
723
|
res.sendStatus(404);
|
|
@@ -462,3 +733,5 @@ const serveTileFromMbtiles = (res, provider, z, x, y) => {
|
|
|
462
733
|
}
|
|
463
734
|
});
|
|
464
735
|
};
|
|
736
|
+
module.exports = createPlugin;
|
|
737
|
+
//# sourceMappingURL=index.js.map
|