signalk-charts-provider-simple 2.4.0 → 2.4.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAqHxD,OAAO,KAAK,EACV,aAAa,EAEb,iBAAiB,EAIjB,QAAQ,EAET,MAAM,YAAY,CAAC;AAepB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MAs6GnD,CAAC;AA4GF,eAAO,MAAM,oBAAoB,GAC/B,KAAK,QAAQ,EACb,UAAU,aAAa,EACvB,GAAG,MAAM,EACT,GAAG,MAAM,EACT,GAAG,MAAM,KACR,IAiDF,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAQ,MAAM,EAAE,MAAM,qBAAqB,CAAC;AAsHxD,OAAO,KAAK,EACV,aAAa,EAEb,iBAAiB,EAIjB,QAAQ,EAET,MAAM,YAAY,CAAC;AAwBpB,QAAA,MAAM,iBAAiB,GAAI,KAAK,iBAAiB,KAAG,MA49GnD,CAAC;AA4GF,eAAO,MAAM,oBAAoB,GAC/B,KAAK,QAAQ,EACb,UAAU,aAAa,EACvB,GAAG,MAAM,EACT,GAAG,MAAM,EACT,GAAG,MAAM,KACR,IAiDF,CAAC;AAEF,eAAe,iBAAiB,CAAC"}
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import { writeChartPathMarker } from './utils/path-marker.js';
21
21
  import { getBlankTileReplacement, getOverzoomedTile } from './utils/tile-overzoom.js';
22
22
  import { arePairWithinBase, classifyChartDirAccess, hasHostChartsMountEnv, isHostMountPath, isWithinBase, resolveDefaultChartsPath, validateChartName } from './utils/path-safety.js';
23
23
  import { parsePluginConfig } from './utils/plugin-config-schema.js';
24
- import { cleanupQuarantineDir, makeQuarantineDir, promoteQuarantine, sweepStaleQuarantineDirs } from './utils/quarantine.js';
24
+ import { cleanupQuarantineDir, makeQuarantineDir, makeUniqueQuarantineDir, promoteQuarantine, sweepStaleQuarantineDirs } from './utils/quarantine.js';
25
25
  import { parseBody, parseShape } from './utils/rest-validation.js';
26
26
  import { getAllConversionProgress as getAllRncProgress, getConversionProgress as getRncProgress, initRncConverter, processPilotTar, processRncZip, setConversionFailed as setRncFailed } from './utils/rnc-converter.js';
27
27
  import { getAllConversionProgress as getAllS57Progress, getConversionProgress as getS57Progress, initS57Converter, processGshhg, processS57Directory, processS57Zip, processShpBasemap, setConversionFailed as setS57Failed } from './utils/s57-converter.js';
@@ -32,6 +32,14 @@ import { downloadAndExtractEncs } from './utils/custom-catalog-download.js';
32
32
  // is what the marker file records so users can confirm the running build.
33
33
  import packageJson from '../package.json' with { type: 'json' };
34
34
  const pluginVersion = packageJson.version;
35
+ // The startup-global 'job-completed' handler is re-registered on every
36
+ // start() because its closure captures the current chartPath. Track the
37
+ // previous instance (module scope, like downloadManager itself) so
38
+ // re-registration removes exactly that handler — a blanket
39
+ // removeAllListeners('job-completed') would also strip the per-job
40
+ // promotion/cleanup listeners of downloads still running from a prior
41
+ // plugin lifecycle, stranding their files in the quarantine.
42
+ let globalJobCompletedListener = null;
35
43
  // Single source of truth lives in container-jobs.ts as PLUGIN_OWNER_ID
36
44
  // (used as the `ownerPluginId` label on every runJob call). The plugin
37
45
  // id Signal K uses must match exactly so `cleanupOrphanedJobs` reaps
@@ -403,8 +411,10 @@ const pluginConstructor = (app) => {
403
411
  }
404
412
  }
405
413
  startCatalogUpdateChecker();
406
- downloadManager.removeAllListeners('job-completed');
407
- downloadManager.on('job-completed', (job) => {
414
+ if (globalJobCompletedListener) {
415
+ downloadManager.removeListener('job-completed', globalJobCompletedListener);
416
+ }
417
+ globalJobCompletedListener = (job) => {
408
418
  app.debug(`Download job completed: ${job.id}, extracted files: ${job.extractedFiles.join(', ')}`);
409
419
  for (const fileName of job.extractedFiles) {
410
420
  const relativePath = path.relative(chartPath, path.join(job.targetDir, fileName));
@@ -421,7 +431,8 @@ const pluginConstructor = (app) => {
421
431
  }
422
432
  }
423
433
  });
424
- });
434
+ };
435
+ downloadManager.on('job-completed', globalJobCompletedListener);
425
436
  // Filesystem housekeeping (removing invalid .mbtiles and orphaned
426
437
  // journal/WAL files) runs independently of the display path so it can
427
438
  // never delay chart listing. It rescans the chart directory itself
@@ -616,6 +627,13 @@ const pluginConstructor = (app) => {
616
627
  .json({ success: false, error: `Invalid chart name: ${chartNameCheck.reason}` });
617
628
  return;
618
629
  }
630
+ // Setup-failure cleanup state: if anything throws after the
631
+ // quarantine dir exists but before the job owns it, the catch
632
+ // below removes the orphan. Once createJob has returned, the
633
+ // job's completion/failure listeners own the dir — the catch
634
+ // must NOT touch it then.
635
+ let stagedDir = null;
636
+ let createdJobId = null;
619
637
  try {
620
638
  console.log(`Creating download job for: ${downloadUrl}`);
621
639
  console.log(`Target folder: ${targetFolder}`);
@@ -628,11 +646,16 @@ const pluginConstructor = (app) => {
628
646
  if (!fs.existsSync(targetDir)) {
629
647
  fs.mkdirSync(targetDir, { recursive: true });
630
648
  }
631
- // Stage the download in the quarantine dir; promote on
649
+ // Stage the download in a per-job quarantine dir; promote on
632
650
  // job-completed so a crash mid-fetch never leaves a partial
633
- // .mbtiles in the live chart library.
634
- const quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartName);
651
+ // .mbtiles in the live chart library. Per-job (unique) because
652
+ // download jobs run concurrently: two downloads with the same
653
+ // chart name must not share a dir, or the first to finish wipes
654
+ // the workspace out from under the other mid-extraction.
655
+ const quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartName);
656
+ stagedDir = quarantineDir;
635
657
  const jobId = downloadManager.createJob(downloadUrl, quarantineDir, chartName);
658
+ createdJobId = jobId;
636
659
  const promoteListener = (job) => {
637
660
  if (job.id !== jobId) {
638
661
  return;
@@ -688,6 +711,9 @@ const pluginConstructor = (app) => {
688
711
  });
689
712
  }
690
713
  catch (error) {
714
+ if (stagedDir !== null && createdJobId === null) {
715
+ cleanupQuarantineDir(stagedDir);
716
+ }
691
717
  console.error('Error creating download job:', error);
692
718
  res.status(500).json({
693
719
  success: false,
@@ -1326,7 +1352,7 @@ const pluginConstructor = (app) => {
1326
1352
  // streamed here first, then atomically promoted on `finish`.
1327
1353
  // A crashed/aborted request leaves the partial files in the
1328
1354
  // quarantine for the startup sweep to wipe, never in basePath.
1329
- const quarantineDir = makeQuarantineDir(app.getDataDirPath(), `upload-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`);
1355
+ const quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), 'upload');
1330
1356
  const uploadedFiles = [];
1331
1357
  const writePromises = [];
1332
1358
  const fields = {};
@@ -1371,6 +1397,11 @@ const pluginConstructor = (app) => {
1371
1397
  reject(err);
1372
1398
  });
1373
1399
  });
1400
+ // Observe the rejection now: Promise.all only attaches in the
1401
+ // 'finish' handler, which never fires on an aborted request —
1402
+ // a staging failure there would otherwise surface as a
1403
+ // process-level unhandled rejection.
1404
+ writePromise.catch(() => undefined);
1374
1405
  writePromises.push(writePromise);
1375
1406
  });
1376
1407
  bb.on('finish', () => {
@@ -1433,6 +1464,23 @@ const pluginConstructor = (app) => {
1433
1464
  }
1434
1465
  })();
1435
1466
  });
1467
+ // An aborted request or a Busboy parse error never reaches
1468
+ // 'finish', which owns the normal cleanup — without these the
1469
+ // staged files stay on disk and the dir stays registered as
1470
+ // active (so the startup sweep skips it) until a full server
1471
+ // restart. cleanupQuarantineDir is idempotent, so a late abort
1472
+ // racing the finish path is harmless.
1473
+ bb.on('error', (err) => {
1474
+ app.error('Upload stream error: ' + (err instanceof Error ? err.message : String(err)));
1475
+ cleanupQuarantineDir(quarantineDir);
1476
+ if (!res.headersSent) {
1477
+ res.status(400).send('Malformed upload');
1478
+ }
1479
+ });
1480
+ req.on('aborted', () => {
1481
+ app.debug('Upload request aborted by client');
1482
+ cleanupQuarantineDir(quarantineDir);
1483
+ });
1436
1484
  req.pipe(bb);
1437
1485
  }
1438
1486
  catch (error) {
@@ -1728,7 +1776,7 @@ const pluginConstructor = (app) => {
1728
1776
  // the UI stuck waiting for status it would never get.
1729
1777
  let quarantineDir;
1730
1778
  try {
1731
- quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartNumber);
1779
+ quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartNumber);
1732
1780
  }
1733
1781
  catch (err) {
1734
1782
  res.status(500).json({
@@ -1820,6 +1868,11 @@ const pluginConstructor = (app) => {
1820
1868
  });
1821
1869
  return;
1822
1870
  }
1871
+ // Setup-failure cleanup state for the direct-.mbtiles branch: see
1872
+ // the download-chart-locker route. Once createJob has returned,
1873
+ // the job's listeners own the dir and the catch must leave it be.
1874
+ let stagedDir = null;
1875
+ let createdJobId = null;
1823
1876
  try {
1824
1877
  const chartPath = props.chartPath || defaultChartsPath;
1825
1878
  const targetDir = !targetFolder || targetFolder === '/' ? chartPath : path.join(chartPath, targetFolder);
@@ -1876,13 +1929,16 @@ const pluginConstructor = (app) => {
1876
1929
  handleGshhgDownload(res, app, chartNumber, catalogFile, zipfileDatetime, url, targetDir, chartPath);
1877
1930
  }
1878
1931
  else {
1879
- // Direct .mbtiles download (or ZIP-of-.mbtiles): stage into the
1880
- // quarantine dir so a crash mid-download/extract never leaves a
1881
- // half-built file in the live chart library. Promotion runs on
1882
- // job-completed; failure / no-files paths drop the quarantine.
1883
- const quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartNumber);
1932
+ // Direct .mbtiles download (or ZIP-of-.mbtiles): stage into a
1933
+ // per-job quarantine dir so a crash mid-download/extract never
1934
+ // leaves a half-built file in the live chart library, and so a
1935
+ // re-download of the same chartNumber can't share (and wipe)
1936
+ // a still-running job's dir. Promotion runs on job-completed;
1937
+ // failure / no-files paths drop the quarantine.
1938
+ const quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartNumber);
1939
+ stagedDir = quarantineDir;
1884
1940
  const jobId = downloadManager.createJob(url, quarantineDir, chartNumber);
1885
- trackInstall(chartNumber, catalogFile, zipfileDatetime ?? '', url);
1941
+ createdJobId = jobId;
1886
1942
  const cleanupListener = (job) => {
1887
1943
  if (job.id !== jobId) {
1888
1944
  return;
@@ -1951,6 +2007,11 @@ const pluginConstructor = (app) => {
1951
2007
  };
1952
2008
  downloadManager.on('job-failed', cleanupListener);
1953
2009
  downloadManager.on('job-completed', cleanupListener);
2010
+ // Only after the listeners own the job/quarantine lifecycle:
2011
+ // if this throws, the catch below must NOT touch the dir (the
2012
+ // listeners will promote or clean it), and nothing before the
2013
+ // registration above can throw once createJob has returned.
2014
+ trackInstall(chartNumber, catalogFile, zipfileDatetime ?? '', url);
1954
2015
  app.debug(`Catalog download started: ${chartNumber} from ${catalogFile}, job: ${jobId}`);
1955
2016
  res.json({
1956
2017
  success: true,
@@ -1960,6 +2021,9 @@ const pluginConstructor = (app) => {
1960
2021
  }
1961
2022
  }
1962
2023
  catch (error) {
2024
+ if (stagedDir !== null && createdJobId === null) {
2025
+ cleanupQuarantineDir(stagedDir);
2026
+ }
1963
2027
  console.error('Error creating catalog download job:', error);
1964
2028
  res.status(500).json({
1965
2029
  success: false,
@@ -2205,7 +2269,7 @@ const pluginConstructor = (app) => {
2205
2269
  // the same cleanup as a conversion failure.
2206
2270
  let quarantineDir = null;
2207
2271
  try {
2208
- quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartNumber);
2272
+ quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartNumber);
2209
2273
  const displayName = chartTitle ? cleanCatalogTitle(chartTitle) : undefined;
2210
2274
  const result = await processS57Zip(zipPath, quarantineDir, chartNumber, (status, message) => {
2211
2275
  app.debug(`S-57 [${chartNumber}] ${status}: ${message}`);
@@ -2288,7 +2352,7 @@ const pluginConstructor = (app) => {
2288
2352
  app.debug(`Starting RNC conversion for ${chartNumber}: ${zipPath}`);
2289
2353
  let quarantineDir = null;
2290
2354
  try {
2291
- quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartNumber);
2355
+ quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartNumber);
2292
2356
  const result = await processRncZip(zipPath, quarantineDir, chartNumber, (status, message) => {
2293
2357
  app.debug(`RNC [${chartNumber}] ${status}: ${message}`);
2294
2358
  });
@@ -2369,7 +2433,7 @@ const pluginConstructor = (app) => {
2369
2433
  setConvertingState(chartNumber, true);
2370
2434
  let quarantineDir = null;
2371
2435
  try {
2372
- quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartNumber);
2436
+ quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartNumber);
2373
2437
  const result = await processPilotTar(dlPath, quarantineDir, chartNumber, (status, message) => {
2374
2438
  app.debug(`Pilot [${chartNumber}] ${status}: ${message}`);
2375
2439
  });
@@ -2440,7 +2504,7 @@ const pluginConstructor = (app) => {
2440
2504
  setConvertingState(chartNumber, true);
2441
2505
  let quarantineDir = null;
2442
2506
  try {
2443
- quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartNumber);
2507
+ quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartNumber);
2444
2508
  const result = await processShpBasemap(dlPath, quarantineDir, chartNumber, (status, message) => {
2445
2509
  app.debug(`ShpBasemap [${chartNumber}] ${status}: ${message}`);
2446
2510
  });
@@ -2491,7 +2555,7 @@ const pluginConstructor = (app) => {
2491
2555
  void (async () => {
2492
2556
  let quarantineDir = null;
2493
2557
  try {
2494
- quarantineDir = makeQuarantineDir(app.getDataDirPath(), chartNumber);
2558
+ quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), chartNumber);
2495
2559
  const result = await processGshhg(tmpDir, quarantineDir, resolution, chartNumber, (status, message) => {
2496
2560
  app.debug(`GSHHG [${chartNumber}] ${status}: ${message}`);
2497
2561
  });
@@ -2673,7 +2737,7 @@ const pluginConstructor = (app) => {
2673
2737
  }
2674
2738
  setConvertingState(id, true);
2675
2739
  convertingFlagged = true;
2676
- quarantineDir = makeQuarantineDir(app.getDataDirPath(), id);
2740
+ quarantineDir = makeUniqueQuarantineDir(app.getDataDirPath(), id);
2677
2741
  const conv = await processS57Directory(encDir, quarantineDir, id, (status, message) => setCatalogProgress(id, status, message), {
2678
2742
  displayName: catalog.name,
2679
2743
  displayDescription: `Custom catalog: ${catalog.name}`,