five-server
Version:
Development Server with Live Reload Capability. (Maintained Fork of Live Server)
436 lines (367 loc) • 13.9 kB
text/typescript
declare const diffDOM: any
import { Highlight } from './highlight'
import { appendPathToUrl } from '../src/helpers'
// clone the current state of the body before any javascript
// manipulates it inside window.addEventListener('load', (...))
let _internalDOMBody
const block = document.body ? document.body.hasAttribute('data-server-no-reload') : false
if (block) {
console.info("[Five Server] Reload disabled due to 'data-server-no-reload' attribute on BODY element")
}
if ('WebSocket' in window && !block) {
window.addEventListener('load', () => {
// console.log('[Five Server] connecting...')
const script = document.querySelector('[data-id="five-server"]') as HTMLScriptElement
const baseurl = new URL(script.src).pathname.split('/').slice(0, -1).join('/')
const protocol = window.location.protocol === 'http:' ? 'ws://' : 'wss://'
const address = appendPathToUrl(`${protocol}${new URL(script.src).host}${baseurl}`, 'fsws')
// check if we need to clone the body for the "injectBody" feature or not
const optionsInjectBody = script.getAttribute('data-inject-body')
if (optionsInjectBody && optionsInjectBody.toString() === 'true')
_internalDOMBody = document.body ? document.body.cloneNode(true) : undefined
let timer: any = null
const highlight = new Highlight(true)
highlight.redraw()
window.addEventListener('resize', () => {
highlight.redraw()
})
const CONNECTED_MSG = '[Five Server] connected.'
const MAX_ATTEMPTS = 25
let wait = 1000
let attempts = 0
let socket!: WebSocket
let lastPopUp = ''
const popup = (
message: string,
type: 'info' | 'success' | 'error' | 'warn',
options: { time?: number; animation?: boolean } = {}
) => {
const str = JSON.stringify({ message, type, options })
// block identical popups, except "css update"
if (lastPopUp === str && message !== 'css updated') return
lastPopUp = str
let wrapper = document.getElementById('fiveserver-info-wrapper')
if (wrapper) wrapper.remove()
const { time = 3, animation = true } = options
wrapper = document.createElement('div')
wrapper.id = 'fiveserver-info-wrapper'
wrapper.classList.add(`fiveserver-info-wrapper_${type}`)
wrapper.style.zIndex = '100'
wrapper.style.display = 'flex'
wrapper.style.justifyContent = 'center'
wrapper.style.position = 'fixed'
wrapper.style.top = 'flex'
wrapper.style.left = '50%'
wrapper.style.transform = 'translateX(-50%)'
wrapper.style.width = '100%'
wrapper.style.maxWidth = '80%'
const el = document.createElement('div')
el.id = 'fiveserver-info'
el.style.fontSize = '16px'
el.style.fontFamily = 'Arial, Helvetica, sans-serif'
el.style.color = 'white'
el.style.backgroundColor = 'black'
el.style.padding = '4px 12px'
el.style.borderRadius = '4px'
el.style.whiteSpace = 'pre-wrap'
wrapper.appendChild(el)
document.body.appendChild(wrapper)
// remove popup from DOM after 'time'
if (timer) {
clearTimeout(timer)
timer = null
}
timer = setTimeout(() => {
if (wrapper && wrapper.isConnected) wrapper.remove()
}, time * 1000)
if (type === 'error') {
wrapper.style.top = '4px'
wrapper.style.animation = ''
el.style.color = 'black'
el.style.backgroundColor = 'red'
} else {
if (animation) {
wrapper.style.top = '-40px'
wrapper.style.animation = `fiveserverInfoPopup ${time}s forwards`
} else {
wrapper.style.top = '4px'
wrapper.style.animation = ''
}
}
if (type === 'success') {
el.style.color = '#498d76'
el.style.backgroundColor = '#00ffa9'
} else if (type === 'info') {
el.style.color = '#d2e1f0'
el.style.backgroundColor = '#2996ff'
}
el.innerHTML = message.replace(/</gm, '<')
}
const send = (type: string, ...message: string[]) => {
if (socket && socket?.readyState === 1) {
socket.send(JSON.stringify({ console: { type, message } }))
}
}
const overwriteLogs = () => {
// log
const oldLog = console.log
console.log = function (...message) {
if (message[0] === CONNECTED_MSG) {
popup('connected', 'success')
} else {
send('log', ...message)
}
oldLog.apply(console, message)
}
// warn
const oldWarn = console.warn
console.warn = function (...message) {
send('warn', ...message)
oldWarn.apply(console, message)
}
// error
const oldError = console.error
console.error = function (...message) {
send('error', ...message)
oldError.apply(console, message)
}
}
const refreshCSS = (showPopup: boolean) => {
const head = document.getElementsByTagName('head')[0]
let sheets = Array.from(document.getElementsByTagName('link'))
sheets = sheets.filter(sheet => /\.css/gm.test(sheet.href) || sheet.rel.toLowerCase() == 'stylesheet')
for (let i = 0; i < sheets.length; ++i) {
const el = sheets[i]
const newEl = el.cloneNode(true) as HTMLLinkElement
// changing the href of the css file will make the browser refetch it
const url = newEl.href.replace(/(&|\?)_cacheOverride=\d+/, '')
newEl.href = `${url}${url.indexOf('?') >= 0 ? '&' : '?'}_cacheOverride=${new Date().valueOf()}`
newEl.onload = () => {
setTimeout(() => el.remove(), 0)
}
head.appendChild(newEl)
}
if (sheets.length > 0 && showPopup) popup('css updated', 'info')
}
const injectBody = body => {
document.body.innerHTML = body
}
let _diffDOMStatus = ''
let _dd
const addDiffDOM = (): Promise<void> => {
_diffDOMStatus = 'loading'
return new Promise(resolve => {
const baseurl = new URL(script.src).pathname.split('/').slice(0, -1).join('/')
const url = `//${new URL(script.src).host}${baseurl}/fiveserver/scripts/diffDOM/diffDOM.js`
const s = document.createElement('script')
s.type = 'text/javascript'
s.src = url
s.onload = () => {
setTimeout(() => {
_dd = new diffDOM.DiffDOM()
_diffDOMStatus = 'ready'
resolve()
})
}
document.getElementsByTagName('head')[0].appendChild(s)
})
}
const domParser = new DOMParser()
let diffError = false
const updateBody = async (d: any) => {
if (_diffDOMStatus === '') await addDiffDOM()
if (_diffDOMStatus === 'ready') {
try {
const body = _internalDOMBody
const newBody = domParser.parseFromString(d, 'text/html').querySelector('body')
const tmp = document.createElement('body')
tmp.innerHTML = d
// copy all attributes
if (newBody) {
if (newBody.attributes.length > 0)
for (let i = 0; i < newBody.attributes.length; i++) {
const attr = newBody.attributes.item(i)
if (attr) {
const newAttr = document.createAttribute(attr.name)
newAttr.value = attr.value
tmp.attributes.setNamedItem(newAttr)
}
}
}
const diff = _dd.diff(body, tmp)
const testBody = document.body.cloneNode(true)
const testSuccess = _dd.apply(testBody, diff)
if (testSuccess) {
const success = _dd.apply(document.body, diff)
if (success) {
_internalDOMBody = tmp
if (diffError) {
diffError = false
appendMessage('HIDE')
}
// scroll element into view (center of page)
const el = document.querySelector(`[data-highlight="true"]`)
if (el) {
const documentOffsetTop = el => {
return el.offsetTop + (el.offsetParent ? documentOffsetTop(el.offsetParent) : 0)
}
const pos = documentOffsetTop(el) - window.innerHeight / 2
window.scrollTo(0, pos)
}
}
}
} catch (error) {
diffError = true
appendMessage('Having issues parsing the DOM.\nPlease verify that your HTML is valid...')
}
}
}
const appendMessages = (msg: string[]) => {
appendMessage(msg.join('\n\n'))
}
const appendMessage = (msg: string) => {
if (msg === 'HIDE' || msg === 'HIDE_MESSAGE' || msg === 'HIDE_MESSAGES') {
const wrapper = document.getElementById('fiveserver-info-wrapper')
if (wrapper) wrapper.remove()
} else {
popup(msg, 'info', { animation: false })
}
}
const connect = () => {
socket = new WebSocket(address)
socket.onmessage = function (msg) {
wait = 1000
attempts = 0
if (msg.data === 'reload') window.location.reload()
else if (msg.data === 'refreshcss') refreshCSS(true)
else if (msg.data === 'refreshcss-silent') refreshCSS(false)
else if (msg.data === 'connected') {
// console.log(CONNECTED_MSG)
// dispatch "connected" event when client is connected
const script = document.querySelector('[data-id="five-server"]')
if (script) script.dispatchEvent(new Event('connected'))
} else if (msg.data === 'initRemoteLogs') overwriteLogs()
else {
const d = JSON.parse(msg.data)
if (d.navigate) window.location.replace(d.navigate)
// hot body injection
if (d.body && d.hot) updateBody(d.body)
// simple body replacement
else if (d.body) injectBody(d.body)
// message and messages 🤣
if (d.messages) appendMessages(d.messages)
if (d.message) appendMessage(d.message)
// redraw the highlight on body update
if (d.body) highlight.redraw()
}
}
socket.onopen = function () {
// reload page on successful reconnection
if (attempts > 0) {
window.location.reload()
return
}
const scripts = document.querySelectorAll('script')
for (let i = 0; i < scripts.length; i++) {
const script = scripts[i]
if (script.dataset && script.dataset.file) {
socket.send(JSON.stringify({ file: script.dataset.file }))
}
}
// add styles to body
const style = document.createElement('style')
style.innerHTML = `
/* Injected by five-server */
/*[data-highlight="true"] {
border: 1px rgb(90,170,255) solid !important;
background-color: rgba(155,215,255,0.5);
animation: fadeOutHighlight 1s forwards 0.5s;
}
img[data-highlight="true"] {
filter: sepia(100%) hue-rotate(180deg) saturate(200%);
animation: fadeOutHighlightIMG 0.5s forwards 0.5s;
}*/
@keyframes fadeOutHighlight {
from {background-color: rgba(155,215,255,0.5);}
to {background-color: rgba(155,215,255,0);}
}
@keyframes fadeOutHighlightIMG {
0% {filter: sepia(100%) hue-rotate(180deg) saturate(200%);}
33% {filter: sepia(66%) hue-rotate(180deg) saturate(100%);}
50% {filter: sepia(50%) hue-rotate(90deg) saturate(50%);}
66% {filter: sepia(33%) hue-rotate(0deg) saturate(100%);}
100% {filter: sepia(0%) hue-rotate(0deg) saturate(100%);}
}
@keyframes fiveserverInfoPopup {
0% {top:-40px;}
15% {top:4px;}
85% {top:4px;}
100% {top:-40px;}
}
/*smaller*/
@media (max-width: 640px) {
#fiveserver-info-wrapper {
max-width: 98%;
}
#fiveserver-info {
border-radius: 0px;
}
}
`
document.head.appendChild(style)
}
socket.onclose = function (e) {
setTimeout(function () {
popup('lost connection to dev server', 'error')
}, 300)
if (attempts === 0) console.log('Socket is closed. Reconnect will be attempted in 1 second.', e.reason)
setTimeout(function () {
attempts++
if (attempts > 1) console.log('connecting...')
if (attempts <= MAX_ATTEMPTS) connect()
wait = Math.floor(wait * 1.1)
}, wait)
}
socket.onerror = function (event) {
// console.error('Socket encountered error: ', event, 'Closing socket')
socket.close()
}
}
const MAX_STATUS_CHECK = 10
let statusChecks = 0
const reCheckStatus = () => {
if (statusChecks > MAX_STATUS_CHECK) {
console.error('[Five Server] status check failed')
console.log('[Five Server] browser reloads in 5 seconds')
setTimeout(() => {
window.location.reload()
}, 5000)
return
}
console.log('[Five Server] status check...')
setTimeout(() => {
checkStatus()
}, 1000)
}
const checkStatus = async () => {
statusChecks++
const p = new URL(script.src).protocol
const h = new URL(script.src).host
const baseurl = new URL(script.src).pathname.split('/').slice(0, -1).join('/')
const url = `${p}//${h}${baseurl}/fiveserver/status`
try {
const res = await fetch(url)
const json = await res.json()
if (json && json.status && json.status === 'online') {
connect()
statusChecks = 0
} else {
reCheckStatus()
}
} catch (error) {
reCheckStatus()
}
}
checkStatus()
})
}