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 +26 -0
- package/README.md +3 -1
- package/docs/publishing.md +38 -0
- package/package.json +1 -1
- package/src/config.js +6 -0
- package/src/tile-stats.js +10 -4
- package/src/watch.js +81 -15
- package/src/web/index.html +5 -0
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
|
package/docs/publishing.md
CHANGED
|
@@ -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.
|
|
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 (
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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] ${
|
|
140
|
+
console.error(`[watch] ${directory}: ${error.message}`);
|
|
78
141
|
});
|
|
79
142
|
|
|
80
143
|
this.#watchers.push(watcher);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
package/src/web/index.html
CHANGED
|
@@ -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
|
{
|