styled-map-package 1.0.1 → 2.0.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 (64) hide show
  1. package/bin/smp-view.js +1 -0
  2. package/dist/download.d.ts +49 -0
  3. package/dist/index.d.ts +9 -0
  4. package/dist/reader-watch.d.ts +13 -0
  5. package/dist/reader.d.ts +51 -0
  6. package/dist/reporters.d.ts +7 -0
  7. package/dist/server.d.ts +21 -0
  8. package/dist/style-downloader.d.ts +110 -0
  9. package/dist/tile-downloader.d.ts +72 -0
  10. package/dist/types.d.ts +64 -0
  11. package/dist/utils/errors.d.ts +16 -0
  12. package/dist/utils/fetch.d.ts +54 -0
  13. package/dist/utils/file-formats.d.ts +25 -0
  14. package/dist/utils/geo.d.ts +44 -0
  15. package/dist/utils/mapbox.d.ts +40 -0
  16. package/dist/utils/misc.d.ts +20 -0
  17. package/dist/utils/streams.d.ts +69 -0
  18. package/dist/utils/style.d.ts +59 -0
  19. package/dist/utils/templates.d.ts +68 -0
  20. package/dist/writer.d.ts +130 -0
  21. package/lib/index.js +4 -0
  22. package/lib/reader-watch.js +133 -0
  23. package/lib/reader.js +18 -3
  24. package/lib/server.js +44 -27
  25. package/lib/utils/errors.js +24 -0
  26. package/lib/utils/streams.js +4 -1
  27. package/map-viewer/index.html +1 -1
  28. package/package.json +51 -10
  29. package/.github/workflows/node.yml +0 -30
  30. package/.github/workflows/release.yml +0 -47
  31. package/.husky/pre-commit +0 -1
  32. package/.nvmrc +0 -1
  33. package/eslint.config.js +0 -17
  34. package/test/download-write-read.js +0 -43
  35. package/test/fixtures/invalid-styles/empty.json +0 -1
  36. package/test/fixtures/invalid-styles/missing-source.json +0 -10
  37. package/test/fixtures/invalid-styles/no-layers.json +0 -4
  38. package/test/fixtures/invalid-styles/no-sources.json +0 -4
  39. package/test/fixtures/invalid-styles/null.json +0 -1
  40. package/test/fixtures/invalid-styles/unsupported-version.json +0 -5
  41. package/test/fixtures/valid-styles/external-geojson.input.json +0 -66
  42. package/test/fixtures/valid-styles/external-geojson.output.json +0 -93
  43. package/test/fixtures/valid-styles/inline-geojson.input.json +0 -421
  44. package/test/fixtures/valid-styles/inline-geojson.output.json +0 -1573
  45. package/test/fixtures/valid-styles/maplibre-demotiles.input.json +0 -831
  46. package/test/fixtures/valid-styles/maplibre-unlabelled.input.json +0 -496
  47. package/test/fixtures/valid-styles/maplibre-unlabelled.output.json +0 -1573
  48. package/test/fixtures/valid-styles/minimal-labelled.input.json +0 -37
  49. package/test/fixtures/valid-styles/minimal-labelled.output.json +0 -72
  50. package/test/fixtures/valid-styles/minimal-sprites.input.json +0 -37
  51. package/test/fixtures/valid-styles/minimal-sprites.output.json +0 -58
  52. package/test/fixtures/valid-styles/minimal.input.json +0 -54
  53. package/test/fixtures/valid-styles/minimal.output.json +0 -92
  54. package/test/fixtures/valid-styles/multiple-sprites.input.json +0 -46
  55. package/test/fixtures/valid-styles/multiple-sprites.output.json +0 -128
  56. package/test/fixtures/valid-styles/raster-sources.input.json +0 -33
  57. package/test/fixtures/valid-styles/raster-sources.output.json +0 -69
  58. package/test/utils/assert-bbox-equal.js +0 -19
  59. package/test/utils/digest-stream.js +0 -36
  60. package/test/utils/image-streams.js +0 -30
  61. package/test/utils/reader-helper.js +0 -72
  62. package/test/write-read.js +0 -620
  63. package/tsconfig.json +0 -18
  64. package/types/buffer-peek-stream.d.ts +0 -12
@@ -0,0 +1,69 @@
1
+ /** @import { TransformOptions } from 'readable-stream' */
2
+ /**
3
+ * Create a writable stream from an async function. Default concurrecy is 16 -
4
+ * this is the number of parallel functions that will be pending before
5
+ * backpressure is applied on the stream.
6
+ *
7
+ * @template {(...args: any[]) => Promise<void>} T
8
+ * @param {T} fn
9
+ * @returns {import('readable-stream').Writable}
10
+ */
11
+ export function writeStreamFromAsync<T extends (...args: any[]) => Promise<void>>(fn: T, { concurrency }?: {
12
+ concurrency?: number | undefined;
13
+ }): import("readable-stream").Writable;
14
+ /**
15
+ * From https://github.com/nodejs/node/blob/430c0269/lib/internal/webstreams/adapters.js#L509
16
+ *
17
+ * @param {ReadableStream} readableStream
18
+ * @param {{
19
+ * highWaterMark? : number,
20
+ * encoding? : string,
21
+ * objectMode? : boolean,
22
+ * signal? : AbortSignal,
23
+ * }} [options]
24
+ * @returns {import('stream').Readable}
25
+ */
26
+ export function fromWebReadableStream(readableStream: ReadableStream, options?: {
27
+ highWaterMark?: number;
28
+ encoding?: string;
29
+ objectMode?: boolean;
30
+ signal?: AbortSignal;
31
+ } | undefined): import("stream").Readable;
32
+ /**
33
+ * @param {unknown} obj
34
+ * @returns {obj is ReadableStream}
35
+ */
36
+ export function isWebReadableStream(obj: unknown): obj is ReadableStream;
37
+ /** @typedef {(opts: { totalBytes: number, chunkBytes: number }) => void} ProgressCallback */
38
+ /** @typedef {TransformOptions & { onprogress?: ProgressCallback }} ProgressStreamOptions */
39
+ /**
40
+ * Passthrough stream that counts the bytes passing through it. Pass an optional
41
+ * `onprogress` callback that will be called with the accumulated total byte
42
+ * count and the chunk byte count after each chunk.
43
+ * @extends {Transform}
44
+ */
45
+ export class ProgressStream extends Transform {
46
+ /**
47
+ * @param {ProgressStreamOptions} [opts]
48
+ */
49
+ constructor({ onprogress, ...opts }?: ProgressStreamOptions | undefined);
50
+ /** Total bytes that have passed through this stream */
51
+ get byteLength(): number;
52
+ /**
53
+ * @override
54
+ * @param {Buffer | Uint8Array} chunk
55
+ * @param {Parameters<Transform['_transform']>[1]} encoding
56
+ * @param {Parameters<Transform['_transform']>[2]} callback
57
+ */
58
+ override _transform(chunk: Buffer | Uint8Array, encoding: Parameters<Transform["_transform"]>[1], callback: Parameters<Transform["_transform"]>[2]): void;
59
+ #private;
60
+ }
61
+ export type ProgressCallback = (opts: {
62
+ totalBytes: number;
63
+ chunkBytes: number;
64
+ }) => void;
65
+ export type ProgressStreamOptions = TransformOptions & {
66
+ onprogress?: ProgressCallback;
67
+ };
68
+ import { Transform } from 'readable-stream';
69
+ import type { TransformOptions } from 'readable-stream';
@@ -0,0 +1,59 @@
1
+ /** @import {StyleSpecification, ExpressionSpecification, ValidationError} from '@maplibre/maplibre-gl-style-spec' */
2
+ /**
3
+ * For a given style, replace all font stacks (`text-field` properties) with the
4
+ * provided fonts. If no matching font is found, the first font in the stack is
5
+ * used.
6
+ *
7
+ * *Modifies the input style object*
8
+ *
9
+ * @param {StyleSpecification} style
10
+ * @param {string[]} fonts
11
+ */
12
+ export function replaceFontStacks(style: StyleSpecification, fonts: string[]): StyleSpecification;
13
+ /**
14
+ * From given style layers, create a new style by calling the provided callback
15
+ * function on every font stack defined in the style.
16
+ *
17
+ * @param {StyleSpecification['layers']} layers
18
+ * @param {(fontStack: string[]) => string[]} callbackFn
19
+ * @returns {StyleSpecification['layers']}
20
+ */
21
+ export function mapFontStacks(layers: StyleSpecification["layers"], callbackFn: (fontStack: string[]) => string[]): StyleSpecification["layers"];
22
+ /**
23
+ * @typedef {object} TileJSONPartial
24
+ * @property {string[]} tiles
25
+ * @property {string} [description]
26
+ * @property {string} [attribution]
27
+ * @property {object[]} [vector_layers]
28
+ * @property {import('./geo.js').BBox} [bounds]
29
+ * @property {number} [maxzoom]
30
+ * @property {number} [minzoom]
31
+ */
32
+ /**
33
+ *
34
+ * @param {unknown} tilejson
35
+ * @returns {asserts tilejson is TileJSONPartial}
36
+ */
37
+ export function assertTileJSON(tilejson: unknown): asserts tilejson is TileJSONPartial;
38
+ /**
39
+ * Check whether a source is already inlined (e.g. does not reference a TileJSON or GeoJSON url)
40
+ *
41
+ * @param {import('@maplibre/maplibre-gl-style-spec').SourceSpecification} source
42
+ * @returns {source is import('../types.js').InlinedSource}
43
+ */
44
+ export function isInlinedSource(source: import("@maplibre/maplibre-gl-style-spec").SourceSpecification): source is import("../types.js").InlinedSource;
45
+ export const validateStyle: {
46
+ (style: unknown): style is StyleSpecification;
47
+ errors: ValidationError[];
48
+ };
49
+ export type TileJSONPartial = {
50
+ tiles: string[];
51
+ description?: string | undefined;
52
+ attribution?: string | undefined;
53
+ vector_layers?: object[] | undefined;
54
+ bounds?: import("./geo.js").BBox | undefined;
55
+ maxzoom?: number | undefined;
56
+ minzoom?: number | undefined;
57
+ };
58
+ import type { StyleSpecification } from '@maplibre/maplibre-gl-style-spec';
59
+ import type { ValidationError } from '@maplibre/maplibre-gl-style-spec';
@@ -0,0 +1,68 @@
1
+ /**
2
+ * @param {string} path
3
+ * @returns
4
+ */
5
+ export function getResourceType(path: string): "sprite" | "tile" | "glyph" | "style";
6
+ /**
7
+ * Determine the content type of a file based on its extension.
8
+ *
9
+ * @param {string} path
10
+ */
11
+ export function getContentType(path: string): "application/json; charset=utf-8" | "application/x-protobuf" | "image/png" | "image/jpeg" | "image/webp" | "application/vnd.mapbox-vector-tile";
12
+ /**
13
+ * Get the filename for a tile, given the TileInfo
14
+ *
15
+ * @param {import("type-fest").SetRequired<import("../writer.js").TileInfo, 'format'>} tileInfo
16
+ * @returns
17
+ */
18
+ export function getTileFilename({ sourceId, z, x, y, format }: import("type-fest").SetRequired<import("../writer.js").TileInfo, "format">): string;
19
+ /**
20
+ * Get a filename for a sprite file, given the sprite id, pixel ratio and extension
21
+ *
22
+ * @param {{ id: string, pixelRatio: number, ext: '.json' | '.png'}} spriteInfo
23
+ */
24
+ export function getSpriteFilename({ id, pixelRatio, ext }: {
25
+ id: string;
26
+ pixelRatio: number;
27
+ ext: ".json" | ".png";
28
+ }): string;
29
+ /**
30
+ * Get the filename for a glyph file, given the fontstack and range
31
+ *
32
+ * @param {object} options
33
+ * @param {string} options.fontstack
34
+ * @param {import("../writer.js").GlyphRange} options.range
35
+ */
36
+ export function getGlyphFilename({ fontstack, range }: {
37
+ fontstack: string;
38
+ range: import("../writer.js").GlyphRange;
39
+ }): string;
40
+ /**
41
+ * Get the URI template for the sprites in the style
42
+ */
43
+ export function getSpriteUri(id?: string): string;
44
+ /**
45
+ * Get the URI template for tiles in the style
46
+ *
47
+ * @param {object} opts
48
+ * @param {string} opts.sourceId
49
+ * @param {import("../writer.js").TileFormat} opts.format
50
+ * @returns
51
+ */
52
+ export function getTileUri({ sourceId, format }: {
53
+ sourceId: string;
54
+ format: import("../writer.js").TileFormat;
55
+ }): string;
56
+ /**
57
+ * Replaces variables in a string with values provided in an object. Variables
58
+ * in the string are denoted by curly braces, e.g., {variableName}.
59
+ *
60
+ * @param {string} template - The string containing variables wrapped in curly braces.
61
+ * @param {Record<string, string | number>} variables - An object where the keys correspond to variable names and values correspond to the replacement values.
62
+ * @returns {string} The string with the variables replaced by their corresponding values.
63
+ */
64
+ export function replaceVariables(template: string, variables: Record<string, string | number>): string;
65
+ export const URI_SCHEME: "smp";
66
+ export const URI_BASE: string;
67
+ export const STYLE_FILE: "style.json";
68
+ export const GLYPH_URI: string;
@@ -0,0 +1,130 @@
1
+ /** @typedef {string | Buffer | Uint8Array | import('stream').Readable } Source */
2
+ /** @typedef {string | Buffer | import('stream').Readable} SourceInternal */
3
+ /** @typedef {`${number}-${number}`} GlyphRange */
4
+ /** @typedef {'png' | 'mvt' | 'jpg' | 'webp'} TileFormat */
5
+ /**
6
+ * @typedef {object} SourceInfo
7
+ * @property {import('./types.js').SMPSource} source
8
+ * @property {string} encodedSourceId
9
+ * @property {TileFormat} [format]
10
+ */
11
+ /**
12
+ * @typedef {object} TileInfo
13
+ * @property {number} z
14
+ * @property {number} x
15
+ * @property {number} y
16
+ * @property {string} sourceId
17
+ * @property {TileFormat} [format]
18
+ */
19
+ /**
20
+ * @typedef {object} GlyphInfo
21
+ * @property {string} font
22
+ * @property {GlyphRange} range
23
+ */
24
+ /** @import { StyleSpecification } from '@maplibre/maplibre-gl-style-spec' */
25
+ /** @import { InputSource, SMPSource } from './types.js' */
26
+ export const SUPPORTED_SOURCE_TYPES: readonly ["raster", "vector", "geojson"];
27
+ /**
28
+ * Write a styled map package to a stream. Stream `writer.outputStream` to a
29
+ * destination, e.g. `fs.createWriteStream('my-map.styledmap')`. You must call
30
+ * `witer.finish()` and then wait for your writable stream to `finish` before
31
+ * using the output.
32
+ */
33
+ export default class Writer extends EventEmitter<[never]> {
34
+ static SUPPORTED_SOURCE_TYPES: readonly ["raster", "vector", "geojson"];
35
+ /**
36
+ * @param {any} style A v7 or v8 MapLibre style. v7 styles will be migrated to
37
+ * v8. (There are currently no typescript declarations for v7 styles, hence
38
+ * this is typed as `any` and validated internally)
39
+ * @param {object} opts
40
+ * @param {number} [opts.highWaterMark=1048576] The maximum number of bytes to buffer during write
41
+ */
42
+ constructor(style: any, { highWaterMark }?: {
43
+ highWaterMark?: number | undefined;
44
+ });
45
+ /**
46
+ * @returns {import('stream').Readable} Readable stream of the styled map package
47
+ */
48
+ get outputStream(): Readable;
49
+ /**
50
+ * Add a tile to the styled map package
51
+ *
52
+ * @param {Source} tileData
53
+ * @param {TileInfo} opts
54
+ */
55
+ addTile(tileData: Source, { z, x, y, sourceId, format }: TileInfo): Promise<void>;
56
+ /**
57
+ * Create a write stream for adding tiles to the styled map package
58
+ *
59
+ * @param {object} opts
60
+ * @param {number} [opts.concurrency=16] The number of concurrent writes
61
+ *
62
+ * @returns
63
+ */
64
+ createTileWriteStream({ concurrency }?: {
65
+ concurrency?: number | undefined;
66
+ }): import("readable-stream").Writable;
67
+ /**
68
+ * Add a sprite to the styled map package
69
+ *
70
+ * @param {object} options
71
+ * @param {Source} options.json
72
+ * @param {Source} options.png
73
+ * @param {number} [options.pixelRatio]
74
+ * @param {string} [options.id='default']
75
+ * @returns {Promise<void>}
76
+ */
77
+ addSprite({ json, png, pixelRatio, id }: {
78
+ json: Source;
79
+ png: Source;
80
+ pixelRatio?: number | undefined;
81
+ id?: string | undefined;
82
+ }): Promise<void>;
83
+ /**
84
+ * Add glyphs to the styled map package
85
+ *
86
+ * @param {Source} glyphData
87
+ * @param {GlyphInfo} glyphInfo
88
+ * @returns {Promise<void>}
89
+ */
90
+ addGlyphs(glyphData: Source, { font: fontName, range }: GlyphInfo): Promise<void>;
91
+ /**
92
+ * Create a write stream for adding glyphs to the styled map package
93
+ *
94
+ * @param {object} opts
95
+ * @param {number} [opts.concurrency=16] The number of concurrent writes
96
+ * @returns
97
+ */
98
+ createGlyphWriteStream({ concurrency }?: {
99
+ concurrency?: number | undefined;
100
+ }): import("readable-stream").Writable;
101
+ /**
102
+ * Finalize the styled map package and write the style to the archive.
103
+ * This method must be called to complete the archive.
104
+ * You must wait for your destination write stream to 'finish' before using the output.
105
+ */
106
+ finish(): void;
107
+ #private;
108
+ }
109
+ export type Source = string | Buffer | Uint8Array | import("stream").Readable;
110
+ export type SourceInternal = string | Buffer | import("stream").Readable;
111
+ export type GlyphRange = `${number}-${number}`;
112
+ export type TileFormat = "png" | "mvt" | "jpg" | "webp";
113
+ export type SourceInfo = {
114
+ source: import("./types.js").SMPSource;
115
+ encodedSourceId: string;
116
+ format?: TileFormat | undefined;
117
+ };
118
+ export type TileInfo = {
119
+ z: number;
120
+ x: number;
121
+ y: number;
122
+ sourceId: string;
123
+ format?: TileFormat | undefined;
124
+ };
125
+ export type GlyphInfo = {
126
+ font: string;
127
+ range: GlyphRange;
128
+ };
129
+ import { EventEmitter } from 'events';
130
+ import { Readable } from 'stream';
package/lib/index.js CHANGED
@@ -1,4 +1,8 @@
1
+ /** @typedef {import('./types.js').SMPSource} SMPSource */
2
+ /** @typedef {import('./types.js').SMPStyle} SMPStyle */
3
+
1
4
  export { default as Reader } from './reader.js'
5
+ export { default as ReaderWatch } from './reader-watch.js'
2
6
  export { default as Writer } from './writer.js'
3
7
  export { default as Server } from './server.js'
4
8
  export { default as StyleDownloader } from './style-downloader.js'
@@ -0,0 +1,133 @@
1
+ import { once } from 'events'
2
+
3
+ import fs from 'node:fs'
4
+ import fsPromises from 'node:fs/promises'
5
+
6
+ import Reader from './reader.js'
7
+ import { ENOENT, isFileNotThereError } from './utils/errors.js'
8
+ import { noop } from './utils/misc.js'
9
+
10
+ /** @implements {Pick<Reader, keyof Reader>} */
11
+ export default class ReaderWatch {
12
+ /** @type {Reader | undefined} */
13
+ #reader
14
+ /** @type {Reader | undefined} */
15
+ #maybeReader
16
+ /** @type {Promise<Reader> | undefined} */
17
+ #readerOpeningPromise
18
+ #filepath
19
+ /** @type {fs.FSWatcher | undefined} */
20
+ #watch
21
+
22
+ /**
23
+ * @param {string} filepath
24
+ */
25
+ constructor(filepath) {
26
+ this.#filepath = filepath
27
+ // Call this now to catch any synchronous errors
28
+ this.#tryToWatchFile()
29
+ // eagerly open Reader
30
+ this.#get().catch(noop)
31
+ }
32
+
33
+ #tryToWatchFile() {
34
+ if (this.#watch) return
35
+ try {
36
+ this.#watch = fs
37
+ .watch(this.#filepath, { persistent: false }, () => {
38
+ this.#reader?.close().catch(noop)
39
+ this.#reader = undefined
40
+ this.#maybeReader = undefined
41
+ this.#readerOpeningPromise = undefined
42
+ // Close the watcher (which on some platforms will continue watching
43
+ // the previous file) so on the next request we will start watching
44
+ // the new file
45
+ this.#watch?.close()
46
+ this.#watch = undefined
47
+ })
48
+ .on('error', noop)
49
+ } catch (error) {
50
+ if (isFileNotThereError(error)) {
51
+ // Ignore: File does not exist yet, but we'll try to open it later
52
+ } else {
53
+ throw error
54
+ }
55
+ }
56
+ }
57
+
58
+ async #get() {
59
+ if (isWin() && (this.#reader || this.#readerOpeningPromise)) {
60
+ // On Windows, the file watcher does not recognize file deletions, so we
61
+ // need to check if the file still exists each time
62
+ try {
63
+ await fsPromises.stat(this.#filepath)
64
+ } catch {
65
+ this.#watch?.close()
66
+ this.#watch = undefined
67
+ this.#reader?.close().catch(noop)
68
+ this.#reader = undefined
69
+ this.#maybeReader = undefined
70
+ this.#readerOpeningPromise = undefined
71
+ }
72
+ }
73
+ // Need to retry this each time in case it failed initially because the file
74
+ // was not present, or if the file was moved or deleted.
75
+ this.#tryToWatchFile()
76
+ // A lovely promise tangle to confuse future readers... sorry.
77
+ //
78
+ // 1. If the reader is already open, return it.
79
+ // 2. If the reader is in the process of opening, return a promise that will
80
+ // return the reader instance if it opened without error, or throw.
81
+ // 3. If the reader threw an error during opening, try to open it again next
82
+ // time this is called.
83
+ if (this.#reader) return this.#reader
84
+ if (this.#readerOpeningPromise) return this.#readerOpeningPromise
85
+ this.#maybeReader = new Reader(this.#filepath)
86
+ this.#readerOpeningPromise = this.#maybeReader
87
+ .opened()
88
+ .then(() => {
89
+ if (!this.#maybeReader) {
90
+ throw new ENOENT(this.#filepath)
91
+ }
92
+ this.#reader = this.#maybeReader
93
+ return this.#reader
94
+ })
95
+ .finally(() => {
96
+ this.#maybeReader = undefined
97
+ this.#readerOpeningPromise = undefined
98
+ })
99
+ return this.#readerOpeningPromise
100
+ }
101
+
102
+ /** @type {Reader['opened']} */
103
+ async opened() {
104
+ const reader = await this.#get()
105
+ return reader.opened()
106
+ }
107
+
108
+ /** @type {Reader['getStyle']} */
109
+ async getStyle(baseUrl = null) {
110
+ const reader = await this.#get()
111
+ return reader.getStyle(baseUrl)
112
+ }
113
+
114
+ /** @type {Reader['getResource']} */
115
+ async getResource(path) {
116
+ const reader = await this.#get()
117
+ return reader.getResource(path)
118
+ }
119
+
120
+ async close() {
121
+ const reader = await this.#get()
122
+ if (this.#watch) {
123
+ this.#watch.close()
124
+ await once(this.#watch, 'close')
125
+ }
126
+ await reader.close()
127
+ }
128
+ }
129
+
130
+ /** @returns {boolean} */
131
+ function isWin() {
132
+ return process.platform === 'win32'
133
+ }
package/lib/reader.js CHANGED
@@ -3,6 +3,8 @@ import { open } from 'yauzl-promise'
3
3
 
4
4
  import { json } from 'node:stream/consumers'
5
5
 
6
+ import { ENOENT } from './utils/errors.js'
7
+ import { noop } from './utils/misc.js'
6
8
  import { validateStyle } from './utils/style.js'
7
9
  import {
8
10
  getContentType,
@@ -39,6 +41,7 @@ export default class Reader {
39
41
  typeof filepathOrZip === 'string'
40
42
  ? open(filepathOrZip)
41
43
  : Promise.resolve(filepathOrZip))
44
+ zipPromise.catch(noop)
42
45
  this.#entriesPromise = (async () => {
43
46
  /** @type {Map<string, import('yauzl-promise').Entry>} */
44
47
  const entries = new Map()
@@ -51,6 +54,15 @@ export default class Reader {
51
54
  }
52
55
  return entries
53
56
  })()
57
+ this.#entriesPromise.catch(noop)
58
+ }
59
+
60
+ /**
61
+ * Resolves when the styled map package has been opened and the entries have
62
+ * been read. Throws any error that occurred during opening.
63
+ */
64
+ async opened() {
65
+ await this.#entriesPromise
54
66
  }
55
67
 
56
68
  /**
@@ -62,7 +74,7 @@ export default class Reader {
62
74
  */
63
75
  async getStyle(baseUrl = null) {
64
76
  const styleEntry = (await this.#entriesPromise).get(STYLE_FILE)
65
- if (!styleEntry) throw new Error(`File not found: ${STYLE_FILE}`)
77
+ if (!styleEntry) throw new ENOENT(STYLE_FILE)
66
78
  const stream = await styleEntry.openReadStream()
67
79
  const style = await json(stream)
68
80
  if (!validateStyle(style)) {
@@ -107,7 +119,7 @@ export default class Reader {
107
119
  }
108
120
  }
109
121
  const entry = (await this.#entriesPromise).get(path)
110
- if (!entry) throw new Error(`File not found: ${path}`)
122
+ if (!entry) throw new ENOENT(path)
111
123
  const resourceType = getResourceType(path)
112
124
  const contentType = getContentType(path)
113
125
  const stream = await entry.openReadStream()
@@ -146,5 +158,8 @@ function getUrl(smpUri, baseUrl) {
146
158
  throw new Error(`Invalid SMP URI: ${smpUri}`)
147
159
  }
148
160
  if (typeof baseUrl !== 'string') return smpUri
149
- return smpUri.replace(URI_BASE, baseUrl + '/')
161
+ if (!baseUrl.endsWith('/')) {
162
+ baseUrl += '/'
163
+ }
164
+ return smpUri.replace(URI_BASE, baseUrl)
150
165
  }
package/lib/server.js CHANGED
@@ -1,13 +1,24 @@
1
- /**
2
- * @typedef {object} PluginOptions
3
- * @property {string} filepath
4
- * @property {boolean} [lazy=false]
5
- */
1
+ import createError from 'http-errors'
2
+
6
3
  import Reader from './reader.js'
4
+ import { isFileNotThereError } from './utils/errors.js'
5
+ import { noop } from './utils/misc.js'
7
6
 
8
7
  /** @import { FastifyPluginCallback, FastifyReply } from 'fastify' */
9
8
  /** @import { Resource } from './reader.js' */
10
9
 
10
+ /**
11
+ * @typedef {object} PluginOptionsFilepath
12
+ * @property {string} filepath Path to styled map package (`.smp`) file
13
+ */
14
+ /**
15
+ * @typedef {object} PluginOptionsReader
16
+ * @property {Pick<Reader, keyof Reader>} reader SMP Reader interface (also supports ReaderWatch)
17
+ */
18
+ /**
19
+ * @typedef {PluginOptionsFilepath | PluginOptionsReader} PluginOptions
20
+ */
21
+
11
22
  /**
12
23
  * @param {FastifyReply} reply
13
24
  * @param {Resource} resource
@@ -24,41 +35,47 @@ function sendResource(reply, resource) {
24
35
  }
25
36
 
26
37
  /**
27
- * Fastify plugin for serving a styled map package. User `lazy: true` to defer
28
- * opening the file until the first request.
38
+ * Fastify plugin for serving a styled map package.
39
+ *
40
+ * If you provide a `Reader` (or `ReaderWatch`) instance via the `reader` opt,
41
+ * you must manually close the instance yourself.
29
42
  *
30
43
  * @type {FastifyPluginCallback<PluginOptions>}
31
44
  */
32
- export default function (fastify, { filepath, lazy = false }, done) {
33
- /** @type {Reader | undefined} */
34
- let reader
35
- if (!lazy) {
36
- reader = new Reader(filepath)
45
+ export default function (fastify, opts, done) {
46
+ const reader = 'reader' in opts ? opts.reader : new Reader(opts.filepath)
47
+
48
+ // Only close the reader if it was created by this plugin
49
+ if (!('reader' in opts)) {
50
+ fastify.addHook('onClose', () => reader.close().catch(noop))
37
51
  }
38
52
 
39
53
  fastify.get('/style.json', async () => {
40
- if (!reader) {
41
- reader = new Reader(filepath)
54
+ try {
55
+ const baseUrl = new URL(fastify.prefix, fastify.listeningOrigin)
56
+ const style = await reader.getStyle(baseUrl.href)
57
+ return style
58
+ } catch (error) {
59
+ if (isFileNotThereError(error)) {
60
+ throw createError(404, error.message)
61
+ }
62
+ throw error
42
63
  }
43
- return reader.getStyle(fastify.listeningOrigin)
44
64
  })
45
65
 
46
66
  fastify.get('*', async (request, reply) => {
47
- if (!reader) {
48
- reader = new Reader(filepath)
49
- }
67
+ // @ts-expect-error - not worth the hassle of type casting this
68
+ const path = request.params['*']
50
69
 
51
- /** @type {Resource} */
52
- let resource
53
70
  try {
54
- resource = await reader.getResource(decodeURI(request.url))
55
- } catch (e) {
56
- // @ts-ignore
57
- e.statusCode = 404
58
- throw e
71
+ const resource = await reader.getResource(path)
72
+ return sendResource(reply, resource)
73
+ } catch (error) {
74
+ if (isFileNotThereError(error)) {
75
+ throw createError(404, error.message)
76
+ }
77
+ throw error
59
78
  }
60
-
61
- return sendResource(reply, resource)
62
79
  })
63
80
  done()
64
81
  }
@@ -0,0 +1,24 @@
1
+ export class ENOENT extends Error {
2
+ code = 'ENOENT'
3
+ /** @param {string} path */
4
+ constructor(path) {
5
+ const message = `ENOENT: no such file or directory, open '${path}'`
6
+ super(message)
7
+ this.path = path
8
+ }
9
+ }
10
+
11
+ /**
12
+ * Returns true if the error if because a file is not found. On Windows, some
13
+ * operations like fs.watch() throw an EPERM error rather than ENOENT.
14
+ *
15
+ * @param {unknown} error
16
+ * @returns {error is Error & { code: 'ENOENT' | 'EPERM' }}
17
+ */
18
+ export function isFileNotThereError(error) {
19
+ return (
20
+ error instanceof Error &&
21
+ 'code' in error &&
22
+ (error.code === 'ENOENT' || error.code === 'EPERM')
23
+ )
24
+ }
@@ -123,18 +123,20 @@ export function isWebReadableStream(obj) {
123
123
  }
124
124
 
125
125
  /** @typedef {(opts: { totalBytes: number, chunkBytes: number }) => void} ProgressCallback */
126
+ /** @typedef {TransformOptions & { onprogress?: ProgressCallback }} ProgressStreamOptions */
126
127
 
127
128
  /**
128
129
  * Passthrough stream that counts the bytes passing through it. Pass an optional
129
130
  * `onprogress` callback that will be called with the accumulated total byte
130
131
  * count and the chunk byte count after each chunk.
132
+ * @extends {Transform}
131
133
  */
132
134
  export class ProgressStream extends Transform {
133
135
  #onprogress
134
136
  #byteLength = 0
135
137
 
136
138
  /**
137
- * @param {TransformOptions & { onprogress?: ProgressCallback }} [opts]
139
+ * @param {ProgressStreamOptions} [opts]
138
140
  */
139
141
  constructor({ onprogress, ...opts } = {}) {
140
142
  super(opts)
@@ -147,6 +149,7 @@ export class ProgressStream extends Transform {
147
149
  }
148
150
 
149
151
  /**
152
+ * @override
150
153
  * @param {Buffer | Uint8Array} chunk
151
154
  * @param {Parameters<Transform['_transform']>[1]} encoding
152
155
  * @param {Parameters<Transform['_transform']>[2]} callback