├── .travis.yml ├── lib ├── abaculus.js ├── capabilities.js ├── checkTile.js ├── find.js ├── getEtag.js ├── image-type.js ├── index.js ├── makeError.js ├── makeHost.js ├── makeUnknownError.js ├── map.js ├── memCache.js ├── merc.js ├── normalizeObj.js ├── promise.js └── tile.js ├── license.md ├── package-lock.json ├── package.json ├── readme.md ├── templates ├── wms.hbs └── wmts.hbs └── test └── basic.js /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | node_js: 4 | - '12' 5 | - '14' 6 | - '16' 7 | 8 | addons: 9 | apt: 10 | sources: 11 | - ubuntu-toolchain-r-test 12 | packages: 13 | - libstdc++-4.9-dev 14 | 15 | before_script: 16 | - npm install 17 | 18 | script: 19 | - npm test 20 | -------------------------------------------------------------------------------- /lib/abaculus.js: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright (c) 2016, Mapbox 3 | 4 | Permission to use, copy, modify, and/or distribute this software for any 5 | purpose with or without fee is hereby granted, provided that the above 6 | copyright notice and this permission notice appear in all copies. 7 | 8 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 9 | WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 10 | MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 11 | ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 12 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 13 | ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 14 | OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. 15 | */ 16 | 17 | 18 | var SphericalMercator = require('@mapbox/sphericalmercator'); 19 | var queue = require('d3-queue').queue; 20 | var blend = require('mapnik').blend; 21 | var crypto = require('crypto'); 22 | 23 | module.exports = abaculus; 24 | 25 | function abaculus(arg, callback) { 26 | var z = arg.zoom || 0, 27 | s = arg.scale || 1, 28 | center = arg.center || null, 29 | bbox = arg.bbox || null, 30 | getTile = arg.getTile || null, 31 | format = arg.format || 'png', 32 | quality = arg.quality || undefined, 33 | limit = arg.limit || 19008, 34 | tileSize = arg.tileSize || 256; 35 | 36 | if (!getTile) return callback(new Error('Invalid function for getting tiles')); 37 | 38 | if (center) { 39 | // get center coordinates in px from lng,lat 40 | center = abaculus.coordsFromCenter(z, s, center, limit, tileSize); 41 | } else if (bbox) { 42 | // get center coordinates in px from [w,s,e,n] bbox 43 | center = abaculus.coordsFromBbox(z, s, bbox, limit, tileSize); 44 | } else { 45 | return callback(new Error('No coordinates provided.')); 46 | } 47 | // generate list of tile coordinates center 48 | var coords = abaculus.tileList(z, s, center, tileSize); 49 | 50 | // get tiles based on coordinate list and stitch them together 51 | abaculus.stitchTiles(coords, format, quality, getTile, callback); 52 | } 53 | 54 | abaculus.coordsFromBbox = function(z, s, bbox, limit, tileSize) { 55 | var sm = new SphericalMercator({ size: tileSize * s }); 56 | var topRight = sm.px([bbox[2], bbox[3]], z), 57 | bottomLeft = sm.px([bbox[0], bbox[1]], z); 58 | var center = {}; 59 | center.w = topRight[0] - bottomLeft[0]; 60 | center.h = bottomLeft[1] - topRight[1]; 61 | 62 | if (center.w <= 0 || center.h <= 0) throw new Error('Incorrect coordinates'); 63 | 64 | var origin = [topRight[0] - center.w / 2, topRight[1] + center.h / 2]; 65 | center.x = origin[0]; 66 | center.y = origin[1]; 67 | center.w = Math.round(center.w * s); 68 | center.h = Math.round(center.h * s); 69 | 70 | if (center.w >= limit || center.h >= limit) throw new Error('Desired image is too large.'); 71 | return center; 72 | }; 73 | 74 | abaculus.coordsFromCenter = function(z, s, center, limit, tileSize) { 75 | var sm = new SphericalMercator({ size: tileSize * s }); 76 | var origin = sm.px([center.x, center.y], z); 77 | center.x = origin[0]; 78 | center.y = origin[1]; 79 | center.w = Math.round(center.w * s); 80 | center.h = Math.round(center.h * s); 81 | 82 | if (center.w >= limit || center.h >= limit) throw new Error('Desired image is too large.'); 83 | return center; 84 | }; 85 | 86 | // Generate the zxy and px/py offsets needed for each tile in a static image. 87 | // x, y are center coordinates in pixels 88 | abaculus.tileList = function(z, s, center, tileSize) { 89 | var x = center.x, 90 | y = center.y, 91 | w = center.w, 92 | h = center.h; 93 | var dimensions = {x: w - 1, y: h - 1}; 94 | var size = tileSize || 256; 95 | var ts = Math.floor(size * s); 96 | 97 | var centerCoordinate = { 98 | column: x / size, 99 | row: y / size, 100 | zoom: z 101 | }; 102 | 103 | function pointCoordinate(point) { 104 | var coord = { 105 | column: centerCoordinate.column, 106 | row: centerCoordinate.row, 107 | zoom: centerCoordinate.zoom, 108 | }; 109 | coord.column += (point.x - w / 2) / ts; 110 | coord.row += (point.y - h / 2) / ts; 111 | return coord; 112 | } 113 | 114 | function coordinatePoint(coord) { 115 | // Return an x, y point on the map image for a given coordinate. 116 | if (coord.zoom != z) coord = coord.zoomTo(z); 117 | return { 118 | x: w / 2 + ts * (coord.column - centerCoordinate.column), 119 | y: h / 2 + ts * (coord.row - centerCoordinate.row) 120 | }; 121 | } 122 | 123 | function floorObj(obj) { 124 | return { 125 | row: Math.floor(obj.row), 126 | column: Math.floor(obj.column), 127 | zoom: obj.zoom 128 | }; 129 | } 130 | 131 | var maxTilesInRow = Math.pow(2, z); 132 | var tl = floorObj(pointCoordinate({x: 0, y:0})); 133 | var br = floorObj(pointCoordinate(dimensions)); 134 | var coords = {}; 135 | coords.tiles = []; 136 | 137 | for (var column = tl.column; column <= br.column; column++) { 138 | for (var row = tl.row; row <= br.row; row++) { 139 | var c = { 140 | column: column, 141 | row: row, 142 | zoom: z, 143 | }; 144 | var p = coordinatePoint(c); 145 | 146 | // Wrap tiles with negative coordinates. 147 | c.column = c.column % maxTilesInRow; 148 | if (c.column < 0) c.column = maxTilesInRow + c.column; 149 | 150 | if (c.row < 0 || c.row >= maxTilesInRow) continue; 151 | coords.tiles.push({ 152 | z: c.zoom, 153 | x: c.column, 154 | y: c.row, 155 | px: Math.round(p.x), 156 | py: Math.round(p.y) 157 | }); 158 | } 159 | } 160 | coords.dimensions = { x: w, y: h }; 161 | coords.center = floorObj(centerCoordinate); 162 | coords.scale = s; 163 | 164 | return coords; 165 | }; 166 | 167 | abaculus.stitchTiles = function(coords, format, quality, getTile, callback) { 168 | if (!coords) return callback(new Error('No coords object.')); 169 | var tileQueue = queue(32); 170 | var w = coords.dimensions.x, 171 | h = coords.dimensions.y, 172 | s = coords.scale, 173 | tiles = coords.tiles; 174 | 175 | tiles.forEach(function(t) { 176 | tileQueue.defer(function(z, x, y, px, py, done) { 177 | var cb = function(err, buffer, headers) { 178 | if (err) return done(err); 179 | done(err, { 180 | buffer: buffer, 181 | headers: headers, 182 | x: px, 183 | y: py, 184 | reencode: true 185 | }) 186 | }; 187 | cb.scale = s; 188 | cb.format = format; 189 | // getTile is a function that returns 190 | // a tile given z, x, y, & callback 191 | getTile(z, x, y, cb); 192 | }, t.z, t.x, t.y, t.px, t.py); 193 | }); 194 | 195 | function tileQueueFinish(err, data) { 196 | if (err) return callback(err); 197 | if (!data) return callback(new Error('No tiles to stitch.')); 198 | var headers = []; 199 | data.forEach(function(d) { 200 | headers.push(d.headers); 201 | }); 202 | var opts = { 203 | format: format, 204 | width: w, 205 | height: h, 206 | reencode: true 207 | } 208 | if (quality) { 209 | opts.quality = quality; 210 | } 211 | blend(data, opts, function(err, buffer) { 212 | if (err) return callback(err); 213 | callback(null, buffer, headerReduce(headers, format)); 214 | }); 215 | } 216 | 217 | tileQueue.awaitAll(tileQueueFinish); 218 | }; 219 | 220 | // Calculate TTL from newest (max mtime) layer. 221 | function headerReduce(headers, format) { 222 | var minmtime = new Date('Sun, 23 Feb 2014 18:00:00 UTC'); 223 | var composed = {}; 224 | 225 | composed['Cache-Control'] = 'max-age=3600'; 226 | 227 | switch (format) { 228 | case 'vector.pbf': 229 | composed['Content-Type'] = 'application/x-protobuf'; 230 | composed['Content-Encoding'] = 'deflate'; 231 | break; 232 | case 'jpeg': 233 | composed['Content-Type'] = 'image/jpeg'; 234 | break; 235 | case 'png': 236 | composed['Content-Type'] = 'image/png'; 237 | break; 238 | } 239 | 240 | var times = headers.reduce(function(memo, h) { 241 | if (!h) return memo; 242 | for (var k in h) if (k.toLowerCase() === 'last-modified') { 243 | memo.push(new Date(h[k])); 244 | return memo; 245 | } 246 | return memo; 247 | }, []); 248 | if (!times.length) { 249 | times.push(new Date()); 250 | } else { 251 | times.push(minmtime); 252 | } 253 | composed['Last-Modified'] = (new Date(Math.max.apply(Math, times))).toUTCString(); 254 | 255 | var etag = headers.reduce(function(memo, h) { 256 | if (!h) return memo; 257 | for (var k in h) if (k.toLowerCase() === 'etag') { 258 | memo.push(h[k]); 259 | return memo; 260 | } 261 | return memo; 262 | }, []); 263 | if (!etag.length) { 264 | composed['ETag'] = '"' + crypto.createHash('md5').update(composed['Last-Modified']).digest('hex') + '"'; 265 | } else { 266 | composed['ETag'] = etag.length === 1 ? etag[0] : '"' + crypto.createHash('md5').update(etag.join(',')).digest('hex') + '"'; 267 | } 268 | 269 | return composed; 270 | } 271 | -------------------------------------------------------------------------------- /lib/capabilities.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Promise = require('./promise'); 3 | var fs = Promise.promisifyAll(require('fs')); 4 | var handlebars = require('handlebars'); 5 | var path = require('path'); 6 | var getEtag = require('./getEtag'); 7 | var wms = fs.readFileAsync(path.join(__dirname, '../templates/wms.hbs')).then(function (template) { 8 | return handlebars.compile(template.toString()); 9 | }); 10 | var wmts = fs.readFileAsync(path.join(__dirname, '../templates/wmts.hbs')).then(function (template) { 11 | return handlebars.compile(template.toString()); 12 | }); 13 | var merc = require('./merc'); 14 | var normalizeObj = require('./normalizeObj'); 15 | var defaultCache = require('./memCache'); 16 | 17 | var makeUnknownError = require('./makeUnknownError'); 18 | var getCapabilities = Promise.coroutine(_getCapabilities); 19 | function filterAbstract (abstract) { 20 | if (!abstract) { 21 | return ''; 22 | } 23 | if (abstract.indexOf(']]>') > -1) { 24 | return 'Abstract Contains invalid sequences.'; 25 | } 26 | return abstract; 27 | } 28 | var makeHost = require('./makeHost'); 29 | module.exports = module.exports = function (layers, params, cache, callback) { 30 | if (typeof cache === 'function') { 31 | callback = cache; 32 | cache = undefined; 33 | } 34 | if (typeof cache === 'undefined') { 35 | cache = defaultCache; 36 | } 37 | return getCapabilities(layers, params, cache).asCallback(callback); 38 | }; 39 | 40 | function * _getCapabilities(layers, rawParams) { 41 | try { 42 | layers = yield layers; 43 | } catch (e) { 44 | return makeUnknownError(e); 45 | } 46 | if (!layers) { 47 | throw new TypeError('options are required'); 48 | } 49 | var params = normalizeObj(rawParams); 50 | var template; 51 | var service = params.service && params.service.toLowerCase(); 52 | if (service === 'wms') { 53 | template = yield wms; 54 | } else if (service === 'wmts') { 55 | template = yield wmts; 56 | } else { 57 | throw new TypeError(`unknown service: ${params.service}`); 58 | } 59 | var tileSets = new Set(); 60 | var out = { 61 | title: layers.title || '', 62 | abstract: filterAbstract(layers.abstract), 63 | host: makeHost(layers.host), 64 | layers: makeLayers(layers.layers, tileSets), 65 | tilematrix: [] 66 | }; 67 | if (typeof layers.layerLimit === 'number') { 68 | out.layerLimit = layers.layerLimit; 69 | } 70 | if (layers.wmshost) { 71 | out.wmshost = makeHost(layers.wmshost); 72 | } else { 73 | out.wmshost = out.host; 74 | } 75 | for (let matrix of tileSets) { 76 | out.tilematrix.push(makeTileSet(matrix)); 77 | } 78 | var data = Buffer.from(template(out)); 79 | var headers = { 80 | 'content-type': 'text/xml', 81 | 'content-length': data.length, 82 | etag: getEtag(data) 83 | }; 84 | return { 85 | data: data, 86 | headers: headers, 87 | code: 200 88 | }; 89 | } 90 | 91 | function makeLayers(layers, tileSets) { 92 | if (!Array.isArray(layers) || !layers.length) { 93 | throw new Error('layers are not optional'); 94 | } 95 | 96 | return layers.map(function (layer) { 97 | if (!layer) { 98 | return; 99 | } 100 | if (layer.viewable === false) { 101 | return; 102 | } 103 | var out = { 104 | title: layer.title || '', 105 | name: layer.name || layer.identifier || '', 106 | abstract: filterAbstract(layer.abstract), 107 | png: true, 108 | jpeg: true 109 | }; 110 | 111 | if (Array.isArray(layer.image)) { 112 | if (layer.image.indexOf('png') === -1) { 113 | out.png = false; 114 | } 115 | if (layer.image.indexOf('jpeg') === -1) { 116 | out.jpeg = false; 117 | } 118 | } 119 | 120 | var bl = merc.forward(layer.bbox.slice(0, 2)); 121 | out.mercminx = bl[0]; 122 | out.mercminy = bl[1]; 123 | var tr = merc.forward(layer.bbox.slice(2, 4)); 124 | out.mercmaxx = tr[0]; 125 | out.mercmaxy = tr[1]; 126 | out.minx = layer.bbox[0]; 127 | out.miny = layer.bbox[1]; 128 | out.maxx = layer.bbox[2]; 129 | out.maxy = layer.bbox[3]; 130 | out.tileset = layer.range.join('to'); 131 | tileSets.add(out.tileset); 132 | out.tilelimits = getTileLimits(layer.bbox, layer.range[0], layer.range[1]); 133 | return out; 134 | }).filter(function (layer) { 135 | return layer; 136 | }); 137 | } 138 | function getTileLimits(bbox, minzoom, maxzoom) { 139 | var i = minzoom; 140 | var out = []; 141 | while (i <= maxzoom) { 142 | out.push({ 143 | zoom: i < 10 ? '0' + i.toString() : i.toString(), 144 | bbox: merc.xyz(bbox, i) 145 | }); 146 | i++; 147 | } 148 | return out; 149 | } 150 | function makeTileSet(layer) { 151 | var out = {name: layer}; 152 | var split = layer.split('to'); 153 | var min = parseInt(split[0], 10); 154 | var max = parseInt(split[1], 10); 155 | var i = min; 156 | while (i <= max) { 157 | out['level' + i] = true; 158 | i++; 159 | } 160 | return out; 161 | } 162 | -------------------------------------------------------------------------------- /lib/checkTile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var merc = require('./merc'); 3 | 4 | module.exports = checkTile; 5 | function checkTile(layer, x, y, z) { 6 | if (z < layer.range[0] || z > layer.range[1]) { 7 | return 'TILEMATRIX'; 8 | } 9 | var tilebbox = merc.xyz(layer.bbox, z); 10 | if (y < tilebbox.minY || y > tilebbox.maxY) { 11 | return 'TILEROW'; 12 | } 13 | if (x < tilebbox.minX || x > tilebbox.maxX) { 14 | return 'TILECOLUMN'; 15 | } 16 | return false; 17 | } 18 | -------------------------------------------------------------------------------- /lib/find.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = find; 4 | 5 | function find(layers, target) { 6 | if (!Array.isArray(layers)) { 7 | if (layers.abstract && Array.isArray(layers.layers)) { 8 | layers = layers.layers; 9 | } else if (layers.name === target || layers.identifier === target) { 10 | return normalize(layers); 11 | } 12 | } 13 | for (let layer of layers) { 14 | if (layer.name === target || layer.identifier === target) { 15 | return normalize(layer); 16 | } 17 | } 18 | } 19 | function normalize(layer) { 20 | if (!layer.name && layer.identifier) { 21 | layer.name = layer.identifier; 22 | } 23 | return layer; 24 | } 25 | -------------------------------------------------------------------------------- /lib/getEtag.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var crypto = require('crypto'); 4 | 5 | module.exports = getEtag; 6 | 7 | function getEtag(data) { 8 | return crypto.createHash('sha224').update(data).digest('base64'); 9 | } 10 | -------------------------------------------------------------------------------- /lib/image-type.js: -------------------------------------------------------------------------------- 1 | 2 | 'use strict'; 3 | module.exports = imageType; 4 | function imageType(data) { 5 | if (data.slice(0, 4).toString('hex') === '89504e47') { 6 | return 'image/png'; 7 | } else if (data.slice(0, 2).toString('hex') === 'ffd8') { 8 | return 'image/jpeg'; 9 | } 10 | throw new Error('unknown image type'); 11 | } 12 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var normalizeObj = require('./normalizeObj'); 3 | var Promise = require('./promise'); 4 | module.exports = exports = wms; 5 | exports.getCapabilities = require('./capabilities'); 6 | exports.getTile = require('./tile'); 7 | exports.getMap = require('./map'); 8 | 9 | function wms(options, rawParams, cache, callback) { 10 | var params = normalizeObj(rawParams); 11 | if (typeof params.request !== 'string') { 12 | return Promise.resolve({ 13 | code: 400, 14 | data: 'unknown/invalid request' 15 | }).asCallback(callback); 16 | } 17 | switch (params.request.toLowerCase()) { 18 | case 'getcapabilities': 19 | return exports.getCapabilities(options, params, cache, callback); 20 | case 'getmap': 21 | return exports.getMap(options, params, cache, callback); 22 | case 'gettile': 23 | return exports.getTile(options, params, cache, callback); 24 | default: 25 | return Promise.resolve({ 26 | code: 400, 27 | data: 'unknown/invalid request' 28 | }).asCallback(callback); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /lib/makeError.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var getEtag = require('./getEtag'); 3 | 4 | var errors = new Map(); 5 | 6 | var handlebars = require('handlebars'); 7 | module.exports = makeError; 8 | function makeError(text, code, locator) { 9 | var key = [text, code, locator].join('/'); 10 | var data; 11 | if (errors.has(key)) { 12 | data = errors.get(key); 13 | } else { 14 | var params = { 15 | code: code, 16 | text: text 17 | }; 18 | if (locator) { 19 | params.locator = locator; 20 | } 21 | var errData = Buffer.from(rangeError(params), 'utf8'); 22 | data = { 23 | resp: errData, 24 | etag: getEtag(errData), 25 | len: errData.length 26 | }; 27 | errors.set(key, data); 28 | } 29 | return { 30 | data: data.resp, 31 | headers: { 32 | 'content-type': 'application/xml', 33 | 'content-length': data.len, 34 | etag: data.etag 35 | }, 36 | code: 400 37 | }; 38 | } 39 | 40 | var rangeError = handlebars.compile(` 43 | 44 | {{text}} 45 | 46 | 47 | `); 48 | -------------------------------------------------------------------------------- /lib/makeHost.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var url = require('url'); 3 | 4 | module.exports = makeHost; 5 | 6 | function makeHost(rawUrl) { 7 | if (!rawUrl) { 8 | throw new TypeError('no url provided'); 9 | } 10 | var parsedUrl = url.parse(rawUrl); 11 | if (!parsedUrl.protocol) { 12 | throw new Error('invalid protocol'); 13 | } 14 | var outUrl = `${parsedUrl.protocol}//`; 15 | if (parsedUrl.port === '80') { 16 | outUrl += parsedUrl.hostName; 17 | } else { 18 | outUrl += parsedUrl.host; 19 | } 20 | if (parsedUrl.pathname && parsedUrl.pathname !== '/') { 21 | var path = parsedUrl.pathname; 22 | if (path.slice(-1) === '/') { 23 | path = path.slice(0, -1); 24 | } 25 | outUrl += path; 26 | } 27 | return outUrl; 28 | } 29 | -------------------------------------------------------------------------------- /lib/makeUnknownError.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var makeError = require('./makeError'); 3 | module.exports = makeUnknownError; 4 | function makeUnknownError(e) { 5 | var message = typeof e === 'string' ? e : 'unknown error'; 6 | if (process.env.NODE_ENV !== 'production' && e) { 7 | if (e.stack) { 8 | message = e.stack; 9 | } else if (e.message) { 10 | message = e.message; 11 | } 12 | } 13 | var resp = makeError(message, 'NoApplicableCode'); 14 | resp.code = 500; 15 | return resp; 16 | } 17 | -------------------------------------------------------------------------------- /lib/map.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var Promise = require('./promise'); 4 | var normalizeObj = require('./normalizeObj'); 5 | var makeError = require('./makeError'); 6 | var checkTile = require('./checkTile'); 7 | var merc = require('./merc'); 8 | var mime = require('mime'); 9 | var mapnik = require('mapnik'); 10 | var Image = mapnik.Image; 11 | var blend = mapnik.blend; 12 | var Color = mapnik.Color; 13 | var defaultCache = require('./memCache'); 14 | var EE = require('events').EventEmitter; 15 | var util = require('util') 16 | var makeUnknownError = require('./makeUnknownError'); 17 | var _abaculus = require('./abaculus'); 18 | var abaculus = util.promisify(_abaculus); 19 | var getEtag = require('./getEtag'); 20 | var find = require('./find'); 21 | var imageType = require('./image-type'); 22 | 23 | var requiredParams = [ 24 | 'layers', 25 | 'bbox', 26 | 'width', 27 | 'height', 28 | 'format', 29 | 'srs' 30 | ]; 31 | const MAX_SIZE4 = 19008; 32 | const MAX_SIZE = 4752 33 | var fromBytes = util.promisify(Image.fromBytes); 34 | 35 | function mapnikResize(img, width, height) { 36 | return new Promise((yes, no) => { 37 | img.resize(width, height, { 38 | scaling_method: mapnik.imageScaling.sinc 39 | }, (err, resp) => { 40 | if (err) { 41 | no(err); 42 | } else { 43 | yes(resp); 44 | } 45 | }); 46 | }); 47 | } 48 | 49 | function mapnikEncode(img, format) { 50 | return new Promise((yes, no) => { 51 | img.encode(format, (err, resp) => { 52 | if (err) { 53 | no(err); 54 | } else { 55 | yes(resp); 56 | } 57 | }); 58 | }); 59 | } 60 | 61 | function maybePremultiply(img) { 62 | if (img.premultiplied()) { 63 | return Promise.resolve(img); 64 | } 65 | return new Promise((yes, no) => { 66 | img.premultiply((err, buf) => { 67 | if (err) { 68 | return no(err); 69 | } 70 | yes(buf); 71 | }); 72 | }); 73 | } 74 | 75 | async function resize(image, format, width, height, oWidth, oHeight) { 76 | image = Array.isArray(image) ? image[0] : image; 77 | if (width === oWidth && height === oHeight) { 78 | return image; 79 | } 80 | let mapnikImage = await fromBytes(image, {max_size: MAX_SIZE}); 81 | let maybePremultiplied = await maybePremultiply(mapnikImage); 82 | let resized = await mapnikResize(maybePremultiplied, width, height); 83 | let output = await mapnikEncode(resized, format); 84 | return output; 85 | } 86 | 87 | async function getMap(layer, rawParams) { 88 | try { 89 | layer = await layer; 90 | } catch (e) { 91 | return makeUnknownError(e); 92 | } 93 | if (!layer) { 94 | throw new TypeError('must include layer'); 95 | } 96 | var params = normalizeObj(rawParams); 97 | var i = -1; 98 | var len = requiredParams.length; 99 | var param; 100 | if (params.crs && !params.srs) { 101 | params.srs = params.crs; 102 | } 103 | while (++i < len) { 104 | param = requiredParams[i]; 105 | if (!params[param]) { 106 | return makeError(`missing required parameter: ${param}`, 'MissingParameterValue', param); 107 | } 108 | } 109 | var srs = params.srs || params.crs; 110 | srs = srs.toLowerCase(); 111 | if (['epsg:900913', 'epsg:3857', 'epsg:4326'].indexOf(srs) === -1) { 112 | return makeError(`invalid srs: ${srs}`, 'InvalidSRS'); 113 | } 114 | var scale; 115 | if (params.dpi || params.map_resolution) { 116 | scale = Math.round(72 / parseInt(params.dpi || params.map_resolution, 10)) || 1; 117 | } else { 118 | scale = 1; 119 | } 120 | if (scale !== scale) { 121 | scale = 1; 122 | } 123 | var format = mime.extension(params.format); 124 | var bgcolor = params.bgcolor || '0xffffff'; 125 | bgcolor = bgcolor.toLowerCase(); 126 | if (params.transparent && params.transparent.toLowerCase() === 'true' && format === 'png') { 127 | bgcolor = false; 128 | } 129 | var abort = layer.abort || new EE(); 130 | try { 131 | layer = makeLayer(layer, params.layers, bgcolor, format, abort); 132 | } catch (e) { 133 | if (e.layerLengthError) { 134 | return makeError(e.message, 'InvalidParameterValue', 'LAYER'); 135 | } 136 | return makeError(e.message); 137 | } 138 | if (!layer) { 139 | return makeError(`No such layer: ${params.layer}`, 'InvalidParameterValue', 'LAYER'); 140 | } 141 | if (layer.viewable === false) { 142 | return { 143 | headers: {}, 144 | data: Buffer.from('not authorized'), 145 | code: 401 146 | }; 147 | } 148 | if (typeof layer.getTile !== 'function') { 149 | throw new TypeError('must include getTile function'); 150 | } 151 | var bbox = params.bbox.split(',').map(function(num) { 152 | return parseFloat(num); 153 | }); 154 | if (['epsg:900913', 'epsg:3857'].indexOf(srs) > -1) { 155 | bbox = merc.inverse([bbox[0], bbox[1]]).concat(merc.inverse([bbox[2], bbox[3]])); 156 | } 157 | var width = parseInt(params.width, 10); 158 | if (width < 1) { 159 | return makeError('width must be greater then 0', 'InvalidParameterValue', 'WIDTH'); 160 | } 161 | var height = parseInt(params.height, 10); 162 | if (height < 1) { 163 | return makeError('height must be greater then 1', 'InvalidParameterValue', 'HEIGHT'); 164 | } 165 | var zoom = getZoom(bbox, [width, height], layer.range[0], layer.range[1]); 166 | 167 | var opts = { 168 | scale: scale, 169 | zoom: zoom, 170 | bbox: bbox, 171 | getTile: makeGetTile(layer, bgcolor, format), 172 | format: format 173 | }; 174 | let dimensions; 175 | try { 176 | dimensions = _abaculus.coordsFromBbox(opts.zoom, opts.scale, opts.bbox, MAX_SIZE4) 177 | while (dimensions.w > MAX_SIZE || dimensions.h > MAX_SIZE) { 178 | opts.zoom--; 179 | dimensions = _abaculus.coordsFromBbox(opts.zoom, opts.scale, opts.bbox, MAX_SIZE4); 180 | } 181 | } catch (e) { 182 | return makeError(e.message, 'InvalidDimensionValue'); 183 | } 184 | 185 | var image, resizedImage; 186 | try { 187 | image = await abaculus(opts); 188 | } catch (e) { 189 | return makeUnknownError(e); 190 | } 191 | if (abort.aborted) { 192 | return makeUnknownError(new Error('aborted')); 193 | } 194 | try { 195 | resizedImage = await resize(image, format, width, height, dimensions.w, dimensions.h); 196 | } catch (e) { 197 | return makeError(e && e.message || 'error resizing image, please check dimensions'); 198 | } 199 | if (abort.aborted) { 200 | return makeUnknownError(new Error('aborted')); 201 | } 202 | var headers = { 203 | 'content-type': mime.lookup(format), 204 | 'content-length': resizedImage.length, 205 | etag: getEtag(resizedImage) 206 | }; 207 | return { 208 | data: resizedImage, 209 | headers: headers, 210 | code: 200 211 | }; 212 | 213 | } 214 | 215 | function getZoom(bounds, dimensions, minzoom, maxzoom) { 216 | minzoom = (minzoom === undefined) ? 0 : minzoom; 217 | maxzoom = (maxzoom === undefined) ? 20 : maxzoom; 218 | 219 | 220 | var bl = merc.px([bounds[0], bounds[1]], maxzoom); 221 | var tr = merc.px([bounds[2], bounds[3]], maxzoom); 222 | var width = tr[0] - bl[0]; 223 | var height = bl[1] - tr[1]; 224 | var ratios = [width / dimensions[0], height / dimensions[1]]; 225 | var adjusted = Math.ceil(Math.min( 226 | maxzoom - (Math.log(ratios[0]) / Math.log(2)), 227 | maxzoom - (Math.log(ratios[1]) / Math.log(2)))); 228 | return Math.max(minzoom, Math.min(maxzoom, adjusted)); 229 | 230 | } 231 | module.exports = function(layer, params, cache, callback) { 232 | if (typeof cache === 'function') { 233 | callback = cache; 234 | cache = undefined; 235 | } 236 | if (typeof cache === 'undefined') { 237 | cache = defaultCache; 238 | } 239 | return Promise.resolve(getMap(layer, params, cache)).asCallback(callback); 240 | }; 241 | var blankPNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAABFUlEQVR42u3BMQEAAADCoPVP7WsIoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAMBPAAB2ClDBAAAAABJRU5ErkJggg==', 'base64'); 242 | var blanks = new Map(); 243 | 244 | function callBlankBack(tile, format, callback) { 245 | var headers = { 246 | 'content-type': mime.lookup(format), 247 | 'content-length': tile.length 248 | }; 249 | callback(null, tile, headers); 250 | } 251 | 252 | function sendBlank(bgcolor, format, callback) { 253 | if (!format) { 254 | return callback(new Error('not found')); 255 | } 256 | if (bgcolor === false) { 257 | return callBlankBack(blankPNG, format, callback); 258 | } 259 | if (bgcolor.slice(0, 2) === '0x') { 260 | bgcolor = bgcolor.slice(2); 261 | } 262 | if (bgcolor.length === 3) { 263 | bgcolor = bgcolor[0] + bgcolor[0] + bgcolor[1] + bgcolor[1] + bgcolor[2] + bgcolor[2]; 264 | } 265 | if (bgcolor.length !== 6) { 266 | bgcolor = 'ffffff'; 267 | } 268 | var key = bgcolor + format; 269 | if (blanks.has(key)) { 270 | return callBlankBack(blanks.get(key), format, callback); 271 | } 272 | makeTile(bgcolor, format, function(err, image) { 273 | if (err) { 274 | return callback(err); 275 | } 276 | if (!blanks.has(key)) { 277 | blanks.set(key, image); 278 | } 279 | callBlankBack(image, format, callback); 280 | }); 281 | } 282 | 283 | function makeTile(bgcolor, format, cb) { 284 | var image = new Image(256, 256); 285 | image.fill(new Color('#' + bgcolor + 'ff'), function(err, resp) { 286 | if (err) { 287 | return cb(err); 288 | } 289 | resp.encode(format, cb); 290 | }); 291 | } 292 | 293 | function makeGetTile(layer, bgcolor, format) { 294 | return getTile; 295 | 296 | function getTile(z, x, y, callback) { 297 | if (checkTile(layer, x, y, z)) { 298 | return sendBlank(bgcolor, format, callback); 299 | } 300 | var aborted = false; 301 | var req; 302 | actualGet(true); 303 | return { 304 | abort() { 305 | aborted = true; 306 | if (req) { 307 | req.abort(); 308 | } 309 | } 310 | } 311 | 312 | function actualGet(retry) { 313 | req = layer.getTile(z, x, y, function(err, tile, headers) { 314 | req = null; 315 | if (err) { 316 | if (retry && err.message === 'timeout' && !aborted) { 317 | return actualGet(); 318 | } 319 | return sendBlank(bgcolor, format, callback); 320 | } 321 | callback(null, tile, headers); 322 | }); 323 | } 324 | } 325 | } 326 | 327 | function makeLayer(layerArray, layerParam, bgcolor, format, abort) { 328 | if (layerParam.indexOf(',') === -1) { 329 | return find(layerArray, layerParam); 330 | } 331 | var layers = layerParam.split(','); 332 | if (typeof layerArray.layerLimit === 'number' && layers.length > layerArray.layerLimit) { 333 | var e = new Error(`LayerLimit is ${layerArray.layerLimit} but ${layers.length} layers were requested`); 334 | e.layerLengthError = true; 335 | throw e; 336 | } 337 | var layerObjects = layers.map(function(item) { 338 | return find(layerArray, item); 339 | }); 340 | if (layerObjects.some(function(layer) { 341 | return !layer || layer.viewable === false; 342 | })) { 343 | return false; 344 | } 345 | return layerObjects.reduce(function(acc, item) { 346 | acc.range = [Math.min(acc.range[0], item.range[0]), Math.max(acc.range[1], item.range[1])]; 347 | acc.bbox = [Math.min(acc.bbox[0], item.bbox[0]), Math.min(acc.bbox[1], item.bbox[1]), 348 | Math.max(acc.bbox[2], item.bbox[2]), Math.max(acc.bbox[3], item.bbox[3]) 349 | ]; 350 | return acc; 351 | }, { 352 | range: [Infinity, -Infinity], 353 | bbox: [Infinity, Infinity, -Infinity, -Infinity], 354 | getTile: makeGetTiles(layerObjects, bgcolor, format, abort) 355 | }); 356 | } 357 | var getFuncMap = new WeakMap(); 358 | 359 | function noop() {} 360 | async function parallel (list, fun) { 361 | var i = list.length; 362 | var out = []; 363 | while (i--) { 364 | let result = await fun(list[i]); 365 | if (!result) { 366 | continue; 367 | } 368 | out.push(result); 369 | if (imageType(result.tile) === 'image/jpeg') { 370 | break; 371 | } 372 | } 373 | out.reverse(); 374 | return out; 375 | } 376 | 377 | function makeGetTiles(layerObjects, bgcolor, format, abort) { 378 | var aborts = new Set(); 379 | 380 | function cancelAll() { 381 | aborts.forEach(function(f) { 382 | f(); 383 | }); 384 | } 385 | abort.on('abort', cancelAll); 386 | return getTile; 387 | 388 | function getTile(z, x, y, callback) { 389 | parallel(layerObjects, function(layer) { 390 | return new Promise(function(resolve) { 391 | var getTile; 392 | if (getFuncMap.has(layer)) { 393 | getTile = getFuncMap.get(layer); 394 | } else { 395 | getTile = makeGetTile(layer); 396 | getFuncMap.set(layer, getTile); 397 | } 398 | var cancel; 399 | var out = getTile(z, x, y, function(err, tile, headers) { 400 | aborts.delete(cancel); 401 | if (err) { 402 | return resolve(); 403 | } 404 | resolve({ 405 | tile: tile, 406 | headers: headers 407 | }); 408 | }); 409 | cancel = out.abort || noop; 410 | aborts.add(cancel); 411 | }); 412 | }).then(function(things) { 413 | abort.removeListener('abort', cancelAll); 414 | var filtered = things.filter(function(item) { 415 | return item; 416 | }); 417 | if (!filtered.length) { 418 | return sendBlank(bgcolor, format, callback); 419 | } 420 | if (filtered.length === 1) { 421 | return callback(null, filtered[0].tile, filtered[0].headers); 422 | } 423 | blend(filtered.map(function(item) { 424 | return item.tile; 425 | }), { 426 | format: 'png', 427 | width: 256, 428 | height: 256 429 | }, function(err, resp) { 430 | if (err || abort.aborted) { 431 | return sendBlank(bgcolor, format, callback); 432 | } 433 | 434 | var headers = { 435 | etag: getEtag(resp), 436 | 'Content-Type': 'image/png' 437 | }; 438 | callback(null, resp, headers); 439 | }); 440 | }); 441 | } 442 | } 443 | -------------------------------------------------------------------------------- /lib/memCache.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Cache = require('lru-cache'); 3 | 4 | var lru = new Cache({ 5 | max: 20 * 1024 * 1024, 6 | length: function (item) { 7 | return item.length; 8 | } 9 | }); 10 | 11 | exports.get = get; 12 | function get (key, callback) { 13 | var item = lru.get(key); 14 | if (Buffer.isBuffer(item)) { 15 | return process.nextTick(callback, null, item); 16 | } 17 | process.nextTick(callback, new Error('not found')); 18 | } 19 | 20 | exports.set = set; 21 | 22 | function set(key, value, callback) { 23 | if (!Buffer.isBuffer(value)) { 24 | value = Buffer.from(value); 25 | } 26 | lru.set(key, value); 27 | process.nextTick(callback); 28 | } 29 | -------------------------------------------------------------------------------- /lib/merc.js: -------------------------------------------------------------------------------- 1 | module.exports = new (require('@mapbox/sphericalmercator'))(); 2 | -------------------------------------------------------------------------------- /lib/normalizeObj.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | module.exports = normalizeObj; 4 | function normalizeObj(obj) { 5 | return Object.keys(obj).reduce(function (acc, item) { 6 | acc[item.toLowerCase()] = obj[item]; 7 | return acc; 8 | }, {}); 9 | } 10 | -------------------------------------------------------------------------------- /lib/promise.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Promise = require('bluebird'); 3 | 4 | Promise.coroutine.addYieldHandler(function(v) { 5 | return Promise.resolve(v); 6 | }); 7 | 8 | module.exports = Promise; 9 | -------------------------------------------------------------------------------- /lib/tile.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | var Promise = require('./promise'); 3 | var checkTile = require('./checkTile'); 4 | var getEtag = require('./getEtag'); 5 | var makeError = require('./makeError'); 6 | var mime = require('mime'); 7 | var mapnik = require('mapnik'); 8 | var normalizeObj = require('./normalizeObj'); 9 | var find = require('./find'); 10 | var makeUnknownError = require('./makeUnknownError'); 11 | var defaultCache = Promise.promisifyAll(require('./memCache')); 12 | var crypto = require('crypto'); 13 | var EE = require('events').EventEmitter; 14 | var imageType = require('./image-type'); 15 | module.exports = function(layer, params, cache, callback) { 16 | if (typeof cache === 'function') { 17 | callback = cache; 18 | cache = undefined; 19 | } 20 | if (typeof cache === 'undefined') { 21 | cache = defaultCache; 22 | } else { 23 | cache = Promise.promisifyAll(cache); 24 | } 25 | return Promise.resolve(getTile(layer, params, cache)).asCallback(callback); 26 | }; 27 | var requiredParams = [ 28 | 'layer', 29 | 'tilematrix', 30 | 'tilerow', 31 | 'tilecol', 32 | 'format' 33 | ]; 34 | 35 | async function getTile(layer, rawParams, cache) { 36 | try { 37 | layer = await layer; 38 | } catch (e) { 39 | return makeUnknownError(e); 40 | } 41 | if (!layer) { 42 | throw new TypeError('must include layer function'); 43 | } 44 | var params = normalizeObj(rawParams); 45 | var i = -1; 46 | var len = requiredParams.length; 47 | var param; 48 | while (++i < len) { 49 | param = requiredParams[i]; 50 | if (!params[param]) { 51 | return makeError(`missing required parameter: ${param}`, 'MissingParameterValue', param); 52 | } 53 | } 54 | var abort = layer.abort; 55 | layer = find(layer, params.layer); 56 | if (!layer) { 57 | return makeError(`No such layer: ${params.layer}`, 'InvalidParameterValue', 'LAYER'); 58 | } 59 | if (layer.viewable === false) { 60 | return { 61 | headers: {}, 62 | data: Buffer.from('not authorized'), 63 | code: 401 64 | }; 65 | } 66 | layer.abort = abort || new EE(); 67 | var desiredFormat = mime.extension(params.format); 68 | if (!desiredFormat) { 69 | return makeError(`Invalid paramter value for FORMAT: ${params.format}`, 'InvalidParameterValue', 'FORMAT'); 70 | } 71 | var z; 72 | if (params.tilematrix.indexOf(':') > -1) { 73 | z = parseInt(params.tilematrix.split(':')[1], 10); 74 | } else { 75 | z = parseInt(params.tilematrix, 10); 76 | } 77 | var y = parseInt(params.tilerow, 10); 78 | var x = parseInt(params.tilecol, 10); 79 | var paramError = checkXYZ(z, params.tilematrix, 'TILEMATRIX') || 80 | checkXYZ(y, params.tilerow, 'TILEROW') || 81 | checkXYZ(x, params.tilecol, 'TILECOL'); 82 | if (paramError) { 83 | return paramError; 84 | } 85 | var tileError = checkTile(layer, x, y, z); 86 | if (tileError) { 87 | return makeError(`${tileError} is out of range`, 'TileOutOfRange', tileError); 88 | } 89 | var getTileFun = (z, x, y) => new Promise((success, failure) => { 90 | var ret = layer.getTile(z, x, y, function(err, tile, headers) { 91 | layer.abort.removeListener('abort', abortReq); 92 | if (err) { 93 | return failure(err); 94 | } 95 | return success([tile, headers]); 96 | }); 97 | layer.abort.on('abort', abortReq); 98 | 99 | function abortReq() { 100 | ret.abort(); 101 | failure(new Error('aborted')); 102 | } 103 | }); 104 | var tile; 105 | try { 106 | tile = await getTileFun(z, x, y); 107 | } catch (e) { 108 | var err = makeUnknownError('tile not found'); 109 | err.code = 404; 110 | return err; 111 | } 112 | try { 113 | var data = tile[0]; 114 | if (typeof data === 'string') { 115 | data = Buffer.from(data, 'utf8'); 116 | } 117 | 118 | var headers = normalizeObj(tile[1]); 119 | headers['content-type'] = imageType(data); 120 | var returnedFormat = mime.extension(headers['content-type']); 121 | if (returnedFormat === desiredFormat) { 122 | if (!headers['content-length']) { 123 | headers['content-length'] = data.length; 124 | } 125 | if (!headers.etag) { 126 | headers.etag = getEtag(data); 127 | } 128 | return { 129 | data: data, 130 | headers: headers, 131 | code: 200 132 | }; 133 | } 134 | var transcodedData = await transCode(data, desiredFormat, cache, layer.abort); 135 | headers['content-type'] = mime.lookup(desiredFormat); 136 | headers['content-length'] = transcodedData.length; 137 | headers.etag = getEtag(data); 138 | return { 139 | data: transcodedData, 140 | headers: headers, 141 | code: 200 142 | }; 143 | } catch (e) { 144 | return makeUnknownError(e); 145 | } 146 | } 147 | var fromBytes = Promise.promisify(mapnik.Image.fromBytes, mapnik.Image); 148 | 149 | function encode(image, format) { 150 | if (!image || typeof image.encode !== 'function') { 151 | return Promise.reject(new TypeError('invalid image object')); 152 | } 153 | return new Promise(function(resolve, reject) { 154 | image.encode(format, function(err, resp) { 155 | if (err) { 156 | return reject(err); 157 | } 158 | return resolve(resp); 159 | }); 160 | }); 161 | } 162 | 163 | async function transCode(tile, desiredFormat, cache, abort) { 164 | var key = crypto.createHash('sha224').update(tile).update(desiredFormat).digest('base64'); 165 | try { 166 | return await cache.getAsync(key); 167 | } catch (_) { 168 | // pass 169 | } 170 | if (abort.aborted) { 171 | throw new Error('aborted'); 172 | } 173 | var image = await fromBytes(tile); 174 | if (abort.aborted) { 175 | throw new Error('aborted'); 176 | } 177 | var encoded = await encode(image, desiredFormat); 178 | if (abort.aborted) { 179 | throw new Error('aborted'); 180 | } 181 | await cache.setAsync(key, encoded); 182 | return encoded; 183 | } 184 | 185 | function checkXYZ(value, orig, name) { 186 | if (value !== value) { 187 | return makeError(`Invalid paramter value for ${name}: ${orig}`, 'InvalidParameterValue', name); 188 | } 189 | } 190 | -------------------------------------------------------------------------------- /license.md: -------------------------------------------------------------------------------- 1 | Copyright (C) 2015 Applied Geographics, Inc. & Calvin W. Metcalf 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wms", 3 | "version": "4.2.1", 4 | "description": "WMS/WMTS services for node.js", 5 | "main": "lib", 6 | "author": "Calvin W. Metcalf ", 7 | "license": "MIT", 8 | "scripts": { 9 | "test": "tape test/*.js" 10 | }, 11 | "dependencies": { 12 | "@mapbox/sphericalmercator": "^1.0.5", 13 | "bluebird": "^2.9.34", 14 | "d3-queue": "^3.0.7", 15 | "handlebars": "^4.0.0", 16 | "lru-cache": "^2.6.5", 17 | "mapnik": "^4.2.1", 18 | "mime": "^1.3.4" 19 | }, 20 | "repository": { 21 | "type": "git", 22 | "url": "git@github.com:AppGeo/wms.git" 23 | }, 24 | "devDependencies": { 25 | "eslint": "^4.1.1", 26 | "tape": "^4.1.0", 27 | "babel-eslint": "^7.2.3" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | wms 2 | ==== 3 | 4 | [![Build Status](https://travis-ci.org/AppGeo/wms.svg?branch=master)](https://travis-ci.org/AppGeo/wms) 5 | 6 | WMS and WMTS web service for Node.js. 7 | 8 | Only works in spherical web mercator, expects a tiled source, requires Graphics Magic to be available, and a version of node that has support for ES6 features (specifically template literals, generators, and WeakMaps). 9 | 10 | API 11 | --- 12 | 13 | The module is a function which takes 2 required parameters and 2 optional ones. The first is service config, the full config is below, but is only mandatory for `getCapabilities`, `getMap` and `getTile` may pass just the layers array or just the layer object (for `getTile` and `getMap` with only one layer requested). 14 | 15 | - **title**: title for the service 16 | - **abstract**: short description 17 | - **host**: the host where the service lives 18 | - **wmshost**: if you want the wms to be on a different host populate this, otherwise will default to host 19 | - **layerLimit**: if you want to specify a limit on the number layers in wms requests, do so here 20 | - **layers**: array of layer objects with the following keys 21 | - **viewable**: if set to false then the layer won't be included 22 | - **title**: title of the layer 23 | - **name**: identifier for the layer 24 | - **bbox**: bounding box for the layer in, array in [minx, miny, minx, miny] format 25 | - **range**: array containing the min and max zooms in terms of tile zooms in [minZoom, maxZoom] format. 26 | - **image**: Array with the image types the layer should support, defaults to `['png', 'jpeg']` which are also the only two options, mainly relevant because arcgis offers no ability to select image format (defaulting to 'jpeg' making it impossible to pick png if jpeg is available). 27 | - **getTile**: function called to get tiles, not needed for `getCapabilities`. 28 | 29 | The second argument is the query string. These are the arguments being sent to the server via query string e.g. in Express it's `req.query`. 30 | 31 | A cache object may also be supplied as the 3rd argument (detailed bellow). 32 | 33 | Either a callback can be supplied or if not it returns a promise (this is the half a parameter). 34 | 35 | The callback is called with (or the promise resolves with) an object with the following properties: 36 | 37 | - **data**: a buffer with the data to return 38 | - **headers**: headers 39 | - **code**: status code 40 | 41 | So with Express you can just call 42 | 43 | ```js 44 | var wms = require('wms'); 45 | app.get('/something', function (req, res){ 46 | wms(layerInfo, req.query).then(function (wmsResponse) { 47 | res.set(wmsResponse.headers); 48 | res.status(wmsResponse.code); 49 | res.send(wmsResponse.data); 50 | }); 51 | }); 52 | ``` 53 | 54 | getTile function 55 | ---------------- 56 | 57 | Both WMS and WMTS require a `getTile` function which is called with `zoom`, `level`, and `row` of a tile and a callback. The callback needs to be called with the time or an error or the tile and buffer. For example: 58 | 59 | ```js 60 | function(z, x, y, callback){ 61 | // do something 62 | return callback(null, buffer, headers); 63 | } 64 | ``` 65 | 66 | This will not be called if the tile is outside of the bounding box or zoom range. 67 | 68 | 69 | Cache Object 70 | === 71 | 72 | The cache object which may be supplied as the 3rd argument is an object with get and set, get takes a string and a callback, set takes a string, a buffer, and a callback. 73 | 74 | Get returns either the object from the key, or it calls the callback with an error. 75 | 76 | If this object is omitted then a simple lru memory cache is used, currently is only used to cache the image transforms in the WMTS service. 77 | 78 | Should you use a WMS or a WMTS? 79 | ------------------------------- 80 | 81 | Always use a WMTS over a WMS if given a choice. Only ever use a WMS if you don't have a choice. WMS is very slow. This is not a bug; this is a consequence of how it works. 82 | 83 | Even better then a WMTS server - use a TMS as it allows the server to make and send tiles in arbitrary and mixed formats. 84 | 85 | Spec Compliance 86 | --------------- 87 | 88 | This implementation of a WMS/WMTS server is written with a sensibility more akin to JSON then XML. In other words, mandatory parameters that can be inferred from context do not cause an exception to be throw. For instance: since only WMTS supports `GetTile`, and only WMS supports `GetMap`, and the only request they both support is `GetCapabilities`, omitting the `service` parameter will only throw an error on a `GetCapabilities` request. 89 | 90 | ~~Despite the incredible detail paid to certain areas of the spec, there are situations that are are not covered. For instance, with a WMTS service, the only way to communicate that a tile was not found in the cache is to tell the client that it was out of range. In other words, it does not cover the case where the data behind the cache might not be a perfect square as might be the case for, say, state-level imagery outside of the great plains. In cases like this, I've tried to be the least surprising as I can, and when conventions collide, I err on the side of the more widely used convention. So in this case, I return a `NoApplicableCode` exception report with a 404 status code. This uses the convention of issuing 404 status codes to indicate missing resources, in favor of the OGC specific convention of responding to requests for non existent resources with 400.~~ 91 | 92 | QGIS retries 404's because it is too dumb to live so I just am sending back blank tiles in the case of 404s. 93 | 94 | The OGC spec fundamentally believes that any request for data that doesn't exist is malformed by definition, because well formed requests would get data. A strict reading of the spec (specifically table 24 of the WMTS spec) would imply I should use a 500 status code for uses of `NoApplicableCode` because, from what I can tell, the OGC feels that for a server to get into the situation where there is no applicable OGC exception code would by necessity be a server error, because if the server was constructed properly it would never be in this situation. 95 | 96 | It is currently not possible to specify additional `SRS` codes for WMS requests, but should in theory be possible via GDAL. This faces the road block of GDAL hating you and not supporting stdin/stdout, and the fact it would cause the already slow WMS to be even slower. 97 | 98 | The chances of adding in additional tile pyramids are approximately 0, as the massive increase in complexity would likely not bring in much useful benefits as it is rare to see the same custom tile pyramid used by 2 different groups, and almost unheard of to see the same custom tile pyramid used across state lines. 99 | 100 | License 101 | ------- 102 | 103 | [MIT](license.md) 104 | -------------------------------------------------------------------------------- /templates/wms.hbs: -------------------------------------------------------------------------------- 1 | 2 | 5 | ]> 6 | 7 | 8 | OGC:WMS 9 | {{title}} - WMS 10 | 11 | 12 | none 13 | none 14 | {{#if layerLimit}} 15 | {{layerLimit}} 16 | {{/if}} 17 | 19008 18 | 19008 19 | 20 | 21 | 22 | 23 | application/vnd.ogc.wms_xml 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | image/png 32 | image/jpeg 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | application/vnd.ogc.se_xml 43 | 44 | 45 | 46 | {{title}} 47 | EPSG:3857 48 | 49 | 50 | {{#each layers}} 51 | 52 | {{name}} 53 | {{title}} 54 | 55 | {{! }} 56 | 57 | 58 | 59 | {{/each}} 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /templates/wmts.hbs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | {{title}} - WMTS 5 | 6 | OGC WMTS 7 | 1.0.0 8 | none 9 | none 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | RESTful 19 | 20 | 21 | 22 | 23 | 24 | 25 | KVP 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | KVP 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | {{#each layers}} 48 | 49 | {{title}} 50 | 51 | 52 | {{mercminx}} {{mercminy}} 53 | {{mercmaxx}} {{mercmaxy}} 54 | 55 | 56 | {{minx}} {{miny}} 57 | {{maxx}} {{maxy}} 58 | 59 | {{name}} 60 | 63 | {{#if jpeg}} 64 | image/jpeg 65 | {{/if}} 66 | {{#if png}} 67 | image/png 68 | {{/if}} 69 | 70 | {{tileset}} 71 | {{#each tilelimits}} 72 | 73 | {{../tileset}}:{{zoom}} 74 | {{bbox.minX}} 75 | {{bbox.maxX}} 76 | {{bbox.minY}} 77 | {{bbox.maxY}} 78 | 79 | {{/each}} 80 | 81 | {{#if jpeg}} 82 | 85 | {{/if}} 86 | {{#if png}} 87 | 90 | {{/if}} 91 | 92 | {{/each}} 93 | {{#each tilematrix}} 94 | 95 | {{name}} 96 | EPSG:3857 97 | {{#if level0}} 98 | 99 | {{name}}:00 100 | 559082264.029 101 | -20037508.3428 20037508.3428 102 | 256 103 | 256 104 | 1 105 | 1 106 | 107 | {{/if}} 108 | {{#if level1}} 109 | 110 | {{name}}:01 111 | 279541132.014 112 | -20037508.3428 20037508.3428 113 | 256 114 | 256 115 | 2 116 | 2 117 | 118 | {{/if}} 119 | {{#if level2}} 120 | 121 | {{name}}:02 122 | 139770566.007 123 | -20037508.3428 20037508.3428 124 | 256 125 | 256 126 | 4 127 | 4 128 | 129 | {{/if}} 130 | {{#if level3}} 131 | 132 | {{name}}:03 133 | 69885283.0036 134 | -20037508.3428 20037508.3428 135 | 256 136 | 256 137 | 8 138 | 8 139 | 140 | {{/if}} 141 | {{#if level4}} 142 | 143 | {{name}}:04 144 | 34942641.5018 145 | -20037508.3428 20037508.3428 146 | 256 147 | 256 148 | 16 149 | 16 150 | 151 | {{/if}} 152 | {{#if level5}} 153 | 154 | {{name}}:05 155 | 17471320.7509 156 | -20037508.3428 20037508.3428 157 | 256 158 | 256 159 | 32 160 | 32 161 | 162 | {{/if}} 163 | {{#if level6}} 164 | 165 | {{name}}:06 166 | 8735660.37545 167 | -20037508.3428 20037508.3428 168 | 256 169 | 256 170 | 64 171 | 64 172 | 173 | {{/if}} 174 | {{#if level7}} 175 | 176 | {{name}}:07 177 | 4367830.18772 178 | -20037508.3428 20037508.3428 179 | 256 180 | 256 181 | 128 182 | 128 183 | 184 | {{/if}} 185 | {{#if level8}} 186 | 187 | {{name}}:08 188 | 2183915.09386 189 | -20037508.3428 20037508.3428 190 | 256 191 | 256 192 | 256 193 | 256 194 | 195 | {{/if}} 196 | {{#if level9}} 197 | 198 | {{name}}:09 199 | 1091957.54693 200 | -20037508.3428 20037508.3428 201 | 256 202 | 256 203 | 512 204 | 512 205 | 206 | {{/if}} 207 | {{#if level10}} 208 | 209 | {{name}}:10 210 | 545978.773466 211 | -20037508.3428 20037508.3428 212 | 256 213 | 256 214 | 1024 215 | 1024 216 | 217 | {{/if}} 218 | {{#if level11}} 219 | 220 | {{name}}:11 221 | 272989.386733 222 | -20037508.3428 20037508.3428 223 | 256 224 | 256 225 | 2048 226 | 2048 227 | 228 | {{/if}} 229 | {{#if level12}} 230 | 231 | {{name}}:12 232 | 136494.693366 233 | -20037508.3428 20037508.3428 234 | 256 235 | 256 236 | 4096 237 | 4096 238 | 239 | {{/if}} 240 | {{#if level13}} 241 | 242 | {{name}}:13 243 | 68247.3466832 244 | -20037508.3428 20037508.3428 245 | 256 246 | 256 247 | 8192 248 | 8192 249 | 250 | {{/if}} 251 | {{#if level14}} 252 | 253 | {{name}}:14 254 | 34123.6733416 255 | -20037508.3428 20037508.3428 256 | 256 257 | 256 258 | 16384 259 | 16384 260 | 261 | {{/if}} 262 | {{#if level15}} 263 | 264 | {{name}}:15 265 | 17061.8366708 266 | -20037508.3428 20037508.3428 267 | 256 268 | 256 269 | 32768 270 | 32768 271 | 272 | {{/if}} 273 | {{#if level16}} 274 | 275 | {{name}}:16 276 | 8530.9183354 277 | -20037508.3428 20037508.3428 278 | 256 279 | 256 280 | 65536 281 | 65536 282 | 283 | {{/if}} 284 | {{#if level17}} 285 | 286 | {{name}}:17 287 | 4265.4591677 288 | -20037508.3428 20037508.3428 289 | 256 290 | 256 291 | 131072 292 | 131072 293 | 294 | {{/if}} 295 | {{#if level18}} 296 | 297 | {{name}}:18 298 | 2132.72958385 299 | -20037508.3428 20037508.3428 300 | 256 301 | 256 302 | 262144 303 | 262144 304 | 305 | {{/if}} 306 | {{#if level19}} 307 | 308 | {{name}}:19 309 | 1066.36479192 310 | -20037508.3428 20037508.3428 311 | 256 312 | 256 313 | 524288 314 | 524288 315 | 316 | {{/if}} 317 | {{#if level20}} 318 | 319 | {{name}}:20 320 | 533.18239596 321 | -20037508.3428 20037508.3428 322 | 256 323 | 256 324 | 1048576 325 | 1048576 326 | 327 | {{/if}} 328 | {{#if level21}} 329 | 330 | {{name}}:21 331 | 266.59119798 332 | -20037508.3428 20037508.3428 333 | 256 334 | 256 335 | 2097152 336 | 2097152 337 | 338 | {{/if}} 339 | 340 | {{/each}} 341 | 342 | 343 | -------------------------------------------------------------------------------- /test/basic.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var test = require('tape'); 4 | 5 | var wms = require('../lib'); 6 | 7 | var qs = require('querystring'); 8 | // - **title**: title for the service 9 | // - **abstract**: short description 10 | // - **host**: the host where the service lives 11 | // - **layers**: array of layer objects with the following keys 12 | // - **viewable**: if set to false then the layer won't be included 13 | // - **title**: title of the layer 14 | // - **name**: identifier for the layer 15 | // - **bbox**: bounding box for the layer in, array in [minx, miny, minx, miny] format 16 | // - **range**: array containing the min and max zooms in terms of tile zooms in [minZoom, maxZoom] format. 17 | // - **image**: Array with the image types the layer should support, defaults to `['png', 'jpeg']` which are also the only two options, mainly relevant because arcgis offers no ability to select image format (defaulting to 'jpeg' making it impossible to pick png if jpeg is available). 18 | // - **getTile**: 19 | var fakeTile = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAQAAAAEACAYAAABccqhmAAAABmJLR0QA/wD/AP+gvaeTAAABFUlEQVR42u3BMQEAAADCoPVP7WsIoAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeAMBPAAB2ClDBAAAAABJRU5ErkJggg==', 'base64'); 20 | var fakeHeaders = { 21 | 'content-type': 'image/png', 22 | foo: 'bar' 23 | }; 24 | var obj = { 25 | title: 'a title', 26 | abstract: 'not like picaso', 27 | host: 'abc://blah.blah', 28 | layers: [ 29 | { 30 | title: 'my layer', 31 | name: 'jeffery', 32 | bbox: [-106.787109375, 25.8394494020632, 33 | -93.427734375, 36.6331620955865], 34 | range: [0, 20], 35 | getTile: function (a, b, c, cb) { 36 | process.nextTick(cb, null, fakeTile, fakeHeaders); 37 | } 38 | } 39 | ] 40 | }; 41 | test('basic wms', function (t) { 42 | t.plan(3); 43 | wms(obj, { 44 | service: 'wms', 45 | request: 'GetCapabilities' 46 | }).then(function (resp) { 47 | t.ok(Buffer.isBuffer(resp.data), 'got a buffer'); 48 | t.equals(resp.code, 200, 'correct code'); 49 | t.ok(resp.headers, 'got headers'); 50 | }).catch(function (e) { 51 | t.notOk(e); 52 | }); 53 | }); 54 | test('basic wmts', function (t) { 55 | t.plan(3); 56 | wms(obj, { 57 | service: 'wmts', 58 | request: 'GetCapabilities' 59 | }).then(function (resp) { 60 | t.ok(Buffer.isBuffer(resp.data), 'got a buffer'); 61 | t.equals(resp.code, 200, 'correct code'); 62 | t.ok(resp.headers, 'got headers'); 63 | }).catch(function (e) { 64 | t.notOk(e); 65 | }); 66 | }); 67 | test('basic wmts getTile request', function (t) { 68 | t.plan(4); 69 | wms(obj, qs.parse('service=WMTS&request=GetTile&version=1.0.0&layer=jeffery&style=default&format=image/png&TileMatrixSet=0to20&TileMatrix=0to20:18&TileRow=105976&TileCol=62375') 70 | ).then(function (resp) { 71 | t.ok(Buffer.isBuffer(resp.data), 'got a buffer'); 72 | t.equals(resp.data.toString('hex'), fakeTile.toString('hex'), 'same data'); 73 | t.equals(resp.code, 200, 'correct code'); 74 | t.equals(resp.headers.foo, 'bar', 'got custom headers'); 75 | }).catch(function (e) { 76 | t.notOk(e); 77 | }); 78 | }); 79 | test('basic wmts getTile for out of range request', function (t) { 80 | t.plan(4); 81 | wms(obj, qs.parse('service=WMTS&request=GetTile&version=1.0.0&layer=jeffery&style=default&format=image/png&TileMatrixSet=0to20&TileMatrix=0to20:18&TileRow=63040&TileCol=62375') 82 | ).then(function (resp) { 83 | t.ok(Buffer.isBuffer(resp.data), 'got a buffer'); 84 | t.equals(resp.data.toString(), '\n \n TILEROW is out of range\n \n\n', 'exception text'); 85 | t.equals(resp.code, 400, 'correct code'); 86 | t.ok(resp.headers, 'got headers'); 87 | }).catch(function (e) { 88 | t.notOk(e); 89 | }); 90 | }); 91 | --------------------------------------------------------------------------------