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.
Files changed (36) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/docs/config.rst +43 -2
  3. package/docs/usage.rst +2 -1
  4. package/package.json +33 -28
  5. package/public/resources/elevation-control.js +8 -4
  6. package/public/resources/maplibre-gl-inspect.mjs +2286 -0
  7. package/public/resources/maplibre-gl-inspect.mjs.map +1 -0
  8. package/public/resources/maplibre-gl-shared.mjs +6 -0
  9. package/public/resources/maplibre-gl-shared.mjs.map +1 -0
  10. package/public/resources/maplibre-gl-worker.mjs +10 -0
  11. package/public/resources/maplibre-gl-worker.mjs.map +1 -0
  12. package/public/resources/maplibre-gl.css +1 -1
  13. package/public/resources/maplibre-gl.mjs +819 -0
  14. package/public/resources/maplibre-gl.mjs.map +1 -0
  15. package/public/templates/data.tmpl +12 -6
  16. package/public/templates/viewer.tmpl +12 -8
  17. package/src/main.js +53 -7
  18. package/src/render_mode.js +38 -0
  19. package/src/serve_data.js +29 -11
  20. package/src/serve_rendered.js +317 -249
  21. package/src/server.js +10 -10
  22. package/src/utils.js +51 -0
  23. package/test/data_decorator.js +156 -0
  24. package/test/fixtures/data-decorator-async.js +17 -0
  25. package/test/fixtures/data-decorator.js +29 -0
  26. package/test/fixtures/visual/static-512px-path-alignment-2x.png +0 -0
  27. package/test/fixtures/visual/static-marker-low-zoom-large-viewport.png +0 -0
  28. package/test/render_mode.js +48 -0
  29. package/test/sparse.js +411 -0
  30. package/test/static.js +20 -0
  31. package/test/static_images.js +10 -0
  32. package/test/tiles_data_compression.js +145 -0
  33. package/public/resources/maplibre-gl-inspect.js +0 -2934
  34. package/public/resources/maplibre-gl-inspect.js.map +0 -1
  35. package/public/resources/maplibre-gl.js +0 -59
  36. 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
- const module = await import(dataDecoratorPath);
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
- // Get sparse: per-source overrides global, default to true
456
- resolvedSparse =
457
- sourceData.sparse ?? options.sparse ?? true;
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: true,
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
+ }
@@ -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
+ });