From e4af1d18aa8cbb587d42c484259cf8c9c5f0ddb0 Mon Sep 17 00:00:00 2001 From: Keitaroh Kobayashi Date: Tue, 20 Dec 2022 13:06:05 +0900 Subject: [PATCH] Support for sprites on serve (#143) * Support for sprites on serve Also creates a simple test for charites serve * Update documentation --- docs/source/usage/commandline_interface.rst | 14 ++- package-lock.json | 13 +++ package.json | 3 +- provider/default/app.js | 4 +- provider/default/shared.js | 13 ++- provider/geolonia/app.js | 4 +- provider/mapbox/app.js | 4 +- src/cli/serve.ts | 11 ++- src/commands/serve.ts | 91 ++++++++++++++--- test/command.serve.spec.ts | 103 ++++++++++++++++++++ test/util/charitesCmd.ts | 4 +- test/util/execPromise.ts | 46 ++++++++- test/util/index.ts | 3 + 13 files changed, 281 insertions(+), 32 deletions(-) create mode 100644 test/command.serve.spec.ts create mode 100644 test/util/index.ts diff --git a/docs/source/usage/commandline_interface.rst b/docs/source/usage/commandline_interface.rst index a04993d..3ec276c 100644 --- a/docs/source/usage/commandline_interface.rst +++ b/docs/source/usage/commandline_interface.rst @@ -94,10 +94,12 @@ Realtime editor on browser serve your map locally Options: - --provider [provider] your map service. e.g. `mapbox`, `geolonia` - --mapbox-access-token [mapboxAccessToken] Access Token for the Mapbox - --port [port] Specify custom port - -h, --help display help for command + --provider [provider] your map service. e.g. `mapbox`, `geolonia` + --mapbox-access-token [mapboxAccessToken] Access Token for the Mapbox + -i, --sprite-input [] directory path of icon source to build icons. The default is `icons/` + --port [port] Specify custom port + -h, --help display help for command Charites has three options for `serve` command. @@ -108,4 +110,6 @@ Charites has three options for `serve` command. - ``--mapbox-access-token`` - Set your access-token when styling for Mapbox. -- ``--port`` - Set http server's port number. When not specified, use 8080 port. +- ``--sprite-input`` - If you are building icon spritesheets with Charites, you can specify the directory of SVG files to compile here. See the ``build`` command for more information. + +- ``--port`` - Set http server's port number. When not specified, the default is 8080. diff --git a/package-lock.json b/package-lock.json index 78062d7..a635487 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "fs-extra": "^10.0.0", "kill-port-process": "^3.0.1", "mocha": "^8.0.1", + "node-abort-controller": "^3.0.1", "prettier": "^2.5.0", "ts-node": "^8.10.2", "typescript": "^3.9.5" @@ -2426,6 +2427,12 @@ "node": ">=10" } }, + "node_modules/node-abort-controller": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz", + "integrity": "sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==", + "dev": true + }, "node_modules/node-watch": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.7.2.tgz", @@ -5509,6 +5516,12 @@ "semver": "^7.3.5" } }, + "node-abort-controller": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/node-abort-controller/-/node-abort-controller-3.0.1.tgz", + "integrity": "sha512-/ujIVxthRs+7q6hsdjHMaj8hRG9NuWmwrz+JdRwZ14jdFoKSkm+vDsCbF9PLpnSqjaWQJuTmVtcWHNLr+vrOFw==", + "dev": true + }, "node-watch": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/node-watch/-/node-watch-0.7.2.tgz", diff --git a/package.json b/package.json index 805156a..a8e84fe 100644 --- a/package.json +++ b/package.json @@ -19,8 +19,8 @@ "dependencies": { "@mapbox/mapbox-gl-style-spec": "^13.22.0", "@maplibre/maplibre-gl-style-spec": "^14.0.2", - "@unvt/sprite-one": "^0.0.8", "@types/jsonminify": "^0.4.1", + "@unvt/sprite-one": "^0.0.8", "axios": "^0.24.0", "commander": "^8.2.0", "glob": "^7.2.0", @@ -50,6 +50,7 @@ "fs-extra": "^10.0.0", "kill-port-process": "^3.0.1", "mocha": "^8.0.1", + "node-abort-controller": "^3.0.1", "prettier": "^2.5.0", "ts-node": "^8.10.2", "typescript": "^3.9.5" diff --git a/provider/default/app.js b/provider/default/app.js index 633363b..1b02c23 100644 --- a/provider/default/app.js +++ b/provider/default/app.js @@ -9,9 +9,7 @@ if (zoom) options.zoom = zoom const map = new maplibregl.Map(options) - window._charites.initializeWebSocket((message) => { - map.setStyle(JSON.parse(message.data)) - }) + window._charites.initializeWebSocket(map) map.addControl(new maplibregl.NavigationControl(), 'top-right') diff --git a/provider/default/shared.js b/provider/default/shared.js index f0aad78..695c46e 100644 --- a/provider/default/shared.js +++ b/provider/default/shared.js @@ -1,9 +1,18 @@ ;(async () => { window._charites = { - initializeWebSocket: function (onmessage) { + initializeWebSocket: function (map) { const socket = new WebSocket(`ws://${window.location.host}`) - socket.addEventListener('message', onmessage) + socket.addEventListener('message', (message) => { + const data = JSON.parse(message.data) + if (data.event === 'styleUpdate') { + map.setStyle(`http://${window.location.host}/style.json`) + } else if (data.event === 'spriteUpdate') { + map.setStyle(`http://${window.location.host}/style.json`, { + diff: false, + }) + } + }) }, parseMapStyle: async function () { const mapStyleUrl = `http://${window.location.host}/style.json` diff --git a/provider/geolonia/app.js b/provider/geolonia/app.js index a3153be..da9bcb0 100644 --- a/provider/geolonia/app.js +++ b/provider/geolonia/app.js @@ -9,9 +9,7 @@ if (zoom) options.zoom = zoom const map = new geolonia.Map(options) - window._charites.initializeWebSocket((message) => { - map.setStyle(JSON.parse(message.data)) - }) + window._charites.initializeWebSocket(map) map.addControl( new MaplibreLegendControl( diff --git a/provider/mapbox/app.js b/provider/mapbox/app.js index 2d4bb3a..f8f4474 100644 --- a/provider/mapbox/app.js +++ b/provider/mapbox/app.js @@ -11,9 +11,7 @@ if (zoom) options.zoom = zoom const map = new mapboxgl.Map(options) - window._charites.initializeWebSocket((message) => { - map.setStyle(JSON.parse(message.data)) - }) + window._charites.initializeWebSocket(map) map.addControl(new mapboxgl.NavigationControl(), 'top-right') diff --git a/src/cli/serve.ts b/src/cli/serve.ts index 2c67f97..d9ca75a 100644 --- a/src/cli/serve.ts +++ b/src/cli/serve.ts @@ -17,12 +17,19 @@ program '--mapbox-access-token [mapboxAccessToken]', 'Your Mapbox Access Token (required if using the `mapbox` provider)', ) + .option( + '-i, --sprite-input []', + 'directory path of icon source to build icons. The default is `icons/`', + ) .option('--port [port]', 'Specify custom port') - .action((source: string, serveOptions: serveOptions) => { + .option('--no-open', "Don't open the preview in the default browser") + .action(async (source: string, serveOptions: serveOptions) => { const options: serveOptions = program.opts() options.provider = serveOptions.provider options.mapboxAccessToken = serveOptions.mapboxAccessToken options.port = serveOptions.port + options.spriteInput = serveOptions.spriteInput + options.open = serveOptions.open if (!fs.existsSync(defaultSettings.configFile)) { fs.writeFileSync( defaultSettings.configFile, @@ -30,7 +37,7 @@ program ) } try { - serve(source, program.opts()) + await serve(source, program.opts()) } catch (e) { error(e) } diff --git a/src/commands/serve.ts b/src/commands/serve.ts index 7f9c13f..0f46458 100644 --- a/src/commands/serve.ts +++ b/src/commands/serve.ts @@ -1,5 +1,6 @@ import path from 'path' import fs from 'fs' +import os from 'os' import http from 'http' import open from 'open' import { WebSocketServer } from 'ws' @@ -8,14 +9,17 @@ import watch from 'node-watch' import { parser } from '../lib/yaml-parser' import { validateStyle } from '../lib/validate-style' import { defaultValues } from '../lib/defaultValues' +import { buildSprite } from '../lib/build-sprite' export interface serveOptions { provider?: string mapboxAccessToken?: string port?: string + spriteInput?: string + open?: boolean } -export function serve(source: string, options: serveOptions) { +export async function serve(source: string, options: serveOptions) { let port = process.env.PORT || 8080 if (options.port) { port = Number(options.port) @@ -42,11 +46,44 @@ export function serve(source: string, options: serveOptions) { throw `Provider is mapbox, but the Mapbox Access Token is not set. Please provide it using --mapbox-access-token, or set it in \`~/.charites/config.yml\` (see the Global configuration section of the documentation for more information)` } - const server = http.createServer((req, res) => { + let spriteOut: string | undefined = undefined + let spriteRefresher: (() => Promise) | undefined = undefined + if (options.spriteInput) { + spriteOut = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'charites-')) + spriteRefresher = async () => { + if ( + typeof options.spriteInput === 'undefined' || + typeof spriteOut === 'undefined' + ) { + return + } + await buildSprite(options.spriteInput, spriteOut, 'sprite') + } + await spriteRefresher() + } + + const server = http.createServer(async (req, res) => { const url = (req.url || '').replace(/\?.*/, '') const defaultProviderDir = path.join(defaultValues.providerDir, 'default') const providerDir = path.join(defaultValues.providerDir, provider) + if ( + typeof spriteOut !== 'undefined' && + url.match(/^\/sprite(@2x)?\.(json|png)/) + ) { + res.statusCode = 200 + if (url.endsWith('.json')) { + res.setHeader('Content-Type', 'application/json; charset=UTF-8') + } else { + res.setHeader('Content-Type', 'image/png') + } + res.setHeader('Cache-Control', 'no-store') + const filename = path.basename(url) + const fsStream = fs.createReadStream(path.join(spriteOut, filename)) + fsStream.pipe(res) + return + } + switch (url) { case '/': res.statusCode = 200 @@ -61,12 +98,18 @@ export function serve(source: string, options: serveOptions) { let style try { style = parser(sourcePath) + if (typeof spriteOut !== 'undefined') { + style.sprite = `http://${ + req.headers.host || `localhost:${port}` + }/sprite` + } validateStyle(style, provider) } catch (error) { console.log(error) } res.statusCode = 200 res.setHeader('Content-Type', 'application/json; charset=UTF-8') + res.setHeader('Cache-Control', 'no-store') res.end(JSON.stringify(style)) break case '/app.css': @@ -116,30 +159,56 @@ export function serve(source: string, options: serveOptions) { console.log(`Provider: ${provider}`) console.log(`Loading your style: ${sourcePath}`) console.log(`Your map is running on http://localhost:${port}/\n`) - open(`http://localhost:${port}`) + if (options.open) { + open(`http://localhost:${port}`) + } }) const wss = new WebSocketServer({ server }) wss.on('connection', (ws) => { - watch( + const watcher = watch( path.dirname(sourcePath), - { recursive: true, filter: /\.yml$/ }, + { recursive: true, filter: /\.yml$|\.svg$/i }, (event, file) => { console.log(`${(event || '').toUpperCase()}: ${file}`) try { - const style = parser(sourcePath) - try { - validateStyle(style, provider) - } catch (error) { - console.log(error) + if (file?.toLowerCase().endsWith('.yml')) { + ws.send( + JSON.stringify({ + event: 'styleUpdate', + }), + ) + } else if ( + file?.toLowerCase().endsWith('.svg') && + typeof spriteRefresher !== 'undefined' + ) { + spriteRefresher().then(() => { + ws.send( + JSON.stringify({ + event: 'spriteUpdate', + }), + ) + }) } - ws.send(JSON.stringify(style)) } catch (e) { // Nothing to do } }, ) + ws.on('close', () => { + watcher.close() + }) + }) + + process.on('SIGINT', () => { + console.log('Cleaning up...') + server.close() + if (typeof spriteOut !== 'undefined') { + fs.rmSync(spriteOut, { recursive: true }) + spriteOut = undefined + } + process.exit(0) }) return server diff --git a/test/command.serve.spec.ts b/test/command.serve.spec.ts new file mode 100644 index 0000000..943ee45 --- /dev/null +++ b/test/command.serve.spec.ts @@ -0,0 +1,103 @@ +import { expect } from 'chai' +import { AbortController } from 'node-abort-controller' +import axios from 'axios' +import { abortableExecFile } from './util/execPromise' +import { copyFixturesDir, copyFixturesFile } from './util/copyFixtures' +import { makeTempDir } from './util/makeTempDir' +import { charitesCliJs } from './util/charitesCmd' +import { sleep } from './util' + +let tmpdir = '' + +describe('Test for `charites serve`', () => { + beforeEach(async function () { + tmpdir = makeTempDir() + copyFixturesFile('style.yml', tmpdir) + copyFixturesDir('layers', tmpdir) + copyFixturesDir('icons', tmpdir) + }) + + it('charites serve style.yml', async () => { + const abort = new AbortController() + const server = abortableExecFile( + process.execPath, + [charitesCliJs, 'serve', '--no-open', 'style.yml'], + abort.signal, + tmpdir, + ) + try { + await sleep(500) + await Promise.all([ + (async () => { + const res = await axios('http://localhost:8080/style.json', {}) + expect(res.data.version).to.equal(8) + })(), + (async () => { + await axios('http://localhost:8080/sprite.json', { + validateStatus: (status) => status === 404, + }) + })(), + ]) + } finally { + abort.abort() + } + abort.abort() + const { stdout, stderr } = await server + expect(stderr).to.equal('') + expect(stdout).to.match(/^Your map is running on http:\/\/localhost:8080/m) + }) + + it('charites serve --sprite-input ./icons style.yml', async () => { + const abort = new AbortController() + const server = abortableExecFile( + process.execPath, + [ + charitesCliJs, + 'serve', + '--no-open', + '--sprite-input', + './icons', + 'style.yml', + ], + abort.signal, + tmpdir, + ) + + try { + await sleep(500) + await Promise.all([ + (async () => { + const res = await axios('http://localhost:8080/style.json', {}) + expect(res.status).to.equal(200) + expect(res.data.version).to.equal(8) + expect(res.data.sprite).to.equal('http://localhost:8080/sprite') + })(), + (async () => { + const res = await axios('http://localhost:8080/sprite.json', {}) + expect(res.status).to.equal(200) + expect(Object.entries(res.data).length).to.be.greaterThan(0) + })(), + (async () => { + const res = await axios('http://localhost:8080/sprite@2x.json', {}) + expect(res.status).to.equal(200) + expect(Object.entries(res.data).length).to.be.greaterThan(0) + })(), + (async () => { + const res = await axios('http://localhost:8080/sprite.png', {}) + expect(res.status).to.equal(200) + expect(res.data.length).to.be.greaterThan(0) + })(), + (async () => { + const res = await axios('http://localhost:8080/sprite@2x.png', {}) + expect(res.status).to.equal(200) + expect(res.data.length).to.be.greaterThan(0) + })(), + ]) + } finally { + abort.abort() + } + const { stdout, stderr } = await server + expect(stderr).to.equal('') + expect(stdout).to.match(/^Your map is running on http:\/\/localhost:8080/m) + }) +}) diff --git a/test/util/charitesCmd.ts b/test/util/charitesCmd.ts index 913c5ce..6ee8a2c 100644 --- a/test/util/charitesCmd.ts +++ b/test/util/charitesCmd.ts @@ -1,3 +1,5 @@ import path from 'path' -export default `node ${path.join(__dirname, '..', '..', 'dist', 'cli.js')}` +export const charitesCliJs = path.join(__dirname, '..', '..', 'dist', 'cli.js') + +export default `${process.execPath} ${charitesCliJs}` diff --git a/test/util/execPromise.ts b/test/util/execPromise.ts index e4d0447..9d2c4f0 100644 --- a/test/util/execPromise.ts +++ b/test/util/execPromise.ts @@ -1,9 +1,22 @@ import child_process from 'child_process' import util from 'util' + +// MEMO: Remove node-abort-controller when requiring NodeJS >=16 +// NOTE: AbortController is available by default from NodeJS 15 +import { AbortSignal } from 'node-abort-controller' import { makeTempDir } from './makeTempDir' const execSync = util.promisify(child_process.exec) -export const exec = async (cmd: string, cwd?: string) => { +type ExecResult = { + stdout: string + stderr: string + cwd: string +} + +export const exec: (cmd: string, cwd?: string) => Promise = async ( + cmd, + cwd, +) => { const temp = cwd ? cwd : makeTempDir() const { stdout, stderr } = await execSync(cmd, { encoding: 'utf8', @@ -12,3 +25,34 @@ export const exec = async (cmd: string, cwd?: string) => { return { stdout, stderr, cwd: temp } } + +// MEMO: This can be replaced with the native AbortSignal support in child_process.exec after +// requiring NodeJS >= 16.4.0, see: +// https://nodejs.org/docs/latest-v16.x/api/child_process.html#child_processexeccommand-options-callback +export const abortableExecFile = ( + file: string, + args: string[], + signal: AbortSignal, + cwd?: string, +) => + new Promise((resolve, reject) => { + const temp = cwd ? cwd : makeTempDir() + + const process = child_process.execFile( + file, + args, + { + encoding: 'utf8', + cwd: temp, + }, + (error, stdout, stderr) => { + if (error) { + return reject(error) + } + resolve({ stdout, stderr, cwd: temp }) + }, + ) + signal.addEventListener('abort', () => { + process.kill('SIGINT') + }) + }) diff --git a/test/util/index.ts b/test/util/index.ts new file mode 100644 index 0000000..bb4ab60 --- /dev/null +++ b/test/util/index.ts @@ -0,0 +1,3 @@ +import { promisify } from 'util' + +export const sleep = promisify(setTimeout)