pmtiles-swarm 0.16.0 → 0.17.0

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/CHANGELOG.md CHANGED
@@ -7,6 +7,32 @@
7
7
  ### 🐞 Bug fixes
8
8
  - _...Add new stuff here..._
9
9
 
10
+ ## 0.17.0
11
+ ### ✨ Features and improvements
12
+ - **A monitored folder can filter by filename.** `match` takes a glob, so one directory can be
13
+ described by several entries and each take only its own archives — which is what a generator
14
+ writing `monthly-20260813.pmtiles` and `10yrplus-20260813.pmtiles` side by side needs, since
15
+ categories and retention are decided per entry. Without it every entry claimed every archive,
16
+ and because imports are deduplicated by path the file landed under whichever entry won the
17
+ race: not a duplicate, which would at least have been visible, but one import under an
18
+ arbitrary category.
19
+
20
+ ### 🐞 Bug fixes
21
+ - **Entries sharing a directory now share one watcher.** Each previously started its own, and two
22
+ chokidar instances over one path made ownership a race. They are now grouped, so the first
23
+ entry whose `match` accepts a name takes it — decided by the order they appear in the config.
24
+
25
+ ## 0.16.1
26
+ ### 🐞 Bug fixes
27
+ - **Client addresses are recorded without the IPv4-mapped prefix.** A dual-stack listener reports
28
+ IPv4 peers as `::ffff:172.16.1.2`, so the Traffic tab showed an address nobody types — and
29
+ would have counted one client twice had it reached the node over both stacks.
30
+
31
+ Only a display and counting matter: `trustProxy` matching is unaffected, since Express compares
32
+ mapped addresses against plain IPv4 entries correctly. Verified rather than assumed, because
33
+ `"trustProxy": "172.16.1.2, 172.16.1.3"` looking like it should not match a `::ffff:` socket is
34
+ exactly the sort of thing that would have been quietly wrong.
35
+
10
36
  ## 0.16.0
11
37
  ### ✨ Features and improvements
12
38
  - **A Traffic tab in the console**, which is where the statistics added in 0.10.0 should have been
package/README.md CHANGED
@@ -272,7 +272,9 @@ Three ways in, all editable from the Settings screen:
272
272
  - **Monitored folders** (`watch`) — a directory scanned for new archives, the way a torrent client
273
273
  watches for `.torrent` files. `publishDir` moves each one into the directory a web server serves
274
274
  before the torrent is built, and `webSeedBase` is the URL that directory answers on, so a
275
- brand-new archive has a working web seed before any peer holds a copy.
275
+ brand-new archive has a working web seed before any peer holds a copy. `match` is a filename
276
+ glob, for a generator that writes several kinds of build into one directory: give the folder an
277
+ entry per kind, each matching its own names, and each gets its own categories and retention.
276
278
  - **A URL template** (`sources[].url`) — for an upstream that publishes at a predictable address,
277
279
  like `https://build.protomaps.com/{YYYYMMDD}.pmtiles`. Expanded per candidate date and probed
278
280
  with a `HEAD`. The more reliable of the two web options: it asks a direct question, and needs the
@@ -377,6 +377,44 @@ A new `.pmtiles` appearing in a watched folder is imported automatically.
377
377
 
378
378
  Editable in **Settings → Monitored folders** as a table, rather than by hand.
379
379
 
380
+ ### One folder, several kinds of build
381
+
382
+ Categories and retention are decided per entry, not per directory. A generator
383
+ that writes more than one kind of archive into one place therefore needs an
384
+ entry per kind, and `match` — a filename glob — is what tells them apart:
385
+
386
+ ```json
387
+ {
388
+ "watch": [
389
+ {
390
+ "path": "/srv/out/pmtiles",
391
+ "match": "monthly-*.pmtiles",
392
+ "categories": ["monthly"],
393
+ "latestLink": "monthly.pmtiles",
394
+ "keep": 1
395
+ },
396
+ {
397
+ "path": "/srv/out/pmtiles",
398
+ "match": "10yrplus-*.pmtiles",
399
+ "categories": ["10yrplus"],
400
+ "latestLink": "10yrplus.pmtiles",
401
+ "keep": 1
402
+ }
403
+ ]
404
+ }
405
+ ```
406
+
407
+ The glob is matched against the filename, is anchored at both ends — so
408
+ `monthly-*` does not claim `cell_monthly-*` — and treats `.` and other regex
409
+ punctuation as literal text. An entry with no `match` takes anything the
410
+ entries above it have not already claimed, and the first entry whose glob
411
+ accepts a name is the one that imports it, so config order decides.
412
+
413
+ Leaving `match` off where several entries share a directory does not produce
414
+ duplicates, which would at least be visible. Imports are deduplicated by path,
415
+ so each archive is imported exactly once — under whichever entry got to it
416
+ first, with nothing in the config or the log saying which.
417
+
380
418
  `comment` goes into every torrent this folder produces, and is where the attribution and
381
419
  licence belong — it is the one field a torrent carries that says what the thing is, and
382
420
  it reaches anyone who opens the file in any client:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pmtiles-swarm",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "BitTorrent distribution for PMTiles map archives: create torrents, watch folders, publish and subscribe to RSS feeds, and seed through qBittorrent or an embedded client",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/config.js CHANGED
@@ -595,6 +595,12 @@ const DEFAULTS = {
595
595
  * that it takes the torrent with the data. Both are off unless set, only
596
596
  * ever touch archives this same folder imported, and never remove the
597
597
  * newest build however old it gets.
598
+ *
599
+ * `match` is a glob tested against the filename, for a folder holding more
600
+ * than one kind of build. Categories are decided per entry, so several
601
+ * entries can watch one folder and each take only its own archives:
602
+ * `{ path: '/out/pmtiles', match: 'monthly-*.pmtiles', categories: ['monthly'] }`.
603
+ * Without it every entry imports every archive, once under each category.
598
604
  */
599
605
  watch: [],
600
606
  /**
package/src/tile-stats.js CHANGED
@@ -103,6 +103,12 @@ export class TileStats {
103
103
  const { infoHash, z, status } = entry;
104
104
  if (!infoHash) return;
105
105
 
106
+ // A dual-stack listener reports IPv4 peers as ::ffff:172.16.1.2, which is
107
+ // the same client written two ways -- so counting it as written would
108
+ // split one address across two entries the moment anything reached the
109
+ // node over plain IPv4 as well.
110
+ const ip = entry.ip ? entry.ip.replace(/^::ffff:/, '') : entry.ip;
111
+
106
112
  let archive = this.#archives.get(infoHash);
107
113
  if (!archive) {
108
114
  archive = {
@@ -135,20 +141,20 @@ export class TileStats {
135
141
  if (Number.isFinite(status)) {
136
142
  archive.byStatus.set(status, (archive.byStatus.get(status) ?? 0) + 1);
137
143
  }
138
- if (entry.ip) {
144
+ if (ip) {
139
145
  // Counted rather than listed: a busy node sees a handful of distinct
140
146
  // sources — the proxy, a few LAN clients — and the count is the answer
141
147
  // to "is this arriving directly or through HAProxy".
142
- const seen = archive.clients.get(entry.ip) ?? { requests: 0, bytes: 0 };
148
+ const seen = archive.clients.get(ip) ?? { requests: 0, bytes: 0 };
143
149
  seen.requests += 1;
144
150
  seen.bytes += bytes;
145
- archive.clients.set(entry.ip, seen);
151
+ archive.clients.set(ip, seen);
146
152
  }
147
153
 
148
154
  if (this.#limit === 0) return;
149
155
  const row = {
150
156
  at: archive.lastSeen,
151
- ip: entry.ip ?? null,
157
+ ip: ip ?? null,
152
158
  infoHash,
153
159
  name: archive.name ?? null,
154
160
  z: Number.isInteger(z) ? z : null,
package/src/watch.js CHANGED
@@ -14,6 +14,30 @@ import { retains, retire } from './retention.js';
14
14
  * deliberately generous, because a stalled network copy can pause for a long
15
15
  * time mid-file.
16
16
  */
17
+ /**
18
+ * Compiles a shell-style glob into an anchored regular expression.
19
+ *
20
+ * Deliberately a glob and not a regular expression: a watch folder's filter is
21
+ * something an operator writes in a JSON config beside a filename, and
22
+ * `monthly-*.pmtiles` is what they mean. Every other character is escaped, so
23
+ * a name containing regex punctuation matches itself rather than becoming a
24
+ * pattern by accident.
25
+ *
26
+ * @param {string} pattern - A glob, matched against the basename.
27
+ * @returns {RegExp} - Anchored, case-insensitive.
28
+ */
29
+ export function globToRegExp(pattern) {
30
+ const body = pattern
31
+ .split('')
32
+ .map((character) => {
33
+ if (character === '*') return '.*';
34
+ if (character === '?') return '.';
35
+ return character.replace(/[.+^${}()|[\]\\]/g, '\\$&');
36
+ })
37
+ .join('');
38
+ return new RegExp(`^${body}$`, 'i');
39
+ }
40
+
17
41
  export class WatchManager {
18
42
  #library;
19
43
  #watchers = [];
@@ -33,8 +57,33 @@ export class WatchManager {
33
57
  * @returns {void}
34
58
  */
35
59
  start(folders = []) {
60
+ // One watcher per directory, not per entry. Several entries describing one
61
+ // directory is the normal way to give each build its own category, and two
62
+ // chokidar instances over the same path do not reliably both report a
63
+ // file — so an archive could go unimported with nothing to say why.
64
+ // Grouping also makes the choice of entry deterministic: the first whose
65
+ // `match` accepts a name takes it, decided by config order rather than by
66
+ // whichever watcher fired first.
67
+ const groups = new Map();
36
68
  for (const folder of folders) {
37
- const stability = (folder.stabilitySeconds ?? 30) * 1000;
69
+ if (!groups.has(folder.path)) groups.set(folder.path, []);
70
+ groups.get(folder.path).push(folder);
71
+ }
72
+
73
+ for (const [directory, entries] of groups) {
74
+ // The watcher is shared, so its settings have to suit every entry:
75
+ // the longest stability threshold any of them asked for, and polling if
76
+ // any of them needs it, at the shortest interval requested.
77
+ const folder = {
78
+ ...entries[0],
79
+ stabilitySeconds: Math.max(...entries.map((e) => e.stabilitySeconds ?? 30)),
80
+ pollSeconds: Math.min(
81
+ ...entries.map((e) => e.pollSeconds ?? 0).filter((s) => s > 0),
82
+ ...(entries.some((e) => (e.pollSeconds ?? 0) > 0) ? [] : [0]),
83
+ ),
84
+ path: directory,
85
+ };
86
+ const stability = folder.stabilitySeconds * 1000;
38
87
 
39
88
  // A local directory needs no interval: the filesystem says when
40
89
  // something lands and the archive is picked up as it appears. A network
@@ -63,27 +112,44 @@ export class WatchManager {
63
112
  },
64
113
  });
65
114
 
115
+ const matchers = entries.map((entry) => ({
116
+ entry,
117
+ // A directory holding several kinds of build needs one entry each,
118
+ // because categories are decided per entry. `match` is what tells them
119
+ // apart; an entry without one takes anything not already claimed.
120
+ match: entry.match ? globToRegExp(entry.match) : null,
121
+ }));
122
+
66
123
  watcher.on('add', (file) => {
67
124
  if (!/\.pmtiles$/i.test(file)) return;
68
- // The folder's own `latestLink`, which is a .pmtiles in a watched
69
- // folder like any other and would otherwise be imported as a second
70
- // archive — a whole extra torrent for the same bytes under a name that
71
- // changes every build. A hard link is indistinguishable from the file
72
- // it names, so the name is the only thing that can tell them apart.
73
- if (this.#isLatestLink(file, folder)) return;
74
- this.#import(file, folder);
125
+
126
+ const name = path.basename(file);
127
+ const owner = matchers.find(({ entry, match }) => {
128
+ if (match && !match.test(name)) return false;
129
+ // The entry's own `latestLink`, a .pmtiles like any other that would
130
+ // otherwise be imported as a second archive — a whole extra torrent
131
+ // for the same bytes, under a name that changes every build. A hard
132
+ // link is indistinguishable from the file it names, so the name is
133
+ // the only thing that can tell them apart.
134
+ return !this.#isLatestLink(file, entry);
135
+ });
136
+
137
+ if (owner) this.#import(file, owner.entry);
75
138
  });
76
139
  watcher.on('error', (error) => {
77
- console.error(`[watch] ${folder.path}: ${error.message}`);
140
+ console.error(`[watch] ${directory}: ${error.message}`);
78
141
  });
79
142
 
80
143
  this.#watchers.push(watcher);
81
- const tags = normalizeCategories(folder);
82
- console.log(
83
- `[watch] watching ${folder.path}` +
84
- (tags.length > 0 ? ` as "${tags.join('", "')}"` : '') +
85
- (pollSeconds > 0 ? ` (polling every ${pollSeconds}s)` : ''),
86
- );
144
+ for (const entry of entries) {
145
+ const tags = normalizeCategories(entry);
146
+ console.log(
147
+ `[watch] watching ${directory}` +
148
+ (entry.match ? ` matching ${entry.match}` : '') +
149
+ (tags.length > 0 ? ` as "${tags.join('", "')}"` : '') +
150
+ (pollSeconds > 0 ? ` (polling every ${pollSeconds}s)` : ''),
151
+ );
152
+ }
87
153
  }
88
154
  }
89
155
 
@@ -3408,6 +3408,11 @@
3408
3408
  ...(config.locations ?? []).map((entry) => [entry.path, entry.name]),
3409
3409
  ],
3410
3410
  },
3411
+ {
3412
+ field: 'match',
3413
+ label: 'Only files matching',
3414
+ placeholder: 'everything',
3415
+ },
3411
3416
  { field: 'publishDir', label: 'Publish to', placeholder: '/var/www/pmtiles' },
3412
3417
  { field: 'webSeedBase', label: 'Web seed base', placeholder: 'https://…/files' },
3413
3418
  {