engine/sse.go
Rama main · réplica de lectura del repositorio original.
package engine
import (
"bytes"
"net"
"net/http"
"strings"
"text/template"
"github.com/xyproto/algernon/timeutils"
"github.com/xyproto/algernon/utils"
"github.com/xyproto/huldra"
)
// autoloadTemplate is the SSE listener injected into HTML pages when
// auto-refresh is active. It hot-swaps CSS and JSX changes in-place
// and falls back to a full reload for everything else.
const autoloadTemplate = `
if (!!window.EventSource) {
window.setTimeout(function() {
var source = new EventSource(window.location.protocol + '//{{js .FullHost}}{{js .EventPath}}');
source.addEventListener('message', function(e) {
var changed = e.data;
var basename = changed.replace(/.*[\/\\]/, '');
if (/\.(css|gcss)$/i.test(basename)) {
var cssBase = basename.replace(/\.gcss$/i, '.css');
var swapped = false;
document.querySelectorAll('link[rel=stylesheet][href]').forEach(function(link) {
var b = link.getAttribute('href').split('?')[0].replace(/.*[\/\\]/, '');
if (b === cssBase || b === basename) { link.href = link.href.split('?')[0] + '?t=' + Date.now(); swapped = true; }
});
if (swapped) { return; }
}
if (/\.(jsx?|tsx?)$/i.test(basename)) {
var oldEl = null;
document.querySelectorAll('script[src]').forEach(function(s) {
if (s.getAttribute('src').split('?')[0].replace(/.*[\/\\]/, '') === basename) { oldEl = s; }
});
var pageDir = window.location.pathname.replace(/\/[^\/]*$/, '/').slice(1);
if (oldEl) {
if (window.__algernonHMRBegin) { window.__algernonHMRBegin(); }
var freshEl = document.createElement('script');
var oldSrc = oldEl.getAttribute('src').split('?')[0].replace(/^\//, '').replace('{{.HMRUpdatePrefix}}', '');
if (oldSrc.indexOf('/') < 0 && pageDir) { oldSrc = pageDir + oldSrc; }
freshEl.src = '{{.HMRUpdatePath}}' + oldSrc + '?t=' + Date.now();
freshEl.onload = function() { if (window.__algernonHMREnd) { window.__algernonHMREnd(); } };
freshEl.onerror = function() { if (window.__algernonHMREnd) { window.__algernonHMREnd(); } };
oldEl.parentNode.replaceChild(freshEl, oldEl);
return;
}
var refreshed = false;
if (window.__algernonHMRBegin) { window.__algernonHMRBegin(); }
document.querySelectorAll('script[src]').forEach(function(s) {
var raw = s.getAttribute('src').split('?')[0];
if (!/\.(jsx|tsx)$/i.test(raw)) { return; }
var rel = raw.replace(/^\//, '').replace('{{.HMRUpdatePrefix}}', '');
if (!rel) { return; }
if (rel.indexOf('/') < 0 && pageDir) { rel = pageDir + rel; }
var n = document.createElement('script');
n.src = '{{.HMRUpdatePath}}' + rel + '?t=' + Date.now();
n.onload = function() { if (window.__algernonHMREnd) { window.__algernonHMREnd(); } };
n.onerror = function() { if (window.__algernonHMREnd) { window.__algernonHMREnd(); } };
s.parentNode.replaceChild(n, s);
refreshed = true;
});
if (!refreshed && window.__algernonHMREnd) { window.__algernonHMREnd(); }
if (refreshed) { return; }
}
if (('/' + changed).indexOf(window.location.pathname) >= 0) { location.reload(); }
}, false);
}, {{.RefreshTimeout}});
}
`
type templateData struct {
FullHost string
EventPath string
RefreshTimeout string
HMRUpdatePath string // URL path with leading slash
HMRUpdatePrefix string // URL path without leading slash, for JS string replace
}
// hostPortSymbols are the non-alphanumeric characters that may appear in a
// host and port, like "localhost:3000" or "[::1]:3000"
const hostPortSymbols = ".-:[]_"
// validHostPort checks that a string only contains characters that may appear
// in a host and port
func validHostPort(s string) bool {
if s == "" {
return false
}
for _, r := range s {
alphanumeric := r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9'
if !alphanumeric && !strings.ContainsRune(hostPortSymbols, r) {
return false
}
}
return true
}
// insertBeforeEndTag inserts js immediately before endTag in htmldata
func insertBeforeEndTag(htmldata, js []byte, endTag string) []byte {
tag := []byte(endTag)
idx := bytes.Index(htmldata, tag)
if idx < 0 {
return htmldata
}
result := make([]byte, 0, len(htmldata)+len(js))
result = append(result, htmldata[:idx]...)
result = append(result, js...)
result = append(result, htmldata[idx:]...)
return result
}
// insertAfterStartTag inserts js immediately after startTag in htmldata
func insertAfterStartTag(htmldata, js []byte, startTag string) []byte {
tag := []byte(startTag)
idx := bytes.Index(htmldata, tag)
if idx < 0 {
return htmldata
}
after := idx + len(tag)
result := make([]byte, 0, len(htmldata)+len(js))
result = append(result, htmldata[:after]...)
result = append(result, js...)
result = append(result, htmldata[after:]...)
return result
}
// InsertAutoRefresh inserts JavaScript code to the page that makes the page
// refresh itself when the source files changes.
// The JavaScript depends on the event server being available.
// If JavaScript can not be inserted, return the original data.
// Assumes that the given htmldata is actually HTML
// (looks for body/head/html tags when inserting a script tag)
func (ac *Config) InsertAutoRefresh(req *http.Request, htmldata []byte) []byte {
// When using a separate event server, point EventSource at eventAddr;
// otherwise SSE is on the main mux, so use serverAddr.
fullHost := ac.serverAddr
if ac.separateEventServer {
fullHost = ac.eventAddr
}
// If the host+port is just a port, add the hostname in front
if host, _, err := net.SplitHostPort(fullHost); err == nil && host == "" {
if ac.serverHost != "" {
fullHost = utils.JoinHostPort(ac.serverHost, fullHost)
} else {
// Use the raw request host
fullHost = utils.JoinHostPort(utils.GetHost(req), fullHost)
}
}
// The host can come from the request, and ends up in a JavaScript string
if !validHostPort(fullHost) {
fullHost = utils.HostPortToURL(ac.serverAddr)
}
// Wait 70% of an event duration before starting to listen for events
multiplier := 0.7
refreshTimeout := timeutils.DurationToMS(ac.refreshDuration, multiplier)
tmplData := templateData{
FullHost: fullHost,
EventPath: ac.defaultEventPath,
RefreshTimeout: refreshTimeout,
HMRUpdatePath: hmrUpdatePrefix,
HMRUpdatePrefix: strings.TrimPrefix(hmrUpdatePrefix, "/"),
}
// Swap React production builds to development equivalents for react-refresh
htmldata = ac.swapReactProdToDev(htmldata)
// Inject the react-refresh runtime as the first script in <head>
runtimeTag := []byte(`<script src="` + hmrRefreshRuntimePath + `"></script>`)
htmldata = insertAfterStartTag(htmldata, runtimeTag, "<head>")
// Inject the root-capture shim before </head>
rootScript := []byte("<script>" + hmrRootCaptureScript + "</script>")
htmldata = insertBeforeEndTag(htmldata, rootScript, "</head>")
// Parse the template
t, err := template.New("auto-refresh").Parse(autoloadTemplate)
if err != nil {
return htmldata
}
var script bytes.Buffer
err = t.Execute(&script, tmplData)
if err != nil {
return htmldata
}
return InsertScriptTag(htmldata, script.Bytes())
}
// InsertScriptTag takes HTML and JS and tries to insert the JS in a good spot, then returns modified HTML.
// If the JS does not start with "<script>" and ends with "</script>" then those tags are added.
func InsertScriptTag(htmldata, js []byte) []byte {
// Reduce the size slightly
js = bytes.TrimSpace(bytes.ReplaceAll(js, []byte{'\n'}, []byte{}))
// Replace whitespace with single whitespace, repeatedly
for bytes.Contains(js, []byte{' ', ' '}) {
js = bytes.ReplaceAll(js, []byte{' ', ' '}, []byte{' '})
}
// Add tags, if needed (only check for <script and not <script> in case it is there and async)
if !bytes.HasPrefix(js, []byte("<script")) {
js = append([]byte("<script>"), js...)
}
if !bytes.HasSuffix(js, []byte("</script>")) {
js = append(js, []byte("</script>")...)
}
// If there is no htmldata, then just return the script tag with JS
if len(htmldata) == 0 {
return js
}
// TODO: Write a better HTML manipulator. Create an external package.
// Place the script at the end of the body, if there is a body
switch {
case bytes.Contains(htmldata, []byte("</body>")):
return bytes.Replace(htmldata, []byte("</body>"), append(js, []byte("</body>")...), 1)
case bytes.Contains(htmldata, []byte("<head>")):
// If not, place the script in the <head>, if there is a head
return bytes.Replace(htmldata, []byte("<head>"), append([]byte("<head>"), js...), 1)
case huldra.IsHTML(htmldata):
const maxPosition = 200
// try to retrieve the entire <html[...]> tag
if htmlTagBytes, err := huldra.GetHTMLTag(htmldata, maxPosition); err == nil { // success
htmlAndHeadTagBytes := append(htmlTagBytes, []byte("<head>")...)
// Place the script in the <html> as a new <head>
return bytes.Replace(htmldata, htmlTagBytes, append(append(htmlAndHeadTagBytes, js...), []byte("</head>")...), 1)
}
}
// In the unlikely event that no place to insert the JavaScript was found, just add the script tag to the end
return append(htmldata, js...)
}