map-zero 0.1.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 (52) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/LICENSE +21 -0
  3. package/README.md +220 -0
  4. package/docs/api.md +66 -0
  5. package/docs/architecture.md +87 -0
  6. package/docs/cartography.md +77 -0
  7. package/docs/cesium.md +107 -0
  8. package/docs/openlayers.md +98 -0
  9. package/docs/styles.md +103 -0
  10. package/package.json +51 -0
  11. package/packages/cesium/package.json +13 -0
  12. package/packages/cesium/src/index.js +405 -0
  13. package/packages/ol/package.json +14 -0
  14. package/packages/ol/src/index.js +1705 -0
  15. package/packages/ol/src/labels.js +977 -0
  16. package/src/3dtiles/b3dm.js +38 -0
  17. package/src/3dtiles/clipper-surfaces.js +317 -0
  18. package/src/3dtiles/export.js +768 -0
  19. package/src/3dtiles/extrude.js +301 -0
  20. package/src/3dtiles/flat.js +531 -0
  21. package/src/3dtiles/glb.js +178 -0
  22. package/src/3dtiles/gpkg-buildings.js +240 -0
  23. package/src/3dtiles/gpkg-features.js +157 -0
  24. package/src/3dtiles/tileset.js +75 -0
  25. package/src/build.js +134 -0
  26. package/src/cli.js +656 -0
  27. package/src/export-pmtiles.js +962 -0
  28. package/src/geometry-read.js +50 -0
  29. package/src/gpkg-read.js +460 -0
  30. package/src/gpkg.js +567 -0
  31. package/src/html.js +593 -0
  32. package/src/layers.js +357 -0
  33. package/src/manifest.js +29 -0
  34. package/src/mvt.js +2593 -0
  35. package/src/ol.js +5 -0
  36. package/src/osm.js +2110 -0
  37. package/src/pmtiles-worker.js +70 -0
  38. package/src/pmtiles.js +260 -0
  39. package/src/server.js +720 -0
  40. package/src/style-command.js +78 -0
  41. package/src/style-filters.js +76 -0
  42. package/src/style-presets.js +93 -0
  43. package/src/style-themes.js +235 -0
  44. package/src/style.js +13 -0
  45. package/src/tile-cache.js +59 -0
  46. package/src/utils.js +222 -0
  47. package/styles/presets/light.json +4655 -0
  48. package/styles/presets/monochrome.json +4655 -0
  49. package/styles/presets/neon-dark-3d.json +90 -0
  50. package/styles/presets/neon-dark.json +4690 -0
  51. package/styles/presets/tactical.json +4690 -0
  52. package/styles/themes/neon-dark.theme.json +20 -0
package/src/server.js ADDED
@@ -0,0 +1,720 @@
1
+ import { promises as fs } from 'node:fs';
2
+ import { spawn } from 'node:child_process';
3
+ import { dirname, extname, join, resolve } from 'node:path';
4
+
5
+ import Fastify from 'fastify';
6
+
7
+ import { openGeoPackageReader } from './gpkg-read.js';
8
+ import { createCesiumViewerHtml, createViewerHtml } from './html.js';
9
+ import { encodeMvtTileSetWithStats, encodeMvtTileWithStats } from './mvt.js';
10
+ import { createHiddenFilters } from './style-filters.js';
11
+ import { TileCache } from './tile-cache.js';
12
+
13
+ /**
14
+ * @typedef {{
15
+ * buffer: Buffer,
16
+ * featureCount: number,
17
+ * originalFeatureCount: number,
18
+ * encodedFeatureCount: number,
19
+ * droppedFeatureCount: number,
20
+ * bbox: [number, number, number, number],
21
+ * layerNames: string[],
22
+ * emptyReason: string,
23
+ * originalVertexCount: number,
24
+ * simplifiedVertexCount: number,
25
+ * droppedSmallFeatures: number,
26
+ * simplificationTolerance: number
27
+ * }} TileGenerationResult
28
+ */
29
+
30
+ /**
31
+ * Create a readonly map-zero HTTP server.
32
+ *
33
+ * @param {{ packageDir: string, tileCache?: boolean, tileCacheSize?: number, tileMaxFeatures?: number, debugTiles?: boolean, debugLabels?: boolean }} options
34
+ * @returns {Promise<import('fastify').FastifyInstance>}
35
+ */
36
+ export async function createMapZeroServer(options) {
37
+ const packageDir = resolve(options.packageDir);
38
+ const manifestPath = join(packageDir, 'manifest.json');
39
+ const stylesDir = join(packageDir, 'styles');
40
+ const manifest = await readJsonFile(manifestPath);
41
+ const gpkgPath = join(packageDir, String(manifest.data ?? 'data.gpkg'));
42
+ const defaultStyle = await readDefaultStyle(packageDir, manifest);
43
+
44
+ validateManifest(manifest);
45
+ await assertReadableFile(gpkgPath, 'GeoPackage');
46
+
47
+ const reader = openGeoPackageReader({
48
+ gpkgPath,
49
+ manifest,
50
+ hiddenFilters: createHiddenFilters(manifest, defaultStyle)
51
+ });
52
+ const assetVersion = String(Date.now());
53
+ const tileCache = options.tileCache === false
54
+ ? null
55
+ : new TileCache(options.tileCacheSize ?? 500);
56
+ /** @type {Map<string, Promise<TileGenerationResult>>} */
57
+ const pendingTiles = new Map();
58
+ const app = Fastify({
59
+ logger: false
60
+ });
61
+
62
+ app.addHook('onClose', async () => {
63
+ tileCache?.clear();
64
+ pendingTiles.clear();
65
+ reader.close();
66
+ });
67
+
68
+ app.setErrorHandler((error, request, reply) => {
69
+ const statusCode = Number(error.statusCode) || 500;
70
+ reply.status(statusCode).send({
71
+ error: statusCode >= 500 ? 'internal server error' : error.message
72
+ });
73
+ });
74
+
75
+ app.get('/', async (request, reply) => {
76
+ reply
77
+ .header('cache-control', 'no-store')
78
+ .type('text/html; charset=utf-8')
79
+ .send(createViewerHtml({ assetVersion }));
80
+ });
81
+
82
+ app.get('/cesium', async (request, reply) => {
83
+ reply
84
+ .header('cache-control', 'no-store')
85
+ .type('text/html; charset=utf-8')
86
+ .send(createCesiumViewerHtml({ assetVersion }));
87
+ });
88
+
89
+ app.get('/map-zero-ol.js', async (request, reply) => {
90
+ const moduleSource = await fs.readFile(new URL('../packages/ol/src/index.js', import.meta.url), 'utf8');
91
+ const cacheBustedSource = moduleSource.replace(
92
+ "from './labels.js';",
93
+ `from './labels.js?v=${encodeURIComponent(assetVersion)}';`
94
+ );
95
+ reply
96
+ .header('cache-control', 'no-store')
97
+ .type('text/javascript; charset=utf-8')
98
+ .send(cacheBustedSource);
99
+ });
100
+
101
+ app.get('/labels.js', async (request, reply) => {
102
+ const moduleSource = await fs.readFile(new URL('../packages/ol/src/labels.js', import.meta.url), 'utf8');
103
+ reply
104
+ .header('cache-control', 'no-store')
105
+ .type('text/javascript; charset=utf-8')
106
+ .send(moduleSource);
107
+ });
108
+
109
+ app.get('/vendor/pmtiles.js', async (request, reply) => {
110
+ const moduleSource = await fs.readFile(new URL('../node_modules/pmtiles/dist/esm/index.js', import.meta.url), 'utf8');
111
+ reply
112
+ .header('cache-control', 'public, max-age=31536000, immutable')
113
+ .type('text/javascript; charset=utf-8')
114
+ .send(moduleSource);
115
+ });
116
+
117
+ app.get('/vendor/fflate.js', async (request, reply) => {
118
+ const moduleSource = await fs.readFile(new URL('../node_modules/fflate/esm/browser.js', import.meta.url), 'utf8');
119
+ reply
120
+ .header('cache-control', 'public, max-age=31536000, immutable')
121
+ .type('text/javascript; charset=utf-8')
122
+ .send(moduleSource);
123
+ });
124
+
125
+ app.get('/map-zero-cesium.js', async (request, reply) => {
126
+ const moduleSource = await fs.readFile(new URL('../packages/cesium/src/index.js', import.meta.url), 'utf8');
127
+ const browserSource = moduleSource.replace(
128
+ "import {\n Cesium3DTileStyle,\n Cesium3DTileset\n} from 'cesium';",
129
+ "const { Cesium3DTileStyle, Cesium3DTileset } = globalThis.Cesium;"
130
+ );
131
+ reply
132
+ .header('cache-control', 'no-store')
133
+ .type('text/javascript; charset=utf-8')
134
+ .send(browserSource);
135
+ });
136
+
137
+ app.get('/manifest.json', async (request, reply) => {
138
+ reply.header('cache-control', 'no-store').send(manifest);
139
+ });
140
+
141
+ const pmtilesPath = pmtilesArchivePath(packageDir, manifest);
142
+ if (pmtilesPath) {
143
+ app.get(`/${pmtilesPath.url}`, async (request, reply) => {
144
+ await sendRangeFile(request, reply, pmtilesPath.path, 'application/vnd.pmtiles');
145
+ });
146
+ }
147
+
148
+ const tiles3dPath = tiles3dBasePath(packageDir, manifest);
149
+ if (tiles3dPath) {
150
+ app.get(`/${tiles3dPath.urlPrefix}/*`, async (request, reply) => {
151
+ const params = /** @type {Record<string, string>} */ (request.params);
152
+ const relativePath = params['*'] ?? '';
153
+ const filePath = safeJoin(tiles3dPath.pathPrefix, relativePath);
154
+ if (!filePath) {
155
+ reply.status(404).send({ error: 'file not found' });
156
+ return;
157
+ }
158
+
159
+ await sendRangeFile(request, reply, filePath, contentTypeFor3dTiles(filePath));
160
+ });
161
+ }
162
+
163
+ app.get('/styles/:name', async (request, reply) => {
164
+ const { name } = /** @type {{ name: string }} */ (request.params);
165
+ const safeName = validateStyleName(name);
166
+ const stylePath = join(stylesDir, safeName);
167
+ const style = await readJsonFile(stylePath).catch(() => null);
168
+
169
+ if (!style) {
170
+ reply.status(404).send({ error: `style not found: ${safeName}` });
171
+ return;
172
+ }
173
+
174
+ reply.header('cache-control', 'no-store').send(style);
175
+ });
176
+
177
+ app.get('/api/info', async (request, reply) => {
178
+ reply.header('cache-control', 'no-store').send(reader.getInfo(packageDir));
179
+ });
180
+
181
+ app.get('/api/layers', async (request, reply) => {
182
+ reply.header('cache-control', 'no-store').send({
183
+ layers: reader.getLayers()
184
+ });
185
+ });
186
+
187
+ app.get('/api/tiles/:z/:x/:y.mvt', async (request, reply) => {
188
+ const { z, x, y } = /** @type {{ z: string, x: string, y: string }} */ (request.params);
189
+ const { layers, detail } = /** @type {{ layers?: string, detail?: string }} */ (request.query);
190
+ const layerIds = parseTileLayerQuery(layers);
191
+ const tileDetail = parseTileDetailQuery(detail);
192
+ const resolvedDetail = resolveTileDetail(z, tileDetail);
193
+ const label = tileLayerLabel(layerIds);
194
+
195
+ await sendMvtTile(reply, {
196
+ cacheKey: createTileCacheKey(packageDir, label, z, x, y, resolvedDetail),
197
+ cacheLabel: label,
198
+ z,
199
+ x,
200
+ y,
201
+ detail: resolvedDetail,
202
+ tileCache,
203
+ pendingTiles,
204
+ debugTiles: Boolean(options.debugTiles),
205
+ generate: () => encodeMvtTileSetWithStats(reader, z, x, y, layerIds, {
206
+ detail: resolvedDetail,
207
+ maxFeatures: options.tileMaxFeatures,
208
+ style: defaultStyle,
209
+ debugLabels: Boolean(options.debugLabels)
210
+ })
211
+ });
212
+ });
213
+
214
+ app.get('/api/tiles/:layer/:z/:x/:y.mvt', async (request, reply) => {
215
+ const { layer, z, x, y } = /** @type {{ layer: string, z: string, x: string, y: string }} */ (request.params);
216
+ const { detail } = /** @type {{ detail?: string }} */ (request.query);
217
+ const tileDetail = parseTileDetailQuery(detail);
218
+ const resolvedDetail = resolveTileDetail(z, tileDetail);
219
+
220
+ await sendMvtTile(reply, {
221
+ cacheKey: createTileCacheKey(packageDir, layer, z, x, y, resolvedDetail),
222
+ cacheLabel: layer,
223
+ z,
224
+ x,
225
+ y,
226
+ detail: resolvedDetail,
227
+ tileCache,
228
+ pendingTiles,
229
+ debugTiles: Boolean(options.debugTiles),
230
+ generate: () => encodeMvtTileWithStats(reader, layer, z, x, y, {
231
+ detail: resolvedDetail,
232
+ maxFeatures: options.tileMaxFeatures,
233
+ style: defaultStyle,
234
+ debugLabels: Boolean(options.debugLabels)
235
+ })
236
+ });
237
+ });
238
+
239
+ return app;
240
+ }
241
+
242
+ /**
243
+ * Start a readonly map-zero HTTP server.
244
+ *
245
+ * @param {{
246
+ * packageDir: string,
247
+ * host: string,
248
+ * port: number,
249
+ * open?: boolean,
250
+ * tileCache?: boolean,
251
+ * tileCacheSize?: number,
252
+ * tileMaxFeatures?: number,
253
+ * debugTiles?: boolean,
254
+ * debugLabels?: boolean
255
+ * }} options
256
+ * @returns {Promise<{ app: import('fastify').FastifyInstance, url: string }>}
257
+ */
258
+ export async function serveMapZero(options) {
259
+ const app = await createMapZeroServer(options);
260
+ await app.listen({
261
+ host: options.host,
262
+ port: options.port
263
+ });
264
+
265
+ const url = `http://${options.host}:${options.port}`;
266
+ if (options.open) {
267
+ openBrowser(url);
268
+ }
269
+
270
+ return {
271
+ app,
272
+ url
273
+ };
274
+ }
275
+
276
+ /**
277
+ * Serve one MVT tile through cache and in-flight request coalescing.
278
+ *
279
+ * @param {import('fastify').FastifyReply} reply
280
+ * @param {{
281
+ * cacheKey: string,
282
+ * cacheLabel: string,
283
+ * z: string,
284
+ * x: string,
285
+ * y: string,
286
+ * detail: string,
287
+ * tileCache: TileCache | null,
288
+ * pendingTiles: Map<string, Promise<TileGenerationResult>>,
289
+ * debugTiles: boolean,
290
+ * generate: () => TileGenerationResult
291
+ * }} options
292
+ */
293
+ async function sendMvtTile(reply, options) {
294
+ const startedAt = performance.now();
295
+ const cached = /** @type {TileGenerationResult | undefined} */ (options.tileCache?.get(options.cacheKey));
296
+ if (cached) {
297
+ sendTileReply(reply, cached.buffer, 'hit');
298
+ logTileTiming(options, 'hit', startedAt, cached);
299
+ return;
300
+ }
301
+
302
+ const pending = options.pendingTiles.get(options.cacheKey);
303
+ if (pending) {
304
+ const result = await pending;
305
+ sendTileReply(reply, result.buffer, 'hit');
306
+ logTileTiming(options, 'hit', startedAt, result);
307
+ return;
308
+ }
309
+
310
+ const promise = Promise.resolve().then(options.generate);
311
+ options.pendingTiles.set(options.cacheKey, promise);
312
+
313
+ try {
314
+ const result = await promise;
315
+ options.tileCache?.set(options.cacheKey, result);
316
+ sendTileReply(reply, result.buffer, 'miss');
317
+ logTileTiming(options, 'miss', startedAt, result);
318
+ } finally {
319
+ options.pendingTiles.delete(options.cacheKey);
320
+ }
321
+ }
322
+
323
+ /**
324
+ * @param {import('fastify').FastifyReply} reply
325
+ * @param {Buffer} buffer
326
+ * @param {'hit' | 'miss'} cacheStatus
327
+ */
328
+ function sendTileReply(reply, buffer, cacheStatus) {
329
+ reply
330
+ .header('X-MapZero-Cache', cacheStatus)
331
+ .type('application/vnd.mapbox-vector-tile')
332
+ .send(buffer);
333
+ }
334
+
335
+ /**
336
+ * @param {{
337
+ * cacheLabel: string,
338
+ * z: string,
339
+ * x: string,
340
+ * y: string,
341
+ * detail: string,
342
+ * debugTiles: boolean
343
+ * }} options
344
+ * @param {'hit' | 'miss'} cacheStatus
345
+ * @param {number} startedAt
346
+ * @param {TileGenerationResult} result
347
+ */
348
+ function logTileTiming(options, cacheStatus, startedAt, result) {
349
+ if (!options.debugTiles) {
350
+ return;
351
+ }
352
+
353
+ const durationMs = Math.max(0, performance.now() - startedAt);
354
+ console.error(
355
+ `tile ${options.cacheLabel} ${options.z}/${options.x}/${options.y} ` +
356
+ `detail=${options.detail} cache=${cacheStatus} ` +
357
+ `bbox=${formatBbox(result.bbox)} layerNames=${result.layerNames.join(',')} ` +
358
+ `durationMs=${durationMs.toFixed(1)} originalFeatureCount=${result.originalFeatureCount} ` +
359
+ `encodedFeatureCount=${result.encodedFeatureCount} droppedFeatureCount=${result.droppedFeatureCount} ` +
360
+ `originalVertexCount=${result.originalVertexCount} simplifiedVertexCount=${result.simplifiedVertexCount} ` +
361
+ `droppedSmallFeatures=${result.droppedSmallFeatures} simplificationTolerance=${result.simplificationTolerance.toFixed(6)} ` +
362
+ `sizeBytes=${result.buffer.length} emptyReason=${result.emptyReason}`
363
+ );
364
+ }
365
+
366
+ /**
367
+ * @param {[number, number, number, number]} bbox
368
+ * @returns {string}
369
+ */
370
+ function formatBbox(bbox) {
371
+ return bbox.map((value) => Number(value).toFixed(6)).join(',');
372
+ }
373
+
374
+ /**
375
+ * @param {string} filePath
376
+ * @returns {Promise<Record<string, unknown>>}
377
+ */
378
+ async function readJsonFile(filePath) {
379
+ return JSON.parse(await fs.readFile(filePath, 'utf8'));
380
+ }
381
+
382
+ /**
383
+ * @param {string} packageDir
384
+ * @param {Record<string, unknown>} manifest
385
+ * @returns {Promise<Record<string, unknown> | null>}
386
+ */
387
+ async function readDefaultStyle(packageDir, manifest) {
388
+ const styles = /** @type {Record<string, unknown> | undefined} */ (manifest.styles);
389
+ const defaultStylePath = styles?.default;
390
+ if (typeof defaultStylePath !== 'string') {
391
+ return null;
392
+ }
393
+
394
+ return readJsonFile(join(packageDir, defaultStylePath)).catch(() => null);
395
+ }
396
+
397
+ /**
398
+ * @param {string} filePath
399
+ * @param {string} label
400
+ */
401
+ async function assertReadableFile(filePath, label) {
402
+ const stat = await fs.stat(filePath).catch(() => null);
403
+ if (!stat || !stat.isFile()) {
404
+ throw new Error(`${label} file does not exist: ${filePath}`);
405
+ }
406
+ }
407
+
408
+ /**
409
+ * @param {Record<string, unknown>} manifest
410
+ */
411
+ function validateManifest(manifest) {
412
+ if (manifest.format !== 'mapzero') {
413
+ throw new Error('manifest format must be mapzero');
414
+ }
415
+
416
+ if (!Array.isArray(manifest.layers)) {
417
+ throw new Error('manifest must contain a layers array');
418
+ }
419
+ }
420
+
421
+ /**
422
+ * @param {string} name
423
+ * @returns {string}
424
+ */
425
+ function validateStyleName(name) {
426
+ if (!/^[A-Za-z0-9._-]+$/.test(name)) {
427
+ throw httpError(400, 'invalid style name');
428
+ }
429
+
430
+ return name.endsWith('.json') ? name : `${name}.json`;
431
+ }
432
+
433
+ /**
434
+ * @param {string | undefined} value
435
+ * @returns {string[] | undefined}
436
+ */
437
+ function parseTileLayerQuery(value) {
438
+ if (!value) {
439
+ return undefined;
440
+ }
441
+
442
+ if (value === '__none__') {
443
+ return [];
444
+ }
445
+
446
+ const layers = value
447
+ .split(',')
448
+ .map((layer) => layer.trim())
449
+ .filter(Boolean);
450
+
451
+ if (layers.length === 0) {
452
+ return undefined;
453
+ }
454
+
455
+ for (const layer of layers) {
456
+ if (!/^[A-Za-z0-9_-]+$/.test(layer)) {
457
+ throw httpError(400, 'invalid tile layer list');
458
+ }
459
+ }
460
+
461
+ return [...new Set(layers)];
462
+ }
463
+
464
+ /**
465
+ * @param {string[] | undefined} layerIds
466
+ * @returns {string}
467
+ */
468
+ function tileLayerLabel(layerIds) {
469
+ if (!layerIds) {
470
+ return '*';
471
+ }
472
+
473
+ if (layerIds.length === 0) {
474
+ return '__none__';
475
+ }
476
+
477
+ return layerIds.join(',');
478
+ }
479
+
480
+ /**
481
+ * @param {string} packageDir
482
+ * @param {string} layerLabel
483
+ * @param {string} z
484
+ * @param {string} x
485
+ * @param {string} y
486
+ * @param {string} detail
487
+ * @returns {string}
488
+ */
489
+ function createTileCacheKey(packageDir, layerLabel, z, x, y, detail) {
490
+ return `${packageDir}|${layerLabel}|${z}/${x}/${y}|${detail}`;
491
+ }
492
+
493
+ /**
494
+ * @param {string | undefined} value
495
+ * @returns {string | undefined}
496
+ */
497
+ function parseTileDetailQuery(value) {
498
+ if (!value) {
499
+ return undefined;
500
+ }
501
+
502
+ if (!/^(overview|normal|full)$/.test(value)) {
503
+ throw httpError(400, 'invalid tile detail level');
504
+ }
505
+
506
+ return value;
507
+ }
508
+
509
+ /**
510
+ * @param {string} zValue
511
+ * @param {string | undefined} detail
512
+ * @returns {string}
513
+ */
514
+ function resolveTileDetail(zValue, detail) {
515
+ if (detail) {
516
+ return detail;
517
+ }
518
+
519
+ const z = Number(zValue);
520
+ if (Number.isFinite(z) && z <= 11) {
521
+ return 'overview';
522
+ }
523
+
524
+ if (Number.isFinite(z) && z <= 14) {
525
+ return 'normal';
526
+ }
527
+
528
+ return 'full';
529
+ }
530
+
531
+ /**
532
+ * @param {string} packageDir
533
+ * @param {Record<string, unknown>} manifest
534
+ * @returns {{ url: string, path: string } | null}
535
+ */
536
+ function pmtilesArchivePath(packageDir, manifest) {
537
+ const tiles = /** @type {{ format?: unknown, url?: unknown } | undefined} */ (manifest.tiles);
538
+ if (tiles?.format !== 'pmtiles' || typeof tiles.url !== 'string') {
539
+ return null;
540
+ }
541
+
542
+ const url = tiles.url.replace(/^\/+/, '');
543
+ if (!/^[A-Za-z0-9._/-]+$/.test(url) || url.includes('..')) {
544
+ return null;
545
+ }
546
+
547
+ return {
548
+ url,
549
+ path: join(packageDir, url)
550
+ };
551
+ }
552
+
553
+ /**
554
+ * @param {string} packageDir
555
+ * @param {Record<string, unknown>} manifest
556
+ * @returns {{ urlPrefix: string, pathPrefix: string } | null}
557
+ */
558
+ function tiles3dBasePath(packageDir, manifest) {
559
+ const cesiumTilesets = /** @type {{ tilesets?: Record<string, unknown> } | undefined} */ (manifest.cesium)?.tilesets;
560
+ if (cesiumTilesets && typeof cesiumTilesets === 'object' && Object.keys(cesiumTilesets).length > 0) {
561
+ return {
562
+ urlPrefix: '3dtiles',
563
+ pathPrefix: join(packageDir, '3dtiles')
564
+ };
565
+ }
566
+
567
+ const tiles3d = /** @type {{ format?: unknown, url?: unknown } | undefined} */ (manifest.tiles3d);
568
+ if (tiles3d?.format !== '3dtiles' || typeof tiles3d.url !== 'string') {
569
+ return null;
570
+ }
571
+
572
+ const url = tiles3d.url.replace(/^\/+/, '');
573
+ if (!/^[A-Za-z0-9._/-]+$/.test(url) || url.includes('..')) {
574
+ return null;
575
+ }
576
+
577
+ const urlPrefix = dirname(url).replaceAll('\\', '/');
578
+ if (!urlPrefix || urlPrefix === '.') {
579
+ return null;
580
+ }
581
+
582
+ return {
583
+ urlPrefix,
584
+ pathPrefix: join(packageDir, urlPrefix)
585
+ };
586
+ }
587
+
588
+ /**
589
+ * @param {string} baseDir
590
+ * @param {string} relativePath
591
+ * @returns {string | null}
592
+ */
593
+ function safeJoin(baseDir, relativePath) {
594
+ if (!/^[A-Za-z0-9._/-]+$/.test(relativePath) || relativePath.includes('..')) {
595
+ return null;
596
+ }
597
+
598
+ const base = resolve(baseDir);
599
+ const filePath = resolve(baseDir, relativePath);
600
+ return filePath.startsWith(`${base}/`) || filePath === base ? filePath : null;
601
+ }
602
+
603
+ /**
604
+ * @param {string} filePath
605
+ * @returns {string}
606
+ */
607
+ function contentTypeFor3dTiles(filePath) {
608
+ const ext = extname(filePath).toLowerCase();
609
+ if (ext === '.json') {
610
+ return 'application/json; charset=utf-8';
611
+ }
612
+
613
+ if (ext === '.b3dm') {
614
+ return 'application/octet-stream';
615
+ }
616
+
617
+ if (ext === '.glb') {
618
+ return 'model/gltf-binary';
619
+ }
620
+
621
+ return 'application/octet-stream';
622
+ }
623
+
624
+ /**
625
+ * @param {import('fastify').FastifyRequest} request
626
+ * @param {import('fastify').FastifyReply} reply
627
+ * @param {string} filePath
628
+ * @param {string} contentType
629
+ */
630
+ async function sendRangeFile(request, reply, filePath, contentType) {
631
+ const stat = await fs.stat(filePath).catch(() => null);
632
+ if (!stat?.isFile()) {
633
+ reply.status(404).send({ error: 'file not found' });
634
+ return;
635
+ }
636
+
637
+ if (contentType.startsWith('application/json')) {
638
+ const content = await fs.readFile(filePath, 'utf8');
639
+ reply
640
+ .header('cache-control', 'no-store')
641
+ .type(contentType)
642
+ .send(content);
643
+ return;
644
+ }
645
+
646
+ const range = request.headers.range;
647
+ reply
648
+ .header('cache-control', 'no-store')
649
+ .type(contentType);
650
+
651
+ if (typeof range !== 'string') {
652
+ reply
653
+ .header('accept-ranges', 'none')
654
+ .header('content-length', stat.size)
655
+ .send(await fs.readFile(filePath));
656
+ return;
657
+ }
658
+
659
+ const match = /^bytes=(\d*)-(\d*)$/.exec(range);
660
+ if (!match) {
661
+ reply.status(416).header('content-range', `bytes */${stat.size}`).send();
662
+ return;
663
+ }
664
+
665
+ const start = match[1] ? Number(match[1]) : 0;
666
+ const end = match[2] ? Number(match[2]) : stat.size - 1;
667
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || start >= stat.size) {
668
+ reply.status(416).header('content-range', `bytes */${stat.size}`).send();
669
+ return;
670
+ }
671
+
672
+ const boundedEnd = Math.min(end, stat.size - 1);
673
+ reply
674
+ .status(206)
675
+ .header('content-range', `bytes ${start}-${boundedEnd}/${stat.size}`)
676
+ .header('content-length', boundedEnd - start + 1)
677
+ .send(await readFileRange(filePath, start, boundedEnd));
678
+ }
679
+
680
+ /**
681
+ * @param {string} filePath
682
+ * @param {number} start
683
+ * @param {number} end
684
+ * @returns {Promise<Buffer>}
685
+ */
686
+ async function readFileRange(filePath, start, end) {
687
+ const handle = await fs.open(filePath, 'r');
688
+ try {
689
+ const buffer = Buffer.alloc(end - start + 1);
690
+ const result = await handle.read(buffer, 0, buffer.length, start);
691
+ return result.bytesRead === buffer.length ? buffer : buffer.subarray(0, result.bytesRead);
692
+ } finally {
693
+ await handle.close();
694
+ }
695
+ }
696
+
697
+ /**
698
+ * @param {string} url
699
+ */
700
+ function openBrowser(url) {
701
+ const platform = process.platform;
702
+ const command = platform === 'darwin' ? 'open' : platform === 'win32' ? 'cmd' : 'xdg-open';
703
+ const args = platform === 'win32' ? ['/c', 'start', '', url] : [url];
704
+ const child = spawn(command, args, {
705
+ detached: true,
706
+ stdio: 'ignore'
707
+ });
708
+ child.unref();
709
+ }
710
+
711
+ /**
712
+ * @param {number} statusCode
713
+ * @param {string} message
714
+ * @returns {Error & { statusCode: number }}
715
+ */
716
+ function httpError(statusCode, message) {
717
+ const error = /** @type {Error & { statusCode: number }} */ (new Error(message));
718
+ error.statusCode = statusCode;
719
+ return error;
720
+ }