styled-map-package-api 5.0.0-pre.3 → 5.0.0-pre.4

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 (94) hide show
  1. package/README.md +94 -0
  2. package/dist/download.d.ts +11 -21
  3. package/dist/fallbacks.d.ts +32 -0
  4. package/dist/from-mbtiles.d.ts +1 -3
  5. package/dist/index.d.ts +11 -24
  6. package/dist/reader.d.ts +28 -12
  7. package/dist/server.d.ts +23 -14
  8. package/dist/style-downloader.d.ts +13 -19
  9. package/dist/tile-downloader.d.ts +13 -23
  10. package/dist/types.d.ts +61 -0
  11. package/dist/utils/errors.d.ts +2 -4
  12. package/dist/utils/fetch.d.ts +3 -8
  13. package/dist/utils/file-formats.d.ts +3 -10
  14. package/dist/utils/geo.d.ts +17 -9
  15. package/dist/utils/mapbox.d.ts +8 -10
  16. package/dist/utils/misc.d.ts +3 -5
  17. package/dist/utils/streams.d.ts +6 -10
  18. package/dist/utils/style.d.ts +27 -16
  19. package/dist/utils/templates.d.ts +30 -25
  20. package/dist/validator.d.ts +66 -0
  21. package/dist/writer.d.ts +157 -4
  22. package/lib/download.js +125 -0
  23. package/lib/fallbacks.js +157 -0
  24. package/lib/from-mbtiles.js +131 -0
  25. package/lib/index.js +12 -0
  26. package/lib/reader.js +360 -0
  27. package/lib/server.js +222 -0
  28. package/lib/style-downloader.js +369 -0
  29. package/lib/tile-downloader.js +189 -0
  30. package/lib/types.ts +99 -0
  31. package/lib/utils/errors.js +24 -0
  32. package/lib/utils/fetch.js +104 -0
  33. package/lib/utils/file-formats.js +92 -0
  34. package/lib/utils/geo.js +97 -0
  35. package/lib/utils/mapbox.js +155 -0
  36. package/{dist/utils/misc.d.cts → lib/utils/misc.js} +9 -5
  37. package/lib/utils/streams.js +101 -0
  38. package/lib/utils/style.js +206 -0
  39. package/lib/utils/templates.js +165 -0
  40. package/lib/validator.js +789 -0
  41. package/lib/writer.js +652 -0
  42. package/package.json +30 -78
  43. package/dist/download.cjs +0 -100
  44. package/dist/download.d.cts +0 -63
  45. package/dist/download.js +0 -76
  46. package/dist/from-mbtiles.cjs +0 -127
  47. package/dist/from-mbtiles.d.cts +0 -14
  48. package/dist/from-mbtiles.js +0 -103
  49. package/dist/index.cjs +0 -46
  50. package/dist/index.d.cts +0 -24
  51. package/dist/index.js +0 -16
  52. package/dist/reader.cjs +0 -287
  53. package/dist/reader.d.cts +0 -67
  54. package/dist/reader.js +0 -259
  55. package/dist/server.cjs +0 -73
  56. package/dist/server.d.cts +0 -45
  57. package/dist/server.js +0 -49
  58. package/dist/style-downloader.cjs +0 -314
  59. package/dist/style-downloader.d.cts +0 -118
  60. package/dist/style-downloader.js +0 -290
  61. package/dist/tile-downloader.cjs +0 -156
  62. package/dist/tile-downloader.d.cts +0 -82
  63. package/dist/tile-downloader.js +0 -124
  64. package/dist/types-Bhn0-Ldk.d.cts +0 -201
  65. package/dist/types-Bhn0-Ldk.d.ts +0 -201
  66. package/dist/utils/errors.cjs +0 -41
  67. package/dist/utils/errors.d.cts +0 -18
  68. package/dist/utils/errors.js +0 -16
  69. package/dist/utils/fetch.cjs +0 -97
  70. package/dist/utils/fetch.d.cts +0 -50
  71. package/dist/utils/fetch.js +0 -63
  72. package/dist/utils/file-formats.cjs +0 -96
  73. package/dist/utils/file-formats.d.cts +0 -32
  74. package/dist/utils/file-formats.js +0 -70
  75. package/dist/utils/geo.cjs +0 -84
  76. package/dist/utils/geo.d.cts +0 -46
  77. package/dist/utils/geo.js +0 -56
  78. package/dist/utils/mapbox.cjs +0 -121
  79. package/dist/utils/mapbox.d.cts +0 -43
  80. package/dist/utils/mapbox.js +0 -91
  81. package/dist/utils/misc.cjs +0 -39
  82. package/dist/utils/misc.js +0 -13
  83. package/dist/utils/streams.cjs +0 -99
  84. package/dist/utils/streams.d.cts +0 -49
  85. package/dist/utils/streams.js +0 -73
  86. package/dist/utils/style.cjs +0 -126
  87. package/dist/utils/style.d.cts +0 -66
  88. package/dist/utils/style.js +0 -98
  89. package/dist/utils/templates.cjs +0 -124
  90. package/dist/utils/templates.d.cts +0 -79
  91. package/dist/utils/templates.js +0 -85
  92. package/dist/writer.cjs +0 -539
  93. package/dist/writer.d.cts +0 -4
  94. package/dist/writer.js +0 -516
package/lib/reader.js ADDED
@@ -0,0 +1,360 @@
1
+ import { ZipReader } from '@gmaclennan/zip-reader'
2
+
3
+ import { ENOENT } from './utils/errors.js'
4
+ import { noop } from './utils/misc.js'
5
+ import { validateStyle } from './utils/style.js'
6
+ import {
7
+ getContentType,
8
+ getResourceType,
9
+ STYLE_FILE,
10
+ URI_BASE,
11
+ VERSION_FILE,
12
+ } from './utils/templates.js'
13
+
14
+ /**
15
+ * Simple deferred promise helper (Node 20 lacks Promise.withResolvers).
16
+ * @template T
17
+ * @returns {{ promise: Promise<T>, resolve: (value: T) => void, reject: (reason: unknown) => void }}
18
+ */
19
+ function defer() {
20
+ /** @type {(value: any) => void} */
21
+ let resolve
22
+ /** @type {(reason: unknown) => void} */
23
+ let reject
24
+ const promise = new Promise((res, rej) => {
25
+ resolve = res
26
+ reject = rej
27
+ })
28
+ // @ts-ignore — resolve/reject are assigned synchronously inside the Promise constructor
29
+ return { promise, resolve, reject }
30
+ }
31
+
32
+ /**
33
+ * Manages zip entry iteration and allows looking up entries by name before all
34
+ * entries have been read. When a requested entry hasn't been seen yet, a
35
+ * deferred promise is created that resolves as soon as the entry is encountered
36
+ * during iteration. This avoids waiting for the entire ZIP central directory to
37
+ * be processed before serving early entries like VERSION and style.json.
38
+ */
39
+ class Entries {
40
+ /** @type {Map<string, import('@gmaclennan/zip-reader').ZipEntry>} */
41
+ #entries = new Map()
42
+ /** @type {Map<string, ReturnType<typeof defer<import('@gmaclennan/zip-reader').ZipEntry | undefined>>>} */
43
+ #deferredEntries = new Map()
44
+ #readyPromise
45
+ #ready = false
46
+ #closing = false
47
+ /** @type {unknown} */
48
+ #error
49
+
50
+ /**
51
+ * @param {Promise<import('@gmaclennan/zip-reader').ZipReader>} zipPromise
52
+ * @param {{ maxEntries: number }} options
53
+ */
54
+ constructor(zipPromise, { maxEntries }) {
55
+ this.#readyPromise = (async () => {
56
+ try {
57
+ if (this.#closing) return
58
+ const zip = await zipPromise
59
+ if (this.#closing) return
60
+ let count = 0
61
+ for await (const entry of zip) {
62
+ if (this.#closing) return
63
+ if (++count > maxEntries) {
64
+ throw new Error(
65
+ `SMP archive exceeds maximum entry count of ${maxEntries}`,
66
+ )
67
+ }
68
+ const normalizedName = entry.name.normalize('NFC')
69
+ this.#entries.set(normalizedName, entry)
70
+ this.#deferredEntries.get(normalizedName)?.resolve(entry)
71
+ this.#deferredEntries.delete(normalizedName)
72
+ }
73
+ } catch (error) {
74
+ this.#error = error
75
+ for (const deferred of this.#deferredEntries.values()) {
76
+ deferred.reject(error)
77
+ }
78
+ this.#deferredEntries.clear()
79
+ throw error
80
+ } finally {
81
+ this.#ready = true
82
+ // Resolve remaining deferreds with undefined (entry not found)
83
+ for (const deferred of this.#deferredEntries.values()) {
84
+ deferred.resolve(undefined)
85
+ }
86
+ this.#deferredEntries.clear()
87
+ }
88
+ })()
89
+ this.#readyPromise.catch(noop)
90
+ }
91
+
92
+ async ready() {
93
+ await this.#readyPromise
94
+ }
95
+
96
+ /** @param {string} path */
97
+ async get(path) {
98
+ path = path.normalize('NFC')
99
+ if (this.#entries.has(path)) {
100
+ return this.#entries.get(path)
101
+ }
102
+ if (this.#ready || this.#closing) {
103
+ if (this.#error) throw this.#error
104
+ return undefined
105
+ }
106
+ const existingDeferred = this.#deferredEntries.get(path)
107
+ if (existingDeferred) {
108
+ return existingDeferred.promise
109
+ }
110
+ const deferred = defer()
111
+ this.#deferredEntries.set(path, deferred)
112
+ return deferred.promise
113
+ }
114
+
115
+ async close() {
116
+ this.#closing = true
117
+ await this.#readyPromise
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Read a web ReadableStream into a string.
123
+ * Browser-compatible replacement for node:stream/consumers `text()`.
124
+ * @param {ReadableStream<Uint8Array>} readable
125
+ * @returns {Promise<string>}
126
+ */
127
+ async function streamToText(readable) {
128
+ const chunks = /** @type {Uint8Array[]} */ ([])
129
+ const reader = readable.getReader()
130
+ try {
131
+ while (true) {
132
+ const { done, value } = await reader.read()
133
+ if (done) break
134
+ chunks.push(value instanceof Uint8Array ? value : new Uint8Array(value))
135
+ }
136
+ } finally {
137
+ reader.releaseLock()
138
+ }
139
+ const totalLen = chunks.reduce((s, c) => s + c.byteLength, 0)
140
+ const buf = new Uint8Array(totalLen)
141
+ let off = 0
142
+ for (const chunk of chunks) {
143
+ buf.set(chunk, off)
144
+ off += chunk.byteLength
145
+ }
146
+ return new TextDecoder().decode(buf)
147
+ }
148
+
149
+ /**
150
+ * Read a web ReadableStream into a parsed JSON value.
151
+ * Browser-compatible replacement for node:stream/consumers `json()`.
152
+ * @param {ReadableStream<Uint8Array>} readable
153
+ * @returns {Promise<unknown>}
154
+ */
155
+ async function streamToJson(readable) {
156
+ return JSON.parse(await streamToText(readable))
157
+ }
158
+
159
+ /**
160
+ * @typedef {object} Resource
161
+ * @property {string} resourceType
162
+ * @property {string} contentType
163
+ * @property {number} contentLength
164
+ * @property {ReadableStream<Uint8Array>} stream
165
+ * @property {'gzip'} [contentEncoding]
166
+ */
167
+
168
+ /**
169
+ * @typedef {object} ReaderOptions
170
+ * @property {number} [maxEntries=500_000] Maximum number of ZIP entries to
171
+ * process. Exceeding this limit throws an error during `opened()`. Default is
172
+ * 500,000 (~a global z9 tileset).
173
+ * @property {number} [maxResourceSize=20 * 1024 * 1024] Maximum uncompressed
174
+ * size in bytes for a single resource returned by `getResource()`. Default is
175
+ * 20 MiB.
176
+ */
177
+
178
+ /**
179
+ * A low-level reader for styled map packages. Returns resources in the package
180
+ * as readable streams, for serving over HTTP for example.
181
+ */
182
+ export class Reader {
183
+ #entries
184
+ /** @type {undefined | Promise<void>} */
185
+ #closePromise
186
+ /** @type {import('@gmaclennan/zip-reader/file-source').FileSource | null} */
187
+ #fileSource = null
188
+ #maxResourceSize
189
+
190
+ /**
191
+ * @param {string | import('@gmaclennan/zip-reader').ZipReader} filepathOrZip Path to styled map package (`.styledmap`) file, or a ZipReader instance
192
+ * @param {ReaderOptions} [options]
193
+ */
194
+ constructor(filepathOrZip, options = {}) {
195
+ const { maxEntries = 500_000, maxResourceSize = 20 * 1024 * 1024 } = options
196
+ this.#maxResourceSize = maxResourceSize
197
+ /** @type {Promise<import('@gmaclennan/zip-reader').ZipReader>} */
198
+ let zipPromise
199
+ if (typeof filepathOrZip === 'string') {
200
+ // Dynamic import so FileSource (which uses node:fs) is never loaded
201
+ // in browser environments where only ZipReader instances are passed.
202
+ const sourcePromise = import('@gmaclennan/zip-reader/file-source').then(
203
+ ({ FileSource }) => FileSource.open(filepathOrZip),
204
+ )
205
+ sourcePromise.catch(noop)
206
+ zipPromise = sourcePromise.then((source) => {
207
+ this.#fileSource = source
208
+ return ZipReader.from(source, { skipUniqueEntryCheck: true })
209
+ })
210
+ } else {
211
+ zipPromise = Promise.resolve(filepathOrZip)
212
+ }
213
+ zipPromise.catch(noop)
214
+ this.#entries = new Entries(zipPromise, { maxEntries })
215
+ // Close the internally-opened file source on failure to avoid FD leaks.
216
+ // Uses this.close() so that #closePromise is set, ensuring any subsequent
217
+ // reader.close() call awaits the same cleanup rather than racing with it.
218
+ this.#entries.ready().catch(() => this.close())
219
+ }
220
+
221
+ /**
222
+ * Resolves when the styled map package has been opened and the entries have
223
+ * been read. Throws any error that occurred during opening.
224
+ */
225
+ async opened() {
226
+ await this.#entries.ready()
227
+ }
228
+
229
+ /**
230
+ * Get the format version from the VERSION file in the styled map package.
231
+ * Returns "1.0" if no VERSION file exists (older SMP files did not have a
232
+ * VERSION file, so we assume version 1.0).
233
+ *
234
+ * @returns {Promise<string>}
235
+ */
236
+ async getVersion() {
237
+ const versionEntry = await this.#entries.get(VERSION_FILE)
238
+ if (!versionEntry) return '1.0'
239
+ return (await streamToText(versionEntry.readable())).trim()
240
+ }
241
+
242
+ /**
243
+ * Get the style JSON from the styled map package. The URLs in the style JSON
244
+ * will be transformed to use the provided base URL.
245
+ *
246
+ * @param {string | null} [baseUrl] Base URL where you plan to serve the resources in this styled map package, e.g. `http://localhost:3000/maps/styleA`
247
+ * @returns {Promise<import('./types.js').SMPStyle>}
248
+ */
249
+ async getStyle(baseUrl = null) {
250
+ const styleEntry = await this.#entries.get(STYLE_FILE)
251
+ if (!styleEntry) throw new ENOENT(STYLE_FILE)
252
+ const style = await streamToJson(styleEntry.readable())
253
+ if (!validateStyle(style)) {
254
+ throw new AggregateError(validateStyle.errors, 'Invalid style')
255
+ }
256
+ if (typeof style.glyphs === 'string') {
257
+ style.glyphs = getUrl(style.glyphs, baseUrl)
258
+ }
259
+ if (typeof style.sprite === 'string') {
260
+ style.sprite = getUrl(style.sprite, baseUrl)
261
+ } else if (Array.isArray(style.sprite)) {
262
+ style.sprite = style.sprite.map(({ id, url }) => {
263
+ return { id, url: getUrl(url, baseUrl) }
264
+ })
265
+ }
266
+ for (const source of Object.values(style.sources)) {
267
+ if ('tiles' in source && source.tiles) {
268
+ source.tiles = source.tiles.map((tile) => getUrl(tile, baseUrl))
269
+ }
270
+ if (
271
+ 'data' in source &&
272
+ typeof source.data === 'string' &&
273
+ source.data.startsWith(URI_BASE)
274
+ ) {
275
+ source.data = getUrl(source.data, baseUrl)
276
+ }
277
+ }
278
+ // Hard to get this type-safe without a validation function. Instead we
279
+ // trust the Writer and the tests for now.
280
+ return /** @type {import('./types.js').SMPStyle} */ (style)
281
+ }
282
+
283
+ /**
284
+ * Get a resource from the styled map package. The path should be relative to
285
+ * the root of the package.
286
+ *
287
+ * @param {string} path
288
+ * @returns {Promise<Resource>}
289
+ */
290
+ async getResource(path) {
291
+ if (path[0] === '/') path = path.slice(1)
292
+ if (path === STYLE_FILE) {
293
+ const styleJSON = JSON.stringify(await this.getStyle())
294
+ const bytes = new TextEncoder().encode(styleJSON)
295
+ return {
296
+ contentType: 'application/json; charset=utf-8',
297
+ contentLength: bytes.byteLength,
298
+ resourceType: 'style',
299
+ stream: new ReadableStream({
300
+ start(controller) {
301
+ controller.enqueue(bytes)
302
+ controller.close()
303
+ },
304
+ }),
305
+ }
306
+ }
307
+ const entry = await this.#entries.get(path)
308
+ if (!entry) throw new ENOENT(path)
309
+ if (entry.uncompressedSize > this.#maxResourceSize) {
310
+ throw new Error(
311
+ `Resource ${path} exceeds maximum size of ${this.#maxResourceSize} bytes (${entry.uncompressedSize} bytes)`,
312
+ )
313
+ }
314
+ const resourceType = getResourceType(path)
315
+ const contentType = getContentType(path)
316
+ const stream = entry.readable()
317
+ /** @type {Resource} */
318
+ const resource = {
319
+ resourceType,
320
+ contentType,
321
+ contentLength: entry.uncompressedSize,
322
+ stream,
323
+ }
324
+ if (path.endsWith('.gz')) {
325
+ resource.contentEncoding = 'gzip'
326
+ }
327
+ return resource
328
+ }
329
+
330
+ /**
331
+ * Close the styled map package file (should be called after reading the file to avoid memory leaks)
332
+ */
333
+ async close() {
334
+ if (this.#closePromise) return this.#closePromise
335
+ this.#closePromise = (async () => {
336
+ // Wait for entry iteration to stop before closing the file source,
337
+ // otherwise close() can race with the for-await loop still reading entries.
338
+ await this.#entries.close().catch(noop)
339
+ if (this.#fileSource) {
340
+ await this.#fileSource.close().catch(noop)
341
+ }
342
+ })()
343
+ return this.#closePromise
344
+ }
345
+ }
346
+
347
+ /**
348
+ * @param {string} smpUri
349
+ * @param {string | null} baseUrl
350
+ */
351
+ function getUrl(smpUri, baseUrl) {
352
+ if (!smpUri.startsWith(URI_BASE)) {
353
+ throw new Error(`Invalid SMP URI: ${smpUri}`)
354
+ }
355
+ if (typeof baseUrl !== 'string') return smpUri
356
+ if (!baseUrl.endsWith('/')) {
357
+ baseUrl += '/'
358
+ }
359
+ return smpUri.replace(URI_BASE, baseUrl)
360
+ }
package/lib/server.js ADDED
@@ -0,0 +1,222 @@
1
+ import { IttyRouter } from 'itty-router/IttyRouter'
2
+ import { StatusError } from 'itty-router/StatusError'
3
+ import { createResponse } from 'itty-router/createResponse'
4
+
5
+ import { isFileNotThereError } from './utils/errors.js'
6
+ import { URI_BASE, templateToRegex } from './utils/templates.js'
7
+
8
+ /** @import { Resource, Reader } from './reader.js' */
9
+ /** @import {IRequestStrict, RequestLike} from 'itty-router' */
10
+
11
+ /** @typedef {Pick<Reader, keyof Reader>} ReaderLike */
12
+ /** @typedef {typeof IttyRouter<IRequestStrict, [ReaderLike], Response>} RouterType */
13
+
14
+ /**
15
+ * @param {Resource} resource
16
+ * @param {ResponseInit} [options]
17
+ * @returns {Response} reply
18
+ */
19
+ function resourceResponse(resource, options = {}) {
20
+ const response = new Response(resource.stream, options)
21
+ response.headers.set('Content-Type', resource.contentType)
22
+ response.headers.set('Content-Length', resource.contentLength.toString())
23
+ if (resource.contentEncoding) {
24
+ response.headers.set('Content-Encoding', resource.contentEncoding)
25
+ }
26
+ return response
27
+ }
28
+
29
+ const jsonRaw = createResponse('application/json; charset=utf-8')
30
+ const encoder = new TextEncoder()
31
+
32
+ /** @param {unknown} obj */
33
+ function json(obj) {
34
+ const data = encoder.encode(JSON.stringify(obj))
35
+ return jsonRaw(data, {
36
+ headers: { 'Content-Length': data.length.toString() },
37
+ })
38
+ }
39
+
40
+ /**
41
+ * Create a server for serving styled map packages (SMP) over http. The server
42
+ * is a `fetch` handler that must be provided a WHATWG `Request` and a SMP
43
+ * `Reader` instance. Use `@whatwg-node/server` to use with Node.js HTTP server.
44
+ *
45
+ * To handle errors, catch errors from `fetch` and return appropriate HTTP responses.
46
+ * You can use `itty-router/error` for this.
47
+ *
48
+ * @example
49
+ * ```js
50
+ * import { createServer } from 'node:http'
51
+ * import { error } from 'itty-router/error'
52
+ * import { createServerAdapter } from '@whatwg-node/server'
53
+ * import { createServer as createSMPServer } from 'styled-map-package-api/server'
54
+ * import { Reader } from 'styled-map-package-api/reader'
55
+ *
56
+ * const reader = new Reader('path/to/your-style.smp')
57
+ * const smpServer = createSMPServer()
58
+ * const httpServer = createServer(createServerAdapter((request) => {
59
+ * return smpServer.fetch(request, reader).catch(error)
60
+ * }))
61
+ * ```
62
+ *
63
+ * @param {object} [options]
64
+ * @param {string} [options.base='/'] Base path for the server routes
65
+ * @param {(tileId: { x: number, y: number, z: number }, sourceInfo: { sourceId: string, source: import('./types.js').SMPSource }) => Response | Promise<Response>} [options.fallbackTile] Called when a tile is missing from the SMP
66
+ * @param {(fontstack: string, range: string) => Response | Promise<Response>} [options.fallbackGlyph] Called when a glyph is missing from the SMP
67
+ * @returns {{ fetch: (request: RequestLike, reader: ReaderLike) => Promise<Response> }} server instance
68
+ */
69
+ export function createServer({ base = '/', fallbackTile, fallbackGlyph } = {}) {
70
+ base = base.endsWith('/') ? base : base + '/'
71
+
72
+ /** @type {WeakMap<ReaderLike, Promise<import('./types.js').SMPStyle>>} */
73
+ const styleCache = new WeakMap()
74
+
75
+ /** @type {WeakMap<ReaderLike, TileMatcher[]>} */
76
+ const tileMatcherCache = new WeakMap()
77
+
78
+ /** @type {WeakMap<ReaderLike, RegExp | null>} */
79
+ const glyphRegexCache = new WeakMap()
80
+
81
+ /**
82
+ * Get the raw style for a reader, caching the promise per reader.
83
+ * @param {ReaderLike} reader
84
+ */
85
+ function getCachedStyle(reader) {
86
+ let promise = styleCache.get(reader)
87
+ if (!promise) {
88
+ promise = reader.getStyle()
89
+ styleCache.set(reader, promise)
90
+ }
91
+ return promise
92
+ }
93
+
94
+ const router = IttyRouter({
95
+ base,
96
+ })
97
+ .get('/style.json', async (request, reader) => {
98
+ const baseUrl = new URL('.', request.url)
99
+ const style = await reader.getStyle(baseUrl.href)
100
+ return json(style)
101
+ })
102
+ .get(':path+', async (request, reader) => {
103
+ const path = decodeURIComponent(request.params.path)
104
+ try {
105
+ const resource = await reader.getResource(path)
106
+ return resourceResponse(resource)
107
+ } catch (err) {
108
+ if (!isFileNotThereError(err)) throw err
109
+
110
+ if (fallbackTile) {
111
+ let matchers = tileMatcherCache.get(reader)
112
+ if (!matchers) {
113
+ const style = await getCachedStyle(reader)
114
+ matchers = buildTileMatchers(style.sources)
115
+ tileMatcherCache.set(reader, matchers)
116
+ }
117
+ for (const { regex, sourceId, source } of matchers) {
118
+ const match = path.match(regex)
119
+ if (match?.groups) {
120
+ return fallbackTile(
121
+ {
122
+ x: Number(match.groups.x),
123
+ y: Number(match.groups.y),
124
+ z: Number(match.groups.z),
125
+ },
126
+ { sourceId, source },
127
+ )
128
+ }
129
+ }
130
+ }
131
+
132
+ if (fallbackGlyph) {
133
+ let glyphRegex = glyphRegexCache.get(reader)
134
+ if (glyphRegex === undefined) {
135
+ const style = await getCachedStyle(reader)
136
+ glyphRegex = buildGlyphRegex(style.glyphs)
137
+ glyphRegexCache.set(reader, glyphRegex)
138
+ }
139
+ if (glyphRegex) {
140
+ const match = path.match(glyphRegex)
141
+ if (match?.groups) {
142
+ return fallbackGlyph(match.groups.fontstack, match.groups.range)
143
+ }
144
+ }
145
+ }
146
+
147
+ throw new StatusError(404, 'Not Found')
148
+ }
149
+ })
150
+ return {
151
+ fetch: (request, reader) => {
152
+ return router.fetch(request, reader).catch((err) => {
153
+ if (isFileNotThereError(err)) {
154
+ throw new StatusError(404, 'Not Found')
155
+ } else {
156
+ throw err
157
+ }
158
+ })
159
+ },
160
+ }
161
+ }
162
+
163
+ /**
164
+ * @typedef {{ regex: RegExp, sourceId: string, source: import('./types.js').SMPSource }} TileMatcher
165
+ */
166
+
167
+ const TILE_PLACEHOLDERS = { z: '\\d+', x: '\\d+', y: '\\d+' }
168
+
169
+ /** Check that a tile URL template has {z}, {x}, {y} and no adjacent placeholders.
170
+ * @param {string} template */
171
+ function isValidTileTemplate(template) {
172
+ return (
173
+ template.includes('{z}') &&
174
+ template.includes('{x}') &&
175
+ template.includes('{y}') &&
176
+ !template.includes('}{')
177
+ )
178
+ }
179
+
180
+ /**
181
+ * Build precompiled regex matchers for all tile sources.
182
+ *
183
+ * @param {{ [_: string]: import('./types.js').SMPSource }} sources
184
+ * @returns {TileMatcher[]}
185
+ */
186
+ function buildTileMatchers(sources) {
187
+ /** @type {TileMatcher[]} */
188
+ const matchers = []
189
+ for (const [sourceId, source] of Object.entries(sources)) {
190
+ if (!('tiles' in source) || !source.tiles) continue
191
+ for (const tileUrl of source.tiles) {
192
+ if (!tileUrl.startsWith(URI_BASE)) continue
193
+ const templatePath = tileUrl.slice(URI_BASE.length)
194
+ if (!isValidTileTemplate(templatePath)) continue
195
+ const regex = templateToRegex(templatePath, TILE_PLACEHOLDERS)
196
+ matchers.push({ regex, sourceId, source })
197
+ }
198
+ }
199
+ return matchers
200
+ }
201
+
202
+ const GLYPH_PLACEHOLDERS = { fontstack: '.+', range: '[^/]+' }
203
+
204
+ /**
205
+ * Build a regex to parse fontstack and range from a glyph resource path,
206
+ * based on the style's glyphs URI template.
207
+ *
208
+ * @param {string | undefined} glyphsUri
209
+ * @returns {RegExp | null}
210
+ */
211
+ function buildGlyphRegex(glyphsUri) {
212
+ if (!glyphsUri || !glyphsUri.startsWith(URI_BASE)) return null
213
+ const template = glyphsUri.slice(URI_BASE.length)
214
+ if (
215
+ !template.includes('{fontstack}') ||
216
+ !template.includes('{range}') ||
217
+ template.includes('}{')
218
+ ) {
219
+ return null
220
+ }
221
+ return templateToRegex(template, GLYPH_PLACEHOLDERS)
222
+ }