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
runtimeTag := []byte(``) htmldata = insertAfterStartTag(htmldata, runtimeTag, "") // Inject the root-capture shim before rootScript := []byte("") htmldata = insertBeforeEndTag(htmldata, rootScript, "") // 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 "" 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 ")) { js = append(js, []byte("")...) } // 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("")): return bytes.Replace(htmldata, []byte(""), append(js, []byte("")...), 1) case bytes.Contains(htmldata, []byte("
")): // If not, place the script in the
, if there is a head return bytes.Replace(htmldata, []byte("
"), append([]byte("
"), js...), 1) case huldra.IsHTML(htmldata): const maxPosition = 200 // try to retrieve the entire tag if htmlTagBytes, err := huldra.GetHTMLTag(htmldata, maxPosition); err == nil { // success htmlAndHeadTagBytes := append(htmlTagBytes, []byte("
")...) // Place the script in the as a new
return bytes.Replace(htmldata, htmlTagBytes, append(append(htmlAndHeadTagBytes, js...), []byte("")...), 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...) }