tileserver-gl 5.7.0-pre.0 → 5.7.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 +12 -1
- package/docs/config.rst +43 -2
- package/docs/usage.rst +2 -1
- package/package.json +33 -28
- package/public/resources/elevation-control.js +8 -4
- package/public/resources/maplibre-gl-inspect.mjs +2286 -0
- package/public/resources/maplibre-gl-inspect.mjs.map +1 -0
- package/public/resources/maplibre-gl-shared.mjs +6 -0
- package/public/resources/maplibre-gl-shared.mjs.map +1 -0
- package/public/resources/maplibre-gl-worker.mjs +10 -0
- package/public/resources/maplibre-gl-worker.mjs.map +1 -0
- package/public/resources/maplibre-gl.css +1 -1
- package/public/resources/maplibre-gl.mjs +819 -0
- package/public/resources/maplibre-gl.mjs.map +1 -0
- package/public/templates/data.tmpl +12 -6
- package/public/templates/viewer.tmpl +12 -8
- package/src/main.js +53 -7
- package/src/render_mode.js +38 -0
- package/src/serve_data.js +29 -11
- package/src/serve_rendered.js +317 -249
- package/src/server.js +10 -10
- package/src/utils.js +51 -0
- package/test/data_decorator.js +156 -0
- package/test/fixtures/data-decorator-async.js +17 -0
- package/test/fixtures/data-decorator.js +29 -0
- package/test/fixtures/visual/static-512px-path-alignment-2x.png +0 -0
- package/test/fixtures/visual/static-marker-low-zoom-large-viewport.png +0 -0
- package/test/render_mode.js +48 -0
- package/test/sparse.js +411 -0
- package/test/static.js +20 -0
- package/test/static_images.js +10 -0
- package/test/tiles_data_compression.js +145 -0
- package/public/resources/maplibre-gl-inspect.js +0 -2934
- package/public/resources/maplibre-gl-inspect.js.map +0 -1
- package/public/resources/maplibre-gl.js +0 -59
- package/public/resources/maplibre-gl.js.map +0 -1
package/src/server.js
CHANGED
|
@@ -23,13 +23,9 @@ import {
|
|
|
23
23
|
getPublicUrl,
|
|
24
24
|
isValidHttpUrl,
|
|
25
25
|
isValidRemoteUrl,
|
|
26
|
-
parseAllowedHosts,
|
|
27
|
-
isHostAllowed,
|
|
28
|
-
getCandidateHost,
|
|
29
|
-
getSafeProtocol,
|
|
30
26
|
} from './utils.js';
|
|
31
27
|
|
|
32
|
-
import { fileURLToPath } from 'url';
|
|
28
|
+
import { fileURLToPath, pathToFileURL } from 'url';
|
|
33
29
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
34
30
|
const packageJson = JSON.parse(
|
|
35
31
|
fs.readFileSync(__dirname + '/../package.json', 'utf8'),
|
|
@@ -194,7 +190,10 @@ async function start(opts) {
|
|
|
194
190
|
try {
|
|
195
191
|
const dataDecoratorPath = path.resolve(paths.root, options.dataDecorator);
|
|
196
192
|
|
|
197
|
-
|
|
193
|
+
// import() needs a file:// URL, not a bare path: on Windows an absolute
|
|
194
|
+
// path starts with a drive letter and the ESM loader rejects it as an
|
|
195
|
+
// unknown URL scheme, so the decorator silently never loaded there.
|
|
196
|
+
const module = await import(pathToFileURL(dataDecoratorPath).href);
|
|
198
197
|
options.dataDecoratorFunc = module.default;
|
|
199
198
|
} catch (e) {
|
|
200
199
|
console.error(`Error loading data decorator: ${e}`);
|
|
@@ -452,9 +451,10 @@ async function start(opts) {
|
|
|
452
451
|
resolvedS3Region = sourceData.s3Region;
|
|
453
452
|
}
|
|
454
453
|
|
|
455
|
-
//
|
|
456
|
-
|
|
457
|
-
|
|
454
|
+
// Pass the source's own setting through untouched. The
|
|
455
|
+
// global option and the format-based default are applied
|
|
456
|
+
// by resolveSparse() where the tile format is known.
|
|
457
|
+
resolvedSparse = sourceData.sparse;
|
|
458
458
|
|
|
459
459
|
break; // Found our match, exit the outer loop
|
|
460
460
|
}
|
|
@@ -482,7 +482,7 @@ async function start(opts) {
|
|
|
482
482
|
requestPayer: false,
|
|
483
483
|
s3Region: undefined,
|
|
484
484
|
s3UrlFormat: undefined,
|
|
485
|
-
sparse:
|
|
485
|
+
sparse: undefined,
|
|
486
486
|
};
|
|
487
487
|
}
|
|
488
488
|
|
package/src/utils.js
CHANGED
|
@@ -666,3 +666,54 @@ export async function fetchTileData(source, sourceType, z, x, y) {
|
|
|
666
666
|
});
|
|
667
667
|
}
|
|
668
668
|
}
|
|
669
|
+
|
|
670
|
+
/**
|
|
671
|
+
* Reads a flag that may arrive as a boolean or as text.
|
|
672
|
+
*
|
|
673
|
+
* mbtiles metadata is a table of strings, so a `sparse` row reaches us as
|
|
674
|
+
* `"false"` - truthy if taken at face value. PMTiles metadata is JSON and can
|
|
675
|
+
* hold a real boolean. Anything unrecognised is treated as absent rather than
|
|
676
|
+
* guessed at, so a typo falls through to the next level of precedence instead
|
|
677
|
+
* of silently meaning `true`.
|
|
678
|
+
* @param {unknown} value - The raw value.
|
|
679
|
+
* @returns {boolean|undefined} The boolean, or undefined if absent/unrecognised.
|
|
680
|
+
*/
|
|
681
|
+
export function parseOptionalBoolean(value) {
|
|
682
|
+
if (typeof value === 'boolean') {
|
|
683
|
+
return value;
|
|
684
|
+
}
|
|
685
|
+
if (typeof value === 'string') {
|
|
686
|
+
const normalized = value.trim().toLowerCase();
|
|
687
|
+
if (normalized === 'true' || normalized === '1') {
|
|
688
|
+
return true;
|
|
689
|
+
}
|
|
690
|
+
if (normalized === 'false' || normalized === '0') {
|
|
691
|
+
return false;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return undefined;
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* Resolves whether a source should answer a missing tile sparsely.
|
|
699
|
+
*
|
|
700
|
+
* Precedence, highest first: the per-source config setting, the global config
|
|
701
|
+
* option, the archive's own metadata, then a format-based default - raster
|
|
702
|
+
* sources are sparse so MapLibre overzooms from the parent, vector sources are
|
|
703
|
+
* not so an empty tile stops the overzoom. Config outranks metadata because an
|
|
704
|
+
* operator can change their config but not always the archive.
|
|
705
|
+
*
|
|
706
|
+
* Nullish coalescing is deliberate: an explicit `false` at any level must win
|
|
707
|
+
* over the level below it. Named arguments, because four booleans in a row is
|
|
708
|
+
* an argument-order bug waiting to happen and two of the levels are often
|
|
709
|
+
* absent.
|
|
710
|
+
* @param {object} levels - The values to resolve between.
|
|
711
|
+
* @param {boolean} [levels.perSource] - The source's own `sparse` config setting.
|
|
712
|
+
* @param {boolean} [levels.globalOption] - The top-level `sparse` option.
|
|
713
|
+
* @param {boolean} [levels.metadata] - `sparse` from the archive's metadata.
|
|
714
|
+
* @param {boolean} levels.isVector - Whether the source serves vector tiles.
|
|
715
|
+
* @returns {boolean} True when a missing tile should answer 404 rather than 204.
|
|
716
|
+
*/
|
|
717
|
+
export function resolveSparse({ perSource, globalOption, metadata, isVector }) {
|
|
718
|
+
return perSource ?? globalOption ?? metadata ?? !isVector;
|
|
719
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { server } from '../src/server.js';
|
|
2
|
+
|
|
3
|
+
const SIGNALS = ['SIGHUP', 'SIGINT', 'SIGTERM'];
|
|
4
|
+
|
|
5
|
+
// Resolved against paths.root, which is the cwd (test_data) when the config is
|
|
6
|
+
// passed as an object rather than a file.
|
|
7
|
+
const DECORATOR = '../test/fixtures/data-decorator.js';
|
|
8
|
+
|
|
9
|
+
describe('data decorator', function () {
|
|
10
|
+
let running;
|
|
11
|
+
let listenerBaseline;
|
|
12
|
+
|
|
13
|
+
before(async function () {
|
|
14
|
+
globalThis.__dataDecoratorCalls = [];
|
|
15
|
+
listenerBaseline = SIGNALS.map((eventName) => ({
|
|
16
|
+
eventName,
|
|
17
|
+
count: process.listeners(eventName).length,
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
running = await server({
|
|
21
|
+
config: {
|
|
22
|
+
options: {
|
|
23
|
+
paths: { fonts: 'fonts', styles: 'styles' },
|
|
24
|
+
dataDecorator: DECORATOR,
|
|
25
|
+
},
|
|
26
|
+
// The style pulls in the same source through a mbtiles:// url, which
|
|
27
|
+
// is the path serve_rendered decorates.
|
|
28
|
+
styles: { 'test-style': { style: 'osm-bright/style.json' } },
|
|
29
|
+
data: { openmaptiles: { mbtiles: 'zurich_switzerland.mbtiles' } },
|
|
30
|
+
},
|
|
31
|
+
port: 0,
|
|
32
|
+
publicUrl: '/test/',
|
|
33
|
+
});
|
|
34
|
+
await running.startupPromise;
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
after(async function () {
|
|
38
|
+
await running.cleanup();
|
|
39
|
+
if (running.server.listening) {
|
|
40
|
+
await new Promise((resolve) => running.server.close(resolve));
|
|
41
|
+
}
|
|
42
|
+
for (const { eventName, count } of listenerBaseline) {
|
|
43
|
+
for (const listener of process.listeners(eventName).slice(count)) {
|
|
44
|
+
process.removeListener(eventName, listener);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
delete globalThis.__dataDecoratorCalls;
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
// Regression test: the decorator path is absolute, and on Windows that means
|
|
51
|
+
// it starts with a drive letter. import() rejects that as an unknown URL
|
|
52
|
+
// scheme, and the failure is swallowed by a try/catch, so the decorator was
|
|
53
|
+
// never called at all and the server started as though none was configured.
|
|
54
|
+
it('loads and is called for a configured data source', function () {
|
|
55
|
+
const calls = globalThis.__dataDecoratorCalls;
|
|
56
|
+
expect(calls).to.be.an('array');
|
|
57
|
+
expect(calls.length).to.be.greaterThan(0);
|
|
58
|
+
expect(calls.map((c) => c.id)).to.include('openmaptiles');
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('receives the TileJSON it is meant to decorate', function () {
|
|
62
|
+
const tilejsonCall = globalThis.__dataDecoratorCalls.find(
|
|
63
|
+
(c) => c.type === 'tilejson' && c.id === 'openmaptiles',
|
|
64
|
+
);
|
|
65
|
+
expect(tilejsonCall, 'no tilejson call recorded').to.not.equal(undefined);
|
|
66
|
+
expect(tilejsonCall.format).to.equal('pbf');
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('uses what the decorator returned, not the object it was handed', function (done) {
|
|
70
|
+
// The fixture returns a new object rather than mutating in place. Recording
|
|
71
|
+
// that the decorator ran is not enough - its return value has to be the one
|
|
72
|
+
// that reaches the response.
|
|
73
|
+
supertest(running.app)
|
|
74
|
+
.get('/data/openmaptiles.json')
|
|
75
|
+
.expect(200)
|
|
76
|
+
.expect((res) => {
|
|
77
|
+
expect(
|
|
78
|
+
res.body.decorated,
|
|
79
|
+
'decorator return value was dropped',
|
|
80
|
+
).to.equal(true);
|
|
81
|
+
expect(res.body.format, 'TileJSON lost its own fields').to.equal('pbf');
|
|
82
|
+
})
|
|
83
|
+
.end(done);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('is applied to the local sources a style pulls in', function () {
|
|
87
|
+
// serve_rendered decorates each local source of a style. That call carries
|
|
88
|
+
// the source object, whose tiles url is the internal mbtiles:// form - the
|
|
89
|
+
// only thing distinguishing it from the /data tilejson call for the same id.
|
|
90
|
+
const styleSourceCall = globalThis.__dataDecoratorCalls.find(
|
|
91
|
+
(c) =>
|
|
92
|
+
c.type === 'tilejson' &&
|
|
93
|
+
Array.isArray(c.tiles) &&
|
|
94
|
+
c.tiles.some((t) => t.startsWith('mbtiles://openmaptiles/')),
|
|
95
|
+
);
|
|
96
|
+
expect(styleSourceCall, 'style source was never decorated').to.not.equal(
|
|
97
|
+
undefined,
|
|
98
|
+
);
|
|
99
|
+
expect(styleSourceCall.id).to.equal('openmaptiles');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
describe('async data decorator', function () {
|
|
104
|
+
let running;
|
|
105
|
+
let listenerBaseline;
|
|
106
|
+
|
|
107
|
+
before(async function () {
|
|
108
|
+
listenerBaseline = SIGNALS.map((eventName) => ({
|
|
109
|
+
eventName,
|
|
110
|
+
count: process.listeners(eventName).length,
|
|
111
|
+
}));
|
|
112
|
+
running = await server({
|
|
113
|
+
config: {
|
|
114
|
+
options: {
|
|
115
|
+
paths: { fonts: 'fonts', styles: 'styles' },
|
|
116
|
+
dataDecorator: '../test/fixtures/data-decorator-async.js',
|
|
117
|
+
},
|
|
118
|
+
data: { openmaptiles: { mbtiles: 'zurich_switzerland.mbtiles' } },
|
|
119
|
+
},
|
|
120
|
+
port: 0,
|
|
121
|
+
publicUrl: '/test/',
|
|
122
|
+
});
|
|
123
|
+
await running.startupPromise;
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
after(async function () {
|
|
127
|
+
await running.cleanup();
|
|
128
|
+
if (running.server.listening) {
|
|
129
|
+
await new Promise((resolve) => running.server.close(resolve));
|
|
130
|
+
}
|
|
131
|
+
for (const { eventName, count } of listenerBaseline) {
|
|
132
|
+
for (const listener of process.listeners(eventName).slice(count)) {
|
|
133
|
+
process.removeListener(eventName, listener);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Without an await at the call site the decorator's promise is assigned
|
|
139
|
+
// straight to the TileJSON, which then serves as {tiles: [...]} with every
|
|
140
|
+
// other field gone - a 200 carrying silently emptied data.
|
|
141
|
+
it('awaits a decorator that returns a promise', function (done) {
|
|
142
|
+
supertest(running.app)
|
|
143
|
+
.get('/data/openmaptiles.json')
|
|
144
|
+
.expect(200)
|
|
145
|
+
.expect((res) => {
|
|
146
|
+
expect(
|
|
147
|
+
res.body.decoratedAsync,
|
|
148
|
+
'async decorator was not awaited',
|
|
149
|
+
).to.equal(true);
|
|
150
|
+
expect(res.body.format, 'TileJSON was replaced by a promise').to.equal(
|
|
151
|
+
'pbf',
|
|
152
|
+
);
|
|
153
|
+
})
|
|
154
|
+
.end(done);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Async data decorator fixture. Resolves on a later tick before returning, so
|
|
2
|
+
// a caller that does not await it gets a promise rather than the data.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Marks the TileJSON after yielding to the event loop.
|
|
6
|
+
* @param {string} id - The data source id.
|
|
7
|
+
* @param {string} type - What is being decorated, e.g. 'tilejson' or 'data'.
|
|
8
|
+
* @param {unknown} data - The value being decorated.
|
|
9
|
+
* @returns {Promise<unknown>} The data, marked when it is TileJSON.
|
|
10
|
+
*/
|
|
11
|
+
export default async function decorate(id, type, data) {
|
|
12
|
+
await new Promise((resolve) => setTimeout(resolve, 1));
|
|
13
|
+
if (type === 'tilejson' && data && typeof data === 'object') {
|
|
14
|
+
return { ...data, decoratedAsync: true };
|
|
15
|
+
}
|
|
16
|
+
return data;
|
|
17
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Data decorator fixture. Records every call on globalThis so the test can
|
|
2
|
+
// assert what the server handed it, then passes the data through untouched.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Records the call and returns the data unchanged.
|
|
6
|
+
* @param {string} id - The data source id.
|
|
7
|
+
* @param {string} type - What is being decorated, e.g. 'tilejson' or 'data'.
|
|
8
|
+
* @param {unknown} data - The value being decorated.
|
|
9
|
+
* @returns {unknown} The data, unmodified.
|
|
10
|
+
*/
|
|
11
|
+
export default function decorate(id, type, data) {
|
|
12
|
+
globalThis.__dataDecoratorCalls = globalThis.__dataDecoratorCalls || [];
|
|
13
|
+
globalThis.__dataDecoratorCalls.push({
|
|
14
|
+
id,
|
|
15
|
+
type,
|
|
16
|
+
// Snapshot rather than store the live object, which the server mutates
|
|
17
|
+
// after the decorator returns.
|
|
18
|
+
sparse: data && data.sparse,
|
|
19
|
+
sparseType: typeof (data && data.sparse),
|
|
20
|
+
tiles: data && data.tiles,
|
|
21
|
+
format: data && data.format,
|
|
22
|
+
});
|
|
23
|
+
// Return a *new* object for tilejson, so a caller that only reassigns its
|
|
24
|
+
// local variable visibly loses the change.
|
|
25
|
+
if (type === 'tilejson' && data && typeof data === 'object') {
|
|
26
|
+
return { ...data, decorated: true };
|
|
27
|
+
}
|
|
28
|
+
return data;
|
|
29
|
+
}
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import {
|
|
3
|
+
getMapLibreRenderZoom,
|
|
4
|
+
getStaticOverlayZoom,
|
|
5
|
+
} from '../src/render_mode.js';
|
|
6
|
+
|
|
7
|
+
describe('MapLibre render zoom', function () {
|
|
8
|
+
it('uses the overlay-aligned zoom for a 512px static map', function () {
|
|
9
|
+
assert.equal(getMapLibreRenderZoom(12, 512, 'static'), 11);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('uses the overlay-aligned zoom for a 400px static map', function () {
|
|
13
|
+
assert.equal(getMapLibreRenderZoom(12, 400, 'static'), 11);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('uses the 256px tile zoom for a 256px tile', function () {
|
|
17
|
+
assert.equal(getMapLibreRenderZoom(12, 256, 'tile'), 11);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it('uses the 512px tile zoom for a 512px tile', function () {
|
|
21
|
+
assert.equal(getMapLibreRenderZoom(12, 512, 'tile'), 12);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('rejects an unsupported render mode', function () {
|
|
25
|
+
assert.throws(
|
|
26
|
+
() => getMapLibreRenderZoom(12, 512, 'preview'),
|
|
27
|
+
/Unsupported render mode: preview/,
|
|
28
|
+
);
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
describe('Static overlay zoom', function () {
|
|
33
|
+
it('raises the zoom when the world would not fill the viewport height', function () {
|
|
34
|
+
assert.equal(getStaticOverlayZoom(1, 600, 600), Math.log2(600 / 256));
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('does not raise the zoom for a wide but short viewport', function () {
|
|
38
|
+
assert.equal(getStaticOverlayZoom(1, 1000, 500), 1);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('preserves a requested zoom above the viewport minimum', function () {
|
|
42
|
+
assert.equal(getStaticOverlayZoom(12, 1000, 1000), 12);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('preserves the dedicated 256px zoom-zero render path', function () {
|
|
46
|
+
assert.equal(getStaticOverlayZoom(0, 256, 256), 0);
|
|
47
|
+
});
|
|
48
|
+
});
|