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.
- package/README.md +94 -0
- package/dist/download.d.ts +11 -21
- package/dist/fallbacks.d.ts +32 -0
- package/dist/from-mbtiles.d.ts +1 -3
- package/dist/index.d.ts +11 -24
- package/dist/reader.d.ts +28 -12
- package/dist/server.d.ts +23 -14
- package/dist/style-downloader.d.ts +13 -19
- package/dist/tile-downloader.d.ts +13 -23
- package/dist/types.d.ts +61 -0
- package/dist/utils/errors.d.ts +2 -4
- package/dist/utils/fetch.d.ts +3 -8
- package/dist/utils/file-formats.d.ts +3 -10
- package/dist/utils/geo.d.ts +17 -9
- package/dist/utils/mapbox.d.ts +8 -10
- package/dist/utils/misc.d.ts +3 -5
- package/dist/utils/streams.d.ts +6 -10
- package/dist/utils/style.d.ts +27 -16
- package/dist/utils/templates.d.ts +30 -25
- package/dist/validator.d.ts +66 -0
- package/dist/writer.d.ts +157 -4
- package/lib/download.js +125 -0
- package/lib/fallbacks.js +157 -0
- package/lib/from-mbtiles.js +131 -0
- package/lib/index.js +12 -0
- package/lib/reader.js +360 -0
- package/lib/server.js +222 -0
- package/lib/style-downloader.js +369 -0
- package/lib/tile-downloader.js +189 -0
- package/lib/types.ts +99 -0
- package/lib/utils/errors.js +24 -0
- package/lib/utils/fetch.js +104 -0
- package/lib/utils/file-formats.js +92 -0
- package/lib/utils/geo.js +97 -0
- package/lib/utils/mapbox.js +155 -0
- package/{dist/utils/misc.d.cts → lib/utils/misc.js} +9 -5
- package/lib/utils/streams.js +101 -0
- package/lib/utils/style.js +206 -0
- package/lib/utils/templates.js +165 -0
- package/lib/validator.js +789 -0
- package/lib/writer.js +652 -0
- package/package.json +30 -78
- package/dist/download.cjs +0 -100
- package/dist/download.d.cts +0 -63
- package/dist/download.js +0 -76
- package/dist/from-mbtiles.cjs +0 -127
- package/dist/from-mbtiles.d.cts +0 -14
- package/dist/from-mbtiles.js +0 -103
- package/dist/index.cjs +0 -46
- package/dist/index.d.cts +0 -24
- package/dist/index.js +0 -16
- package/dist/reader.cjs +0 -287
- package/dist/reader.d.cts +0 -67
- package/dist/reader.js +0 -259
- package/dist/server.cjs +0 -73
- package/dist/server.d.cts +0 -45
- package/dist/server.js +0 -49
- package/dist/style-downloader.cjs +0 -314
- package/dist/style-downloader.d.cts +0 -118
- package/dist/style-downloader.js +0 -290
- package/dist/tile-downloader.cjs +0 -156
- package/dist/tile-downloader.d.cts +0 -82
- package/dist/tile-downloader.js +0 -124
- package/dist/types-Bhn0-Ldk.d.cts +0 -201
- package/dist/types-Bhn0-Ldk.d.ts +0 -201
- package/dist/utils/errors.cjs +0 -41
- package/dist/utils/errors.d.cts +0 -18
- package/dist/utils/errors.js +0 -16
- package/dist/utils/fetch.cjs +0 -97
- package/dist/utils/fetch.d.cts +0 -50
- package/dist/utils/fetch.js +0 -63
- package/dist/utils/file-formats.cjs +0 -96
- package/dist/utils/file-formats.d.cts +0 -32
- package/dist/utils/file-formats.js +0 -70
- package/dist/utils/geo.cjs +0 -84
- package/dist/utils/geo.d.cts +0 -46
- package/dist/utils/geo.js +0 -56
- package/dist/utils/mapbox.cjs +0 -121
- package/dist/utils/mapbox.d.cts +0 -43
- package/dist/utils/mapbox.js +0 -91
- package/dist/utils/misc.cjs +0 -39
- package/dist/utils/misc.js +0 -13
- package/dist/utils/streams.cjs +0 -99
- package/dist/utils/streams.d.cts +0 -49
- package/dist/utils/streams.js +0 -73
- package/dist/utils/style.cjs +0 -126
- package/dist/utils/style.d.cts +0 -66
- package/dist/utils/style.js +0 -98
- package/dist/utils/templates.cjs +0 -124
- package/dist/utils/templates.d.cts +0 -79
- package/dist/utils/templates.js +0 -85
- package/dist/writer.cjs +0 -539
- package/dist/writer.d.cts +0 -4
- 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
|
+
}
|