@sap/cds-dk
Version:
Command line client and development toolkit for the SAP Cloud Application Programming Model
162 lines (144 loc) • 5.67 kB
JavaScript
const cds = require('../cds')
const LOG = cds.log('cli|watch|livereload'), DEBUG = cds.debug('cli|watch|livereload')
const axios = require('axios')
const ws = require('ws')
const express = require('express')
const { URL } = require('url')
const { relative, sep } = require ('path')
const ext = 'css,gif,html,jpg,png,svg,ts,mjs,cjs,js,json,properties,change,variant,ctrl_variant,ctrl_variant_change,ctrl_variant_management_change'
const watchIncludes = RegExp(`\\.(${ext.replace(/,/g,'|')})$|\\${sep}(webapp|appconfig)`)
const watchExcludes = RegExp(`@cds-models\\${sep}|@types\\${sep}`)
module.exports = class LiveReload {
async start(port, cwd, delay=0) {
if (!parseInt(port)) port = 35729
this.files = []
// install the JS script for the client
const app = express()
app.use('/livereload.js', express.static(require.resolve('livereload-js')))
return new Promise ((resolve, reject) => {
let server
const listening = async () => {
const {address,port} = server.address()
startWs(server)
this.url = await livereloadURL(address, port)
DEBUG?.(`live reload available at ${this.url}`)
resolve(this.url)
}
// start server
server = app.listen (port,'localhost')
server.once('listening', listening)
server.on('error', e => {
// try once more w/ ephemeral port 0
if (e.code === 'EADDRINUSE') {
server = app.listen (0,'localhost')
server.once('listening', listening)
server.on('error', reject)
}
else reject(e)
})
// start websocket
const startWs = (server) => {
this.wsServer = new ws.Server({ server })
this.wsServer.on('connection', (socket) => {
DEBUG?.('Client connected')
socket.on('message', (msg) => {
DEBUG?.('Client message', msg.toString())
const request = JSON.parse(msg)
if (request.command === 'hello') {
DEBUG?.('Client handshake')
const data = JSON.stringify({
command: 'hello',
protocols: [
'http://livereload.com/protocols/official-7',
'http://livereload.com/protocols/official-8',
'http://livereload.com/protocols/official-9',
'http://livereload.com/protocols/2.x-origin-version-negotiation',
'http://livereload.com/protocols/2.x-remote-control',
],
serverName: 'cds-watch',
})
socket.send(data)
} else if (request.command === 'info') {
DEBUG?.('Server received client data. Not sending response.')
}
})
})
if (LOG._debug) {
server.on('close', () => DEBUG('⚡️','cds watch - livereload closed'))
this.wsServer.on('close', () => DEBUG('⚡️','cds watch - web socket closed'))
this.wsServer.on('error', (e) => DEBUG(e))
}
}
process.once('shutdown', ()=>{
this.wsServer.clients?.forEach(ws => ws.terminate())
this.wsServer.close()
server.close()
})
// Watching for touched files...
const watch = require('node-watch')
const filter = f => {
const isIn = !watchExcludes.test(f) && watchIncludes.test(f)
if (!isIn) DEBUG?. (`livereload ignored: ${relative(cwd||process.cwd(), f)}`)
// else DEBUG?. (`livereload NOT ignored: ${relative(cwd||process.cwd(), f)}`, watchIncludes)
return isIn
}
const watcher = watch (cwd||process.cwd(),{ recursive:true, filter, delay:0 })
if (LOG._debug) watcher.on('close', ()=> DEBUG('⚡️', 'cds watch - livereload file watcher closed'))
watcher.on('change', _coalesceEvents(delay, (files) => {
DEBUG?. (`livereload for: ${files.map(f => f.name)}`)
this.markPending(files.map(f => f.name))
this.reload()
}))
process.once('shutdown', ()=> watcher.close())
})
}
markPending(files=[]) {
this.files = files
}
reload() {
const data = JSON.stringify({
command: 'reload',
path: this.files.length ? this.files[0] : '',
})
DEBUG?.(`live reload for ${this.files}. ${this.wsServer.clients.size} ws clients`)
broadcast(data, this.wsServer.clients)
this.files = []
}
get address() {
return this.url ? new URL(this.url).host : null
}
}
// reduces mutiple node-watch events in a time frame into a bulk
function _coalesceEvents(delay, fn) {
let timer, cache = []
return function(type, name) { // node-watch callback signature
if (type) cache.push({type, name})
if (!timer) timer = setTimeout(() => {
fn(cache)
timer = null
cache = []
}, delay).unref();
}
}
function broadcast(data, sockets) {
sockets.forEach(socket => {
DEBUG?.(`Sending ${data}`)
socket.send(data, err=> DEBUG?.(err))
})
}
async function livereloadURL(address, port) {
if (process.env.WS_BASE_URL) {
// on BAS, expose the livereload port as an externally accessible URL
try {
const resp = await axios.get('http://localhost:3001/AppStudio/api/getHostByPort?port=' + port)
return resp.data.result + 'livereload.js?snipver=1&port=443'
} catch (e) {
console.error(`Error exposing live reload port ${port}: ${e.message}`)
return
}
}
// Local case. Add effective server address/host to URL.
// Make sure IPv6 addresses are properly escaped.
if (require('net').isIPv6(address)) address = `[${address}]`
return `http://${address}:${port}/livereload.js?snipver=1`
}