@signalk/charts-plugin 3.2.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 +384 -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,25 +44,54 @@ 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: [],
|
|
62
|
+
cachePath: '',
|
|
44
63
|
onlineChartProviders: []
|
|
45
64
|
};
|
|
46
65
|
let urlBase = '';
|
|
47
66
|
const configBasePath = app.config.configPath;
|
|
48
67
|
const defaultChartsPath = path_1.default.join(configBasePath, '/charts');
|
|
49
68
|
const serverMajorVersion = app.config.version
|
|
50
|
-
? parseInt(app.config.version.split('.')[0])
|
|
51
|
-
:
|
|
52
|
-
|
|
69
|
+
? parseInt(app.config.version.split('.')[0] ?? '0')
|
|
70
|
+
: 1;
|
|
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');
|
|
53
82
|
// ******** REQUIRED PLUGIN DEFINITION *******
|
|
54
83
|
const CONFIG_SCHEMA = {
|
|
55
84
|
title: 'Signal K Charts',
|
|
56
85
|
type: 'object',
|
|
57
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
|
+
}),
|
|
58
95
|
chartPaths: {
|
|
59
96
|
type: 'array',
|
|
60
97
|
title: 'Chart paths',
|
|
@@ -65,6 +102,11 @@ module.exports = (app) => {
|
|
|
65
102
|
description: `Path for chart files, relative to "${configBasePath}"`
|
|
66
103
|
}
|
|
67
104
|
},
|
|
105
|
+
cachePath: {
|
|
106
|
+
type: 'string',
|
|
107
|
+
title: 'Cache path',
|
|
108
|
+
description: `Directory for cached tiles. Defaults to "${defaultChartsPath}"`
|
|
109
|
+
},
|
|
68
110
|
onlineChartProviders: {
|
|
69
111
|
type: 'array',
|
|
70
112
|
title: 'Online chart providers',
|
|
@@ -168,50 +210,220 @@ module.exports = (app) => {
|
|
|
168
210
|
return doStartup(settings); // return required for tests
|
|
169
211
|
},
|
|
170
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 = {};
|
|
171
227
|
app.setPluginStatus('stopped');
|
|
172
228
|
}
|
|
173
229
|
};
|
|
174
|
-
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
|
+
}
|
|
175
240
|
app.debug(`** loaded config: ${config}`);
|
|
176
241
|
props = { ...config };
|
|
177
242
|
urlBase = `${app.config.ssl ? 'https' : 'http'}://localhost:${'getExternalPort' in app.config ? app.config.getExternalPort() : 3000}`;
|
|
178
243
|
app.debug(`**urlBase** ${urlBase}`);
|
|
179
|
-
|
|
244
|
+
activeChartPaths = !props.chartPaths?.length
|
|
180
245
|
? [defaultChartsPath]
|
|
181
246
|
: resolveUniqueChartPaths(props.chartPaths, configBasePath);
|
|
182
|
-
|
|
247
|
+
cachePath = props.cachePath || defaultChartsPath;
|
|
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 ?? []) {
|
|
183
257
|
const provider = convertOnlineProviderConfig(data);
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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();
|
|
190
271
|
pluginStarted = true;
|
|
191
|
-
|
|
192
|
-
if (serverMajorVersion === 2) {
|
|
272
|
+
if (serverMajorVersion === 2 && !providerRegistered) {
|
|
193
273
|
app.debug('** Registering v2 API paths **');
|
|
194
274
|
registerAsProvider();
|
|
275
|
+
providerRegistered = true;
|
|
195
276
|
}
|
|
196
277
|
app.setPluginStatus('Started');
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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.
|
|
206
293
|
console.error(`Error loading chart providers`, e.message);
|
|
207
|
-
chartProviders = {};
|
|
208
294
|
app.setPluginError(`Error loading chart providers`);
|
|
209
|
-
|
|
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
|
+
}
|
|
210
419
|
};
|
|
211
420
|
const registerRoutes = () => {
|
|
212
421
|
app.debug('** Registering API paths **');
|
|
213
422
|
app.get(`${chartTilesPath}/:identifier/:z([0-9]*)/:x([0-9]*)/:y([0-9]*)`, async (req, res) => {
|
|
214
423
|
const { identifier, z, x, y } = req.params;
|
|
424
|
+
if (!identifier || !z || !x || !y) {
|
|
425
|
+
return res.sendStatus(404);
|
|
426
|
+
}
|
|
215
427
|
const ix = parseInt(x);
|
|
216
428
|
const iy = parseInt(y);
|
|
217
429
|
const iz = parseInt(z);
|
|
@@ -236,21 +448,39 @@ module.exports = (app) => {
|
|
|
236
448
|
});
|
|
237
449
|
app.post(`${chartTilesPath}/cache/:identifier`, async (req, res) => {
|
|
238
450
|
const { identifier } = req.params;
|
|
451
|
+
if (!identifier) {
|
|
452
|
+
return res.sendStatus(404);
|
|
453
|
+
}
|
|
239
454
|
const { regionGUID, tile, bbox, maxZoom } = req.body;
|
|
240
455
|
const provider = chartProviders[identifier];
|
|
241
456
|
if (!provider) {
|
|
242
|
-
return res.
|
|
457
|
+
return res.status(404).send('Provider not found');
|
|
243
458
|
}
|
|
244
459
|
if (!maxZoom) {
|
|
245
460
|
return res.status(400).send('maxZoom parameter is required');
|
|
246
461
|
}
|
|
462
|
+
if (!regionGUID && !bbox && !tile) {
|
|
463
|
+
return res
|
|
464
|
+
.status(400)
|
|
465
|
+
.send('Request must include regionGUID, bbox, or tile');
|
|
466
|
+
}
|
|
247
467
|
const maxZoomParsed = parseInt(maxZoom);
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
+
}
|
|
252
482
|
});
|
|
253
|
-
app.get(`${chartTilesPath}/cache/jobs`, (
|
|
483
|
+
app.get(`${chartTilesPath}/cache/jobs`, (_req, res) => {
|
|
254
484
|
const jobs = Object.values(chartDownloader_1.ChartSeedingManager.ActiveJobs).map((job) => {
|
|
255
485
|
return job.info();
|
|
256
486
|
});
|
|
@@ -258,42 +488,57 @@ module.exports = (app) => {
|
|
|
258
488
|
});
|
|
259
489
|
app.post(`${chartTilesPath}/cache/jobs/:id`, (req, res) => {
|
|
260
490
|
const { id } = req.params;
|
|
491
|
+
if (!id) {
|
|
492
|
+
return res.sendStatus(404);
|
|
493
|
+
}
|
|
261
494
|
const { action } = req.body;
|
|
495
|
+
if (!id) {
|
|
496
|
+
return res.status(400).send('Missing job id');
|
|
497
|
+
}
|
|
262
498
|
const parsedId = parseInt(id);
|
|
499
|
+
if (!Number.isFinite(parsedId)) {
|
|
500
|
+
return res.status(400).send(`Invalid job id: ${id}`);
|
|
501
|
+
}
|
|
263
502
|
const job = chartDownloader_1.ChartSeedingManager.ActiveJobs[parsedId];
|
|
264
|
-
if (job
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
return res.status(404).send(`Job ${parsedId} not found`);
|
|
279
|
-
}
|
|
280
|
-
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();
|
|
281
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`);
|
|
282
525
|
});
|
|
283
526
|
app.debug('** Registering v1 API paths **');
|
|
284
527
|
app.get(constants_1.apiRoutePrefix[1] + '/charts/:identifier', (req, res) => {
|
|
285
528
|
const { identifier } = req.params;
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
529
|
+
if (!identifier) {
|
|
530
|
+
return res.sendStatus(404);
|
|
531
|
+
}
|
|
532
|
+
const view = sanitizedV1[identifier];
|
|
533
|
+
if (view) {
|
|
534
|
+
return res.json(view);
|
|
289
535
|
}
|
|
290
536
|
else {
|
|
291
537
|
return res.status(404).send('Not found');
|
|
292
538
|
}
|
|
293
539
|
});
|
|
294
|
-
app.get(constants_1.apiRoutePrefix[1] + '/charts', (
|
|
295
|
-
|
|
296
|
-
res.json(sanitized);
|
|
540
|
+
app.get(constants_1.apiRoutePrefix[1] + '/charts', (_req, res) => {
|
|
541
|
+
res.json(sanitizedV1);
|
|
297
542
|
});
|
|
298
543
|
};
|
|
299
544
|
// Resources API provider registration
|
|
@@ -305,13 +550,13 @@ module.exports = (app) => {
|
|
|
305
550
|
methods: {
|
|
306
551
|
listResources: (params) => {
|
|
307
552
|
app.debug(`** listResources() ${params}`);
|
|
308
|
-
return Promise.resolve(
|
|
553
|
+
return Promise.resolve(sanitizedV2);
|
|
309
554
|
},
|
|
310
555
|
getResource: (id) => {
|
|
311
556
|
app.debug(`** getResource() ${id}`);
|
|
312
|
-
const
|
|
313
|
-
if (
|
|
314
|
-
return Promise.resolve(
|
|
557
|
+
const view = sanitizedV2[id];
|
|
558
|
+
if (view) {
|
|
559
|
+
return Promise.resolve(view);
|
|
315
560
|
}
|
|
316
561
|
else {
|
|
317
562
|
throw new Error('Chart not found!');
|
|
@@ -328,11 +573,11 @@ module.exports = (app) => {
|
|
|
328
573
|
});
|
|
329
574
|
}
|
|
330
575
|
catch (error) {
|
|
331
|
-
app.
|
|
576
|
+
app.setPluginError(`Failed to register as charts resource provider: ${error.message}`);
|
|
332
577
|
}
|
|
333
578
|
};
|
|
334
579
|
const serveTileFromCacheOrRemote = async (res, provider, z, x, y) => {
|
|
335
|
-
const buffer = await chartDownloader_1.ChartDownloader.getTileFromCacheOrRemote(
|
|
580
|
+
const buffer = await chartDownloader_1.ChartDownloader.getTileFromCacheOrRemote(cachePath, provider, { x, y, z });
|
|
336
581
|
if (!buffer) {
|
|
337
582
|
res.sendStatus(502);
|
|
338
583
|
return;
|
|
@@ -347,9 +592,16 @@ const responseHttpOptions = {
|
|
|
347
592
|
'Cache-Control': 'public, max-age=7776000' // 90 days
|
|
348
593
|
}
|
|
349
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
|
+
};
|
|
350
602
|
const resolveUniqueChartPaths = (chartPaths, configBasePath) => {
|
|
351
|
-
const paths =
|
|
352
|
-
return
|
|
603
|
+
const paths = chartPaths.map((chartPath) => path_1.default.resolve(configBasePath, chartPath));
|
|
604
|
+
return [...new Set(paths)];
|
|
353
605
|
};
|
|
354
606
|
const convertOnlineProviderConfig = (provider) => {
|
|
355
607
|
const id = _.kebabCase(_.deburr(provider.name));
|
|
@@ -396,48 +648,76 @@ const convertOnlineProviderConfig = (provider) => {
|
|
|
396
648
|
};
|
|
397
649
|
return data;
|
|
398
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.
|
|
399
656
|
const sanitizeProvider = (provider, version = 1) => {
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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);
|
|
404
674
|
}
|
|
405
675
|
else if (version === 2) {
|
|
406
|
-
|
|
407
|
-
v.url = v.url ? v.url.replace('~tilePath~', chartTilesPath) : '';
|
|
676
|
+
out.url = '';
|
|
408
677
|
}
|
|
409
|
-
|
|
410
|
-
'_filePath',
|
|
411
|
-
'_fileFormat',
|
|
412
|
-
'_mbtilesHandle',
|
|
413
|
-
'_flipY',
|
|
414
|
-
'v1',
|
|
415
|
-
'v2'
|
|
416
|
-
]);
|
|
417
|
-
return _.merge(provider, v);
|
|
678
|
+
return out;
|
|
418
679
|
};
|
|
419
|
-
const ensureDirectoryExists = (
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
}
|
|
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 });
|
|
423
684
|
};
|
|
424
|
-
const serveTileFromFilesystem = (res, provider, z, x, y) => {
|
|
685
|
+
const serveTileFromFilesystem = async (res, provider, z, x, y) => {
|
|
425
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
|
+
}
|
|
426
692
|
const flippedY = Math.pow(2, z) - 1 - y;
|
|
427
|
-
const file = _filePath
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
res.sendFile(file, responseHttpOptions, (err) => {
|
|
432
|
-
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()) {
|
|
433
697
|
res.sendStatus(404);
|
|
698
|
+
return;
|
|
434
699
|
}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
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);
|
|
439
707
|
};
|
|
440
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
|
+
}
|
|
441
721
|
provider._mbtilesHandle.getTile(z, x, y, (err, tile, headers) => {
|
|
442
722
|
if (err && err.message && err.message === 'Tile does not exist') {
|
|
443
723
|
res.sendStatus(404);
|
|
@@ -453,3 +733,5 @@ const serveTileFromMbtiles = (res, provider, z, x, y) => {
|
|
|
453
733
|
}
|
|
454
734
|
});
|
|
455
735
|
};
|
|
736
|
+
module.exports = createPlugin;
|
|
737
|
+
//# sourceMappingURL=index.js.map
|