veb, tools: live reload over server-sent events, keeping page state (#30208)
* tools: make `v watch` build veb apps before restarting them - Watch `templates/` and its subdirectories in veb projects. Templates are compiled in, but the compiler does not report them, so a change to `templates/layout/header.html` never triggered a rebuild. - For veb apps, build the new executable while the old server keeps serving, and swap them only when the build succeeds, so the app is down only for the restart. A failed build leaves the old server running. A change during a build cancels it and starts a new one, after a 150 ms debounce. `--no-build-first` restores the previous behaviour. - Write the build status (`building`, `failed` with the compiler output, or `ok`) to a file named by VEB_LIVERELOAD_STATUS in the app's environment, for veb's live reload. Print how long each build took. - Stop the build and the program on SIGTERM and SIGHUP, as on Ctrl-C. They run in their own process groups, so they kept running (and kept their port) after `v watch` was terminated. * veb: live reload over server-sent events, keeping page state With `-d veb_livereload`: - Replace the 250 ms polling with one Server-Sent Events stream per page, held in a shared hub (no thread per tab), capped by `-d veb_livereload_max_streams` (default 32). A keepalive ping every 15 s drops closed tabs. - After a restart, fetch the page again and morph the DOM in place, keeping scroll position, focus, typed form values and page JS state. Pages from a POST, failed fetches and non-HTML answers reload instead. - Follow the files of a StaticHandler: a changed stylesheet is swapped in place, other files update the page, with no rebuild. - Relay the build status written by `v watch` (VEB_LIVERELOAD_STATUS): a toast for rebuilding/updated, and a panel with the compiler output when a build fails. `-d veb_livereload_no_toast` hides them. - Reconnect every 250 ms; `-d veb_livereload_retry_max_ms` lets it back off. - Keep the polling script behind `-d veb_livereload_polling`, for HTTPS apps and for proxies that block long-lived connections, with its interval set by `-d veb_livereload_poll_ms`. Deprecate the unused `Context.livereload_poll_interval_ms`, which could never be set. * examples, docs: update the veb live reload instructions - examples/vwatch/web_server: put `-d veb_livereload` after `watch` (the order before `watch` is currently dropped, see #30199), describe the new live reload, and drop `--only-watch=*.v,*.html,*.css,*.js`, which is the default for veb projects. - v-veb skill: live reload is enabled by `-d veb_livereload` and applies to any HTML response ending with `</html>`, not only to `$veb.html()` in debug builds. - veb blog tutorial: suggest `v watch -d veb_livereload run blog.v`, so the open page updates too. * veb: reload changed scripts and page resources * veb: preserve typed values on their original form controls --------- Co-authored-by: Alexander Medvednikov <alexander@medvednikov.com>
This commit is contained in:
1 parent
084481c3e9
commit
640dda22aa
16 files changed
+1557
-53
No files matched your search
+273
-5
@@ -18,6 +18,14 @@ const max_scan_cycles = scan_timeout_s * scan_frequency_hz
|
||||
|
||||
const default_veb_suffixes = '*.v,*.html,*.css,*.js,*.md,*.tr'
|
||||
|
||||
// build_debounce_ms is how long a build waits after the last change, so that
|
||||
// an editor writing a file several times starts one build, not several.
|
||||
const build_debounce_ms = 150
|
||||
|
||||
// veb_status_env names the environment variable that tells a veb app, built
|
||||
// with `-d veb_livereload`, where `v watch` writes the build status.
|
||||
const veb_status_env = 'VEB_LIVERELOAD_STATUS'
|
||||
|
||||
fn get_scan_timeout_seconds() int {
|
||||
env_vw_timeout := os.getenv('VWATCH_TIMEOUT').int()
|
||||
if env_vw_timeout == 0 {
|
||||
@@ -102,6 +110,11 @@ mut:
|
||||
cmd_before_run string // a command to run before each re-run
|
||||
cmd_after_run string // a command to run after each re-run
|
||||
only_watch []string // If not empty, *all* files that trigger updates, should match *at least one* of these s.match_glob() patterns. This is also triggered for veb apps, to monitor for just *.v,*.js,*.css,*.html in veb projects.
|
||||
has_run bool // whether the watched command is `run`
|
||||
is_veb bool // whether the program uses veb
|
||||
build_first bool // for veb apps: build while the old server runs, and swap on success
|
||||
build_process &os.Process = unsafe { nil } // the build in progress, in build-first mode
|
||||
status_path string // where build-first mode writes the build status, for veb's livereload
|
||||
}
|
||||
|
||||
struct ParsedWatchOptions {
|
||||
@@ -117,6 +130,7 @@ mut:
|
||||
cmd_before_run string
|
||||
cmd_after_run string
|
||||
show_help bool
|
||||
no_build_first bool
|
||||
}
|
||||
|
||||
@[if debug_vwatch ?]
|
||||
@@ -165,6 +179,18 @@ fn (mut context Context) is_ext_ignored(pf string, pf_ext string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// add_dir_tree adds `dir` and all of its subdirectories (not following
|
||||
// symlinks) to the set of watched folders.
|
||||
fn add_dir_tree(mut apaths map[string]bool, dir string) {
|
||||
apaths[os.real_path(dir)] = true
|
||||
for entry in os.ls(dir) or { []string{} } {
|
||||
sub := os.join_path_single(dir, entry)
|
||||
if os.is_dir(sub) && !os.is_link(sub) {
|
||||
add_dir_tree(mut apaths, sub)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
|
||||
if context.affected_paths.len == 0 {
|
||||
mut apaths := map[string]bool{}
|
||||
@@ -188,6 +214,7 @@ fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
|
||||
apaths[os.real_path(os.dir(vf))] = true
|
||||
if vf.contains('veb.v') {
|
||||
is_veb_found = true
|
||||
context.is_veb = true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -214,6 +241,13 @@ fn (mut context Context) get_stats_for_affected_vfiles() []VFileStat {
|
||||
if os.is_dir(tr_dir) {
|
||||
apaths[os.real_path(tr_dir)] = true
|
||||
}
|
||||
// Templates are compiled into the program, but the compiler does not
|
||||
// report them either. Watch `templates/` and its subdirectories, so
|
||||
// that a layout in `templates/layout/` triggers a rebuild too.
|
||||
tpl_dir := os.join_path_single(path, 'templates')
|
||||
if os.is_dir(tpl_dir) {
|
||||
add_dir_tree(mut apaths, tpl_dir)
|
||||
}
|
||||
}
|
||||
}
|
||||
context.affected_paths = apaths.keys()
|
||||
@@ -290,6 +324,7 @@ fn change_detection_loop(ocontext &Context) {
|
||||
for {
|
||||
if context.v_cycles >= max_v_cycles || context.scan_cycles >= max_scan_cycles {
|
||||
context.is_exiting = true
|
||||
context.kill_build()
|
||||
context.kill_pgroup()
|
||||
time.sleep(50 * time.millisecond)
|
||||
exit(255)
|
||||
@@ -318,6 +353,202 @@ fn (mut context Context) kill_pgroup() {
|
||||
context.child_process.close()
|
||||
}
|
||||
|
||||
// kill_build stops the build in progress, if any. It returns true when there was one.
|
||||
fn (mut context Context) kill_build() bool {
|
||||
if unsafe { context.build_process == 0 } {
|
||||
return false
|
||||
}
|
||||
mut bp := context.build_process
|
||||
context.build_process = unsafe { nil }
|
||||
if bp.is_alive() {
|
||||
bp.signal_pgkill()
|
||||
}
|
||||
bp.wait()
|
||||
bp.close()
|
||||
return true
|
||||
}
|
||||
|
||||
// stop_app stops the running program, if any.
|
||||
fn (mut context Context) stop_app() {
|
||||
context.kill_pgroup()
|
||||
context.child_process = unsafe { nil }
|
||||
}
|
||||
|
||||
// write_status records the build status for a veb app's livereload: the state
|
||||
// on the first line (`building`, `failed` or `ok`), then the compiler output.
|
||||
// It replaces the file in one step, so the app never reads a partial status.
|
||||
fn (mut context Context) write_status(state string, details string) {
|
||||
tmp := context.status_path + '.tmp'
|
||||
os.write_file(tmp, state + '\n' + details) or { return }
|
||||
os.mv(tmp, context.status_path) or {}
|
||||
}
|
||||
|
||||
// start_app runs the built program, telling it where to read the build status.
|
||||
fn (mut context Context) start_app(app_bin string, args []string) {
|
||||
mut p := os.new_process(app_bin)
|
||||
p.use_pgroup = true
|
||||
p.set_args(args)
|
||||
mut env := os.environ()
|
||||
env[veb_status_env] = context.status_path
|
||||
p.set_environment(env)
|
||||
p.run()
|
||||
context.child_process = p
|
||||
context.child_has_tty = vwatchtty.set_foreground_process_group(p.pid, context.watcher_pgid)
|
||||
if !context.silent {
|
||||
eprintln('${time.now().format_ss_milli()}: ${app_bin} | pid: ${p.pid:7d} | reload cycle: ${context.v_cycles:5d}')
|
||||
}
|
||||
context.v_cycles++
|
||||
}
|
||||
|
||||
// default_output_path returns where `v` puts the executable for `target` by
|
||||
// default: `dir/dir` for a folder, and `file` for `file.v`.
|
||||
fn default_output_path(target string) string {
|
||||
abs := os.real_path(target)
|
||||
if os.is_dir(abs) {
|
||||
return os.join_path_single(abs, os.file_name(abs))
|
||||
}
|
||||
return abs.all_before_last('.v')
|
||||
}
|
||||
|
||||
// drain_build_output appends what the build printed since the last call.
|
||||
fn (mut context Context) drain_build_output(mut output []string) {
|
||||
if unsafe { context.build_process == 0 } {
|
||||
return
|
||||
}
|
||||
mut bp := context.build_process
|
||||
for {
|
||||
chunk := bp.stdout_read()
|
||||
if chunk == '' {
|
||||
break
|
||||
}
|
||||
output << chunk
|
||||
}
|
||||
}
|
||||
|
||||
// build_first_loop is used for veb apps, unless `--no-build-first` is given.
|
||||
// It builds a new executable while the old server keeps serving, then swaps
|
||||
// them only if the build succeeds: a failed build leaves the old server
|
||||
// running. A change during a build cancels it and starts a new one, since its
|
||||
// result would already be out of date. The status of each build is written to
|
||||
// `context.status_path`, where a livereload enabled veb app picks it up.
|
||||
fn (mut context Context) build_first_loop() {
|
||||
run_pos := context.opts.index('run')
|
||||
mut build_opts := context.opts#[..run_pos].clone()
|
||||
after_run := context.opts#[run_pos + 1..]
|
||||
target := if after_run.len > 0 { after_run[0] } else { '.' }
|
||||
program_args := after_run#[1..]
|
||||
mut app_bin := default_output_path(target)
|
||||
o_pos := build_opts.index('-o')
|
||||
if o_pos >= 0 && o_pos + 1 < build_opts.len {
|
||||
app_bin = os.abs_path(build_opts[o_pos + 1])
|
||||
build_opts.delete(o_pos + 1)
|
||||
build_opts.delete(o_pos)
|
||||
}
|
||||
$if windows {
|
||||
if !app_bin.ends_with('.exe') {
|
||||
app_bin += '.exe'
|
||||
}
|
||||
}
|
||||
new_bin := os.join_path_single(os.dir(app_bin), '.vwatch-new-' + os.file_name(app_bin))
|
||||
status_dir := os.join_path(os.vtmp_dir(), 'vwatch_${context.pid}')
|
||||
os.mkdir_all(status_dir) or {}
|
||||
context.status_path = os.join_path_single(status_dir, 'status')
|
||||
mut pending := true
|
||||
mut pending_since := time.now().add(-build_debounce_ms * time.millisecond)
|
||||
mut output := []string{}
|
||||
mut build_started := time.now()
|
||||
for {
|
||||
if context.is_exiting {
|
||||
context.kill_build()
|
||||
context.stop_app()
|
||||
return
|
||||
}
|
||||
if pending && time.since(pending_since) >= build_debounce_ms * time.millisecond {
|
||||
pending = false
|
||||
if context.kill_build() && !context.silent {
|
||||
eprintln('${time.now().format_ss_milli()}: build cancelled, a newer change arrived')
|
||||
}
|
||||
if context.clear_terminal {
|
||||
term.clear()
|
||||
}
|
||||
context.write_status('building', '')
|
||||
context.run_before_cmd()
|
||||
output = []string{}
|
||||
mut bp := os.new_process(context.vexe)
|
||||
bp.use_pgroup = true
|
||||
bp.set_args([...build_opts, '-o', new_bin, target])
|
||||
bp.set_redirect_stdio_merged()
|
||||
bp.run()
|
||||
context.build_process = bp
|
||||
build_started = time.now()
|
||||
if !context.silent {
|
||||
eprintln('${time.now().format_ss_milli()}: building ${target} | pid: ${bp.pid:7d}')
|
||||
}
|
||||
}
|
||||
if unsafe { context.build_process != 0 } {
|
||||
context.drain_build_output(mut output)
|
||||
mut bp := context.build_process
|
||||
if !bp.is_alive() {
|
||||
bp.wait()
|
||||
context.drain_build_output(mut output)
|
||||
code := bp.code
|
||||
bp.close()
|
||||
context.build_process = unsafe { nil }
|
||||
compiler_output := output.join('')
|
||||
if compiler_output.len > 0 {
|
||||
eprint(compiler_output)
|
||||
}
|
||||
if !context.silent {
|
||||
took := f64(time.since(build_started).milliseconds()) / 1000.0
|
||||
result := if code == 0 { 'succeeded' } else { 'failed' }
|
||||
eprintln('${time.now().format_ss_milli()}: build ${result} in ${took:.2f}s')
|
||||
}
|
||||
if code == 0 {
|
||||
context.stop_app()
|
||||
$if windows {
|
||||
os.rm(app_bin) or {}
|
||||
}
|
||||
os.mv(new_bin, app_bin) or {
|
||||
eprintln('v watch: cannot replace ${app_bin}: ${err}')
|
||||
context.write_status('failed', 'cannot replace ${app_bin}: ${err}')
|
||||
continue
|
||||
}
|
||||
context.write_status('ok', '')
|
||||
context.start_app(app_bin, program_args)
|
||||
} else {
|
||||
context.write_status('failed', compiler_output)
|
||||
if !context.silent {
|
||||
eprintln('${time.now().format_ss_milli()}: build failed; the previous version keeps running')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if unsafe { context.child_process != 0 } && !context.child_process.is_alive() {
|
||||
context.restore_terminal()
|
||||
context.child_process.wait()
|
||||
context.child_process.close()
|
||||
context.child_process = unsafe { nil }
|
||||
context.run_after_cmd()
|
||||
if context.keep_running && os.exists(app_bin) {
|
||||
context.start_app(app_bin, program_args)
|
||||
}
|
||||
}
|
||||
select {
|
||||
action := <-context.rerun_channel {
|
||||
if action == .quit {
|
||||
context.kill_build()
|
||||
context.stop_app()
|
||||
return
|
||||
}
|
||||
pending = true
|
||||
pending_since = time.now()
|
||||
}
|
||||
100 * time.millisecond {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn (mut context Context) restore_terminal() {
|
||||
if context.child_has_tty {
|
||||
vwatchtty.restore_foreground_process_group(context.watcher_pgid)
|
||||
@@ -350,6 +581,10 @@ fn (mut context Context) run_after_cmd() {
|
||||
fn (mut context Context) compilation_runner_loop() {
|
||||
cmd := '"${context.vexe}" ${context.opts.join(' ')}'
|
||||
_ := <-context.rerun_channel
|
||||
if context.build_first && context.has_run && context.is_veb {
|
||||
context.build_first_loop()
|
||||
return
|
||||
}
|
||||
for {
|
||||
context.elog('>> loop: v_cycles: ${context.v_cycles}')
|
||||
if context.clear_terminal {
|
||||
@@ -460,6 +695,8 @@ fn main() {
|
||||
context.only_watch = parsed_watch_options.only_watch
|
||||
context.cmd_before_run = parsed_watch_options.cmd_before_run
|
||||
context.cmd_after_run = parsed_watch_options.cmd_after_run
|
||||
context.has_run = has_run
|
||||
context.build_first = !parsed_watch_options.no_build_first
|
||||
context.opts = []
|
||||
context.opts << all_args_before_watch_cmd
|
||||
context.opts << parsed_watch_options.remaining_options
|
||||
@@ -510,6 +747,11 @@ fn parse_watch_options(args []string) !ParsedWatchOptions {
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if is_watch_option_name(arg, 'no-build-first', 0) {
|
||||
parsed.no_build_first = true
|
||||
i++
|
||||
continue
|
||||
}
|
||||
if is_watch_option_name(arg, 'help', `h`) {
|
||||
parsed.show_help = true
|
||||
i++
|
||||
@@ -631,12 +873,23 @@ fn (mut context Context) manager_main(all_args_before_watch_cmd []string, all_ar
|
||||
mut context := unsafe { &Context(voidptr(&ccontext)) }
|
||||
context.resume_worker = true
|
||||
}) or { panic(err) }
|
||||
// Pass termination on to the worker, which stops the program it runs.
|
||||
os.signal_opt(.term, manager_stop_signal_handler) or { panic(err) }
|
||||
os.signal_opt(.hup, manager_stop_signal_handler) or { panic(err) }
|
||||
}
|
||||
for {
|
||||
mut worker_process := os.new_process(myexecutable)
|
||||
worker_process.set_args(worker_opts)
|
||||
worker_process.run()
|
||||
for {
|
||||
if context.is_exiting {
|
||||
if worker_process.is_alive() {
|
||||
worker_process.signal_term()
|
||||
}
|
||||
worker_process.wait()
|
||||
worker_process.close()
|
||||
return
|
||||
}
|
||||
if context.resume_worker {
|
||||
context.resume_worker = false
|
||||
vwatchtty.continue_process_group_of(worker_process.pid)
|
||||
@@ -655,6 +908,18 @@ fn (mut context Context) manager_main(all_args_before_watch_cmd []string, all_ar
|
||||
}
|
||||
}
|
||||
|
||||
fn worker_stop_signal_handler(_ os.Signal) {
|
||||
mut context := unsafe { &Context(voidptr(&ccontext)) }
|
||||
context.is_exiting = true
|
||||
context.kill_build()
|
||||
context.kill_pgroup()
|
||||
}
|
||||
|
||||
fn manager_stop_signal_handler(_ os.Signal) {
|
||||
mut context := unsafe { &Context(voidptr(&ccontext)) }
|
||||
context.is_exiting = true
|
||||
}
|
||||
|
||||
fn propagate_terminal_stop(_ os.Signal) {
|
||||
context := unsafe { &Context(voidptr(&ccontext)) }
|
||||
vwatchtty.suspend_manager_process_group(context.watcher_pgid)
|
||||
@@ -663,11 +928,14 @@ fn propagate_terminal_stop(_ os.Signal) {
|
||||
fn (mut context Context) worker_main() {
|
||||
context.watcher_pgid = vwatchtty.process_group()
|
||||
context.rerun_channel = chan RerunCommand{cap: 10}
|
||||
os.signal_opt(.int, fn (_ os.Signal) {
|
||||
mut context := unsafe { &Context(voidptr(&ccontext)) }
|
||||
context.is_exiting = true
|
||||
context.kill_pgroup()
|
||||
}) or { panic(err) }
|
||||
os.signal_opt(.int, worker_stop_signal_handler) or { panic(err) }
|
||||
$if !windows {
|
||||
// `kill`, a closed terminal, or a process supervisor: stop the build and
|
||||
// the program too. They run in their own process groups, so they would
|
||||
// otherwise keep running (and keep their port) after `v watch` is gone.
|
||||
os.signal_opt(.term, worker_stop_signal_handler) or { panic(err) }
|
||||
os.signal_opt(.hup, worker_stop_signal_handler) or { panic(err) }
|
||||
}
|
||||
$if !windows {
|
||||
for signal in vwatchtty.terminal_stop_signals() {
|
||||
os.signal_opt(signal, propagate_terminal_stop) or { panic(err) }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import os
|
||||
import time
|
||||
import net.http
|
||||
|
||||
const vexe = @VEXE
|
||||
const vroot = os.dir(vexe)
|
||||
@@ -86,6 +87,146 @@ fn test_watch_run_reloads_on_source_change() {
|
||||
assert reload_error == '', '${reload_error}\n${output}'
|
||||
}
|
||||
|
||||
// For veb apps, `v watch` builds the new version while the old one keeps serving,
|
||||
// keeps the old one when a build fails, writes the build status for veb's live
|
||||
// reload, and stops the app when it is itself terminated.
|
||||
fn test_watch_builds_veb_app_first_and_stops_it_on_sigterm() {
|
||||
$if windows {
|
||||
return
|
||||
}
|
||||
app_dir := os.join_path(tsource_dir, 'vebapp')
|
||||
os.mkdir_all(app_dir)!
|
||||
port := 28000 + os.getpid() % 1000
|
||||
write_veb_app(app_dir, port, 'V1', true)!
|
||||
base := 'http://127.0.0.1:${port}'
|
||||
|
||||
mut process := os.new_process(vexe)
|
||||
process.set_work_folder(vroot)
|
||||
process.set_redirect_stdio()
|
||||
process.use_pgroup = true
|
||||
process.set_args(['watch', '-d', 'veb_livereload', 'run', app_dir])
|
||||
process.run()
|
||||
mut log := []string{}
|
||||
|
||||
mut errors := []string{}
|
||||
if !wait_for_http(base + '/', 'version V1', mut process, mut log) {
|
||||
errors << 'the first build never served `version V1`'
|
||||
} else {
|
||||
// A broken build must leave the old version serving, and say so:
|
||||
time.sleep(1500 * time.millisecond)
|
||||
write_veb_app(app_dir, port, 'V2', false)!
|
||||
if !wait_for_log('build failed; the previous version keeps running', mut process, mut
|
||||
log)
|
||||
{
|
||||
errors << 'no `build failed` message after a broken edit'
|
||||
}
|
||||
if get_text(base + '/') != 'version V1' {
|
||||
errors << 'the old version stopped serving after a failed build'
|
||||
}
|
||||
if !get_text(base + '/status').starts_with('failed\n') {
|
||||
errors << 'the build status was not `failed`: `${get_text(base + '/status')}`'
|
||||
}
|
||||
// Fixing it swaps in the new version:
|
||||
time.sleep(1500 * time.millisecond)
|
||||
write_veb_app(app_dir, port, 'V3', true)!
|
||||
if !wait_for_http(base + '/', 'version V3', mut process, mut log) {
|
||||
errors << 'the fixed build never served `version V3`'
|
||||
}
|
||||
if get_text(base + '/status') != 'ok\n' {
|
||||
errors << 'the build status was not `ok`: `${get_text(base + '/status')}`'
|
||||
}
|
||||
}
|
||||
// SIGTERM to `v watch` itself (not its process group) must stop the app too:
|
||||
process.signal_term()
|
||||
stopwatch := time.new_stopwatch()
|
||||
for get_text(base + '/') != '' && stopwatch.elapsed() < 10 * time.second {
|
||||
time.sleep(watch_poll_interval)
|
||||
}
|
||||
if get_text(base + '/') != '' {
|
||||
errors << 'the app kept serving after `v watch` got SIGTERM'
|
||||
}
|
||||
if process.is_alive() {
|
||||
process.signal_pgkill()
|
||||
}
|
||||
process.wait()
|
||||
drain_process_output(mut process, mut log)
|
||||
process.close()
|
||||
assert errors.len == 0, errors.join('\n') + '\n' + log.join('')
|
||||
}
|
||||
|
||||
fn write_veb_app(app_dir string, port int, version string, valid bool) ! {
|
||||
broken := if valid { '' } else { '\n\tthis does not compile' }
|
||||
os.write_file(os.join_path(app_dir, 'main.v'), "module main
|
||||
|
||||
import os
|
||||
import veb
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {}
|
||||
|
||||
pub fn (app &App) index(mut ctx Context) veb.Result {${broken}
|
||||
return ctx.text('version ${version}')
|
||||
}
|
||||
|
||||
pub fn (app &App) status(mut ctx Context) veb.Result {
|
||||
return ctx.text(os.read_file(os.getenv('VEB_LIVERELOAD_STATUS')) or { 'no status' })
|
||||
}
|
||||
|
||||
fn main() {
|
||||
mut app := &App{}
|
||||
veb.run_at[App, Context](mut app, family: .ip, host: '127.0.0.1', port: ${port}) or { panic(err) }
|
||||
}
|
||||
")!
|
||||
}
|
||||
|
||||
fn get_text(url string) string {
|
||||
res := http.get(url) or { return '' }
|
||||
return res.body
|
||||
}
|
||||
|
||||
fn drain_process_output(mut process os.Process, mut log []string) {
|
||||
for {
|
||||
chunk := process.stderr_read() + process.stdout_read()
|
||||
if chunk == '' {
|
||||
break
|
||||
}
|
||||
log << chunk
|
||||
}
|
||||
}
|
||||
|
||||
fn wait_for_http(url string, expected string, mut process os.Process, mut log []string) bool {
|
||||
stopwatch := time.new_stopwatch()
|
||||
for stopwatch.elapsed() < watch_ready_timeout {
|
||||
drain_process_output(mut process, mut log)
|
||||
if get_text(url) == expected {
|
||||
return true
|
||||
}
|
||||
if !process.is_alive() {
|
||||
return false
|
||||
}
|
||||
time.sleep(watch_poll_interval)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fn wait_for_log(text string, mut process os.Process, mut log []string) bool {
|
||||
stopwatch := time.new_stopwatch()
|
||||
for stopwatch.elapsed() < watch_ready_timeout {
|
||||
drain_process_output(mut process, mut log)
|
||||
if log.join('').contains(text) {
|
||||
return true
|
||||
}
|
||||
if !process.is_alive() {
|
||||
return false
|
||||
}
|
||||
time.sleep(watch_poll_interval)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
fn write_versioned_source(source_path string, version string) ! {
|
||||
os.write_file(source_path,
|
||||
"import os\nfn main() {\n\tos.write_file(os.args[1], '${version}') or { panic(err) }\n}\n")!
|
||||
|
||||
@@ -5,11 +5,13 @@ module main
|
||||
// Note 1: while developing services, it is useful to also add the `--keep` option of `v watch`,
|
||||
// which will restart the app right away, even when it exits on its own.
|
||||
//
|
||||
// Note 2: veb supports a special live reload mode, where it will make the browser to check for server
|
||||
// restarts, and it will trigger a refresh of the current page, right after that is detected.
|
||||
// Note 2: veb has a live reload mode. Compiled with `-d veb_livereload`, its pages update in place
|
||||
// whenever `v watch` restarts the app, keeping scroll position and form input; meanwhile
|
||||
// `v watch` builds the new version while the old one keeps serving.
|
||||
//
|
||||
// The above means, that to get the most optimal prototyping experience for veb apps, use:
|
||||
// `v -d veb_livereload watch --only-watch=*.v,*.html,*.css,*.js --keep run .`
|
||||
// `v watch -d veb_livereload --keep run .`
|
||||
// (put `-d veb_livereload` after `watch`, see https://github.com/vlang/v/issues/30199)
|
||||
import os
|
||||
import veb
|
||||
import db.sqlite
|
||||
@@ -57,7 +59,7 @@ fn (mut app App) update_db() !int {
|
||||
fn main() {
|
||||
println('App demonstrating the use of `veb` & `db.sqlite` together.')
|
||||
println('For best prototyping experience, run with:')
|
||||
println('`v -d veb_livereload watch --keep run examples/vwatch/web_server/`')
|
||||
println('`v watch -d veb_livereload --keep run examples/vwatch/web_server/`')
|
||||
println('')
|
||||
mut db := mydb()!
|
||||
db.exec('CREATE TABLE visits (id integer primary key AUTOINCREMENT, created_at timestamp default current_timestamp);')!
|
||||
|
||||
@@ -111,7 +111,8 @@ Custom routes can be defined using attributes like @['/index'].
|
||||
|
||||
<img width=662 src="https://github.com/vlang/v/blob/master/tutorials/building_a_simple_web_blog_with_veb/img/time.png?raw=true">
|
||||
|
||||
> TIP: run the following command to live-reload the server: `v watch run blog.v`
|
||||
> TIP: run `v watch -d veb_livereload run blog.v` to rebuild the server on every change,
|
||||
> and update the page open in your browser.
|
||||
|
||||
The `.text(string)` method returns a plain text document with the provided
|
||||
text, which isn't frequently used in websites.
|
||||
|
||||
@@ -32,6 +32,12 @@ Options:
|
||||
--after <string> A command to execute *after* each re-run.
|
||||
Example: --after 'rm -rf /tmp/v/'
|
||||
|
||||
--no-build-first For veb apps, `v watch` builds the new version while
|
||||
the old one keeps serving, and swaps them only when
|
||||
the build succeeds; a change during a build cancels
|
||||
it and starts a new one. This restores the old
|
||||
behaviour: stop the app, then build and run it.
|
||||
|
||||
You can also customise the timeout, after which `v watch` will re-start a
|
||||
monitored program automatically, even if it was not changed by setting the
|
||||
environment variable VWATCH_TIMEOUT (in seconds). By default, it is 5 min
|
||||
|
||||
@@ -120,8 +120,9 @@ pub fn (app &App) index(mut ctx Context) veb.Result {
|
||||
}
|
||||
```
|
||||
|
||||
`$veb.html()` also enables live reload in debug builds, so during development
|
||||
prefer it over naming a file.
|
||||
Live reload is enabled by compiling with `-d veb_livereload` (for example
|
||||
`v watch -d veb_livereload run .`). It applies to every HTML response that ends
|
||||
with `</html>`, from `$veb.html()` and `ctx.html()` alike.
|
||||
|
||||
## Reading the request
|
||||
|
||||
|
||||
+33
-5
@@ -18,16 +18,44 @@ features.
|
||||
|
||||
## Quick Start
|
||||
|
||||
Run your veb app with a live reload via `v -d veb_livereload watch run .`
|
||||
Run your veb app with a live reload via `v watch -d veb_livereload run .`
|
||||
|
||||
Now modifying any file in your web app (whether it's a .v file with the backend logic
|
||||
or a compiled .html template file) will result in an instant refresh of your app
|
||||
in the browser. No need to quit the app, rebuild it, and refresh the page in the browser!
|
||||
Stylesheet changes are applied in place. Changes to other static assets trigger a
|
||||
full reload so updated scripts and images are fetched. Page or template changes
|
||||
preserve the DOM state unless scripts or head elements changed; those changes
|
||||
also reload the page so the browser applies them.
|
||||
|
||||
Page and template edits update the browser while preserving scroll position, focus,
|
||||
and typed form values when scripts and head elements stay the same.
|
||||
No need to quit the app, rebuild it, and refresh the page in the browser!
|
||||
|
||||
* A `.v` file or a template (anywhere under `templates/`): `v watch` rebuilds and
|
||||
restarts the app, then the page fetches itself again and updates only what changed.
|
||||
The previous version keeps serving while the new one builds. If the build fails, it
|
||||
keeps running, and the page shows the compiler errors until the next successful build.
|
||||
`v watch --no-build-first` stops the app before each build instead.
|
||||
* A file served by a `veb.StaticHandler` (for example `static/css/main.css`): the
|
||||
running app notices the change itself, with no rebuild. Stylesheets are swapped in
|
||||
place; other files reload the page.
|
||||
|
||||
Note: this works by injecting a small `<script>` tag before the ending `</html>` tag of the page,
|
||||
that will check periodically if the current page has to be reloaded. In other words, it works for
|
||||
that keeps one Server-Sent Events connection open to the app. In other words, it works for
|
||||
template pages that have that `</html>` tag, or for ones that were produced by `ctx.html()` and that
|
||||
also have that closing tag, but not for ones produced by `ctx.text()` .
|
||||
Pages that were the result of a POST request, and pages that fail to update, are reloaded instead.
|
||||
A small notice in the corner of the page shows when the app is rebuilding and when the
|
||||
page was updated; hide it with `-d veb_livereload_no_toast`.
|
||||
|
||||
The live updates are not available for apps served over HTTPS (`veb.run_at` with an `ssl_config`),
|
||||
or behind a proxy that blocks long-lived connections. Those can keep using the older script, which
|
||||
polls the app and reloads the whole page after a restart:
|
||||
`v watch -d veb_livereload -d veb_livereload_polling run .`
|
||||
(set its interval with `-d veb_livereload_poll_ms=500`).
|
||||
|
||||
While the app restarts, the page retries the connection every 250 ms; each failed attempt
|
||||
shows up in the browser's network log. With `v watch --no-build-first`, the app is down for
|
||||
the whole build: `-d veb_livereload_retry_max_ms=1000` makes the retries back off, doubling
|
||||
from 250 ms up to that cap, for fewer failed attempts at the cost of a slower update.
|
||||
|
||||
## Port already in use
|
||||
|
||||
|
||||
+6
-3
@@ -79,8 +79,11 @@ pub mut:
|
||||
files map[string][]http.FileData
|
||||
res http.Response
|
||||
// use form_error to pass errors from the context to your frontend
|
||||
form_error string
|
||||
livereload_poll_interval_ms int = 250
|
||||
form_error string
|
||||
// livereload_poll_interval_ms is not used: veb serves the livereload script
|
||||
// before any user code runs, so it could never be set. With
|
||||
// `-d veb_livereload_polling`, use `-d veb_livereload_poll_ms=<ms>` instead.
|
||||
livereload_poll_interval_ms int = 250 @[deprecated: 'use `-d veb_livereload_poll_ms=<ms>`']
|
||||
}
|
||||
|
||||
fn clone_response_writer_header(header http.Header) http.Header {
|
||||
@@ -147,7 +150,7 @@ pub fn (mut ctx Context) send_response_to_client(mimetype string, response strin
|
||||
$if veb_livereload ? {
|
||||
if mimetype == 'text/html' {
|
||||
ctx.res.body = response.replace('</html>',
|
||||
'<script src="/veb_livereload/${veb_livereload_server_start}/script.js"></script>\n</html>')
|
||||
livereload_script_tag(ctx.req.method.str()) + '\n</html>')
|
||||
} else {
|
||||
ctx.res.body = response.clone()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
// vtest retry: 2
|
||||
import os
|
||||
import net
|
||||
import net.http
|
||||
import time
|
||||
|
||||
// Tests veb's live reload (`-d veb_livereload`) through a real server:
|
||||
// the injected script tag, the Server-Sent Events stream, static file changes,
|
||||
// the build status relayed from `v watch`, the polling fallback, the stream
|
||||
// cap, and that nothing is added without the define.
|
||||
|
||||
const vexe = @VEXE
|
||||
const tdir = os.join_path(os.vtmp_dir(), 'veb_livereload_test_${os.getpid()}')
|
||||
const static_dir = os.join_path(tdir, 'static')
|
||||
const status_path = os.join_path(tdir, 'status')
|
||||
const server_source = os.join_path(@VMODROOT, 'vlib', 'veb', 'tests', 'livereload_test_server.v')
|
||||
const base_port = 24000 + os.getpid() % 1000
|
||||
const sse_port = base_port
|
||||
const polling_port = base_port + 1
|
||||
const plain_port = base_port + 2
|
||||
const wait_timeout = 10 * time.second
|
||||
|
||||
struct Servers {
|
||||
mut:
|
||||
procs []&os.Process
|
||||
}
|
||||
|
||||
const servers = &Servers{}
|
||||
|
||||
fn testsuite_begin() {
|
||||
os.rmdir_all(tdir) or {}
|
||||
os.mkdir_all(static_dir)!
|
||||
os.write_file(os.join_path(static_dir, 'style.css'), 'body { color: black; }\n')!
|
||||
os.write_file(os.join_path(static_dir, 'app.js'), 'console.log(1);\n')!
|
||||
os.write_file(status_path, 'ok\n')!
|
||||
start_server('sse', ['-d', 'veb_livereload'], sse_port, true)
|
||||
start_server('polling', ['-d', 'veb_livereload', '-d', 'veb_livereload_polling', '-d',
|
||||
'veb_livereload_max_streams=1'], polling_port, false)
|
||||
start_server('plain', [], plain_port, false)
|
||||
}
|
||||
|
||||
fn testsuite_end() {
|
||||
mut s := unsafe { servers }
|
||||
for mut p in s.procs {
|
||||
p.signal_kill()
|
||||
p.wait()
|
||||
p.close()
|
||||
}
|
||||
os.rmdir_all(tdir) or {}
|
||||
}
|
||||
|
||||
fn start_server(name string, flags []string, port int, with_status bool) {
|
||||
exe := os.join_path(tdir, 'livereload_${name}.exe')
|
||||
res := os.exec([vexe, ...flags, '-o', exe, server_source])
|
||||
assert res.exit_code == 0, res.output
|
||||
mut p := os.new_process(exe)
|
||||
p.set_args([port.str(), static_dir])
|
||||
mut env := os.environ()
|
||||
if with_status {
|
||||
env['VEB_LIVERELOAD_STATUS'] = status_path
|
||||
}
|
||||
p.set_environment(env)
|
||||
p.set_redirect_stdio()
|
||||
p.run()
|
||||
mut s := unsafe { servers }
|
||||
s.procs << p
|
||||
stopwatch := time.new_stopwatch()
|
||||
for stopwatch.elapsed() < wait_timeout {
|
||||
if _ := http.get('http://127.0.0.1:${port}/plain') {
|
||||
return
|
||||
}
|
||||
time.sleep(50 * time.millisecond)
|
||||
}
|
||||
assert false, '${name} server did not start on port ${port}'
|
||||
}
|
||||
|
||||
fn get(port int, path string) http.Response {
|
||||
return http.get('http://127.0.0.1:${port}${path}') or { panic(err) }
|
||||
}
|
||||
|
||||
// open_stream opens a livereload stream, and returns it with what it sent first.
|
||||
fn open_stream(port int) (&net.TcpConn, string) {
|
||||
return open_stream_until(port, '\n\n')
|
||||
}
|
||||
|
||||
// open_stream_until opens a livereload stream, and reads until `needle`.
|
||||
fn open_stream_until(port int, needle string) (&net.TcpConn, string) {
|
||||
mut conn := net.dial_tcp('127.0.0.1:${port}') or { panic(err) }
|
||||
conn.write_string('GET /veb_livereload/test/events HTTP/1.1\r\nHost: 127.0.0.1\r\nAccept: text/event-stream\r\n\r\n') or {
|
||||
panic(err)
|
||||
}
|
||||
head := read_until(mut conn, needle, '')
|
||||
return conn, head
|
||||
}
|
||||
|
||||
// read_until reads from `conn` until `received` (starting with `prefix`) contains `needle`.
|
||||
fn read_until(mut conn net.TcpConn, needle string, prefix string) string {
|
||||
conn.set_read_timeout(200 * time.millisecond)
|
||||
mut received := prefix
|
||||
mut buf := []u8{len: 4096}
|
||||
stopwatch := time.new_stopwatch()
|
||||
for !received.contains(needle) && stopwatch.elapsed() < wait_timeout {
|
||||
n := conn.read(mut buf) or { 0 }
|
||||
if n > 0 {
|
||||
received += buf[..n].bytestr()
|
||||
}
|
||||
}
|
||||
return received
|
||||
}
|
||||
|
||||
fn write_status(content string) {
|
||||
tmp := status_path + '.tmp'
|
||||
os.write_file(tmp, content) or { panic(err) }
|
||||
os.mv(tmp, status_path) or { panic(err) }
|
||||
}
|
||||
|
||||
fn page_server_id() string {
|
||||
page := get(sse_port, '/').body
|
||||
return page.all_after('data-started="').all_before('"')
|
||||
}
|
||||
|
||||
fn test_html_pages_get_the_script_tag() {
|
||||
page := get(sse_port, '/').body
|
||||
assert page.contains('<script src="/veb_livereload/'), page
|
||||
assert page.contains(' data-veb-livereload '), page
|
||||
assert page.contains('data-method="GET"'), page
|
||||
assert page.ends_with('</script>\n</html>'), page
|
||||
assert !get(sse_port, '/plain').body.contains('veb_livereload')
|
||||
posted := http.post('http://127.0.0.1:${sse_port}/form', '') or { panic(err) }
|
||||
assert posted.body.contains('data-method="POST"'), posted.body
|
||||
}
|
||||
|
||||
fn test_script_uses_server_sent_events() {
|
||||
res := get(sse_port, '/veb_livereload/test/script.js')
|
||||
assert res.status() == .ok
|
||||
assert (res.header.get(.content_type) or { '' }).starts_with('text/javascript')
|
||||
assert res.body.contains('new EventSource(')
|
||||
assert !res.body.contains('setInterval(')
|
||||
assert get(sse_port, '/veb_livereload/test/current').status() == .not_found
|
||||
}
|
||||
|
||||
fn test_stream_starts_with_hello_and_the_server_id() {
|
||||
mut conn, head := open_stream(sse_port)
|
||||
defer {
|
||||
conn.close() or {}
|
||||
}
|
||||
assert head.starts_with('HTTP/1.1 200 OK\r\n'), head
|
||||
assert head.contains('Content-Type: text/event-stream\r\n'), head
|
||||
assert head.contains('event: hello\ndata: ${page_server_id()}\n\n'), head
|
||||
assert !head.contains('retry:'), head
|
||||
}
|
||||
|
||||
fn test_static_file_changes_are_pushed() {
|
||||
mut conn, _ := open_stream(sse_port)
|
||||
defer {
|
||||
conn.close() or {}
|
||||
}
|
||||
os.write_file(os.join_path(static_dir, 'style.css'), 'body { color: red; }\n')!
|
||||
got := read_until(mut conn, 'data: css', '')
|
||||
assert got.contains('event: change\ndata: css\n\n'), got
|
||||
os.write_file(os.join_path(static_dir, 'app.js'), 'console.log(22);\n')!
|
||||
got2 := read_until(mut conn, 'data: full', '')
|
||||
assert got2.contains('event: change\ndata: full\n\n'), got2
|
||||
}
|
||||
|
||||
fn test_build_status_is_relayed() {
|
||||
mut conn, _ := open_stream(sse_port)
|
||||
defer {
|
||||
conn.close() or {}
|
||||
}
|
||||
write_status('building\n')
|
||||
got := read_until(mut conn, 'data: building', '')
|
||||
assert got.contains('event: status\ndata: building\n'), got
|
||||
write_status('failed\nmain.v:1:1: error: first\nmain.v:2:1: error: second')
|
||||
got2 := read_until(mut conn, 'error: second', '')
|
||||
assert got2.contains('event: status\ndata: failed\ndata: main.v:1:1: error: first\ndata: main.v:2:1: error: second\n\n'), got2
|
||||
}
|
||||
|
||||
fn test_a_new_page_sees_a_failed_build() {
|
||||
// The status is still `failed` from the previous test.
|
||||
mut conn, head := open_stream(sse_port)
|
||||
got := read_until(mut conn, 'error: second', head)
|
||||
conn.close() or {}
|
||||
assert got.contains('event: hello'), got
|
||||
assert got.contains('event: status\ndata: failed\n'), got
|
||||
write_status('ok\n')
|
||||
time.sleep(200 * time.millisecond)
|
||||
mut conn2, head2 := open_stream(sse_port)
|
||||
conn2.close() or {}
|
||||
assert !head2.contains('event: status'), head2
|
||||
}
|
||||
|
||||
fn test_polling_fallback() {
|
||||
script := get(polling_port, '/veb_livereload/test/script.js').body
|
||||
assert script.contains('setInterval(veb_livereload_checker_fn, 250,'), script
|
||||
assert !script.contains('EventSource')
|
||||
id := get(polling_port, '/').body.all_after('data-started="').all_before('"')
|
||||
assert get(polling_port, '/veb_livereload/test/current').body == id
|
||||
}
|
||||
|
||||
fn test_stream_cap() {
|
||||
// The polling server was built with `-d veb_livereload_max_streams=1`.
|
||||
mut first, head := open_stream(polling_port)
|
||||
defer {
|
||||
first.close() or {}
|
||||
}
|
||||
assert head.starts_with('HTTP/1.1 200 OK'), head
|
||||
mut second, head2 := open_stream_until(polling_port, 'too many livereload streams')
|
||||
second.close() or {}
|
||||
assert head2.starts_with('HTTP/1.1 503 Service Unavailable'), head2
|
||||
}
|
||||
|
||||
fn test_nothing_is_added_without_the_define() {
|
||||
page := get(plain_port, '/').body
|
||||
assert page == '<html><body><p id="greeting">hello</p></body></html>', page
|
||||
assert get(plain_port, '/veb_livereload/test/events').status() == .not_found
|
||||
assert get(plain_port, '/veb_livereload/test/script.js').status() == .not_found
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
module main
|
||||
|
||||
// A small app for livereload_test.v: `livereload_test_server <port> <static dir>`.
|
||||
import os
|
||||
import veb
|
||||
|
||||
pub struct Context {
|
||||
veb.Context
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
veb.StaticHandler
|
||||
}
|
||||
|
||||
pub fn (app &App) index(mut ctx Context) veb.Result {
|
||||
return ctx.html('<html><body><p id="greeting">hello</p></body></html>')
|
||||
}
|
||||
|
||||
pub fn (app &App) plain(mut ctx Context) veb.Result {
|
||||
return ctx.text('plain text')
|
||||
}
|
||||
|
||||
@['/form'; post]
|
||||
pub fn (app &App) form(mut ctx Context) veb.Result {
|
||||
return ctx.html('<html><body>posted</body></html>')
|
||||
}
|
||||
|
||||
fn main() {
|
||||
port := os.args[1].int()
|
||||
mut app := &App{}
|
||||
app.handle_static(os.args[2], true)!
|
||||
veb.run_at[App, Context](mut app, family: .ip, host: '127.0.0.1', port: port) or { panic(err) }
|
||||
}
|
||||
+8
-2
@@ -380,10 +380,16 @@ fn handle_route[A, X](mut app A, mut user_context X, url urllib.URL, host string
|
||||
|
||||
$if veb_livereload ? {
|
||||
if url.path.starts_with('/veb_livereload/') {
|
||||
if url.path.ends_with('current') {
|
||||
user_context.handle_veb_livereload_current()
|
||||
if url.path.ends_with('/events') {
|
||||
user_context.handle_veb_livereload_events()
|
||||
return
|
||||
}
|
||||
$if veb_livereload_polling ? {
|
||||
if url.path.ends_with('current') {
|
||||
user_context.handle_veb_livereload_current()
|
||||
return
|
||||
}
|
||||
}
|
||||
if url.path.ends_with('script.js') {
|
||||
user_context.handle_veb_livereload_script()
|
||||
return
|
||||
|
||||
@@ -98,6 +98,13 @@ pub fn run_new[A, X](mut global_app A, params RunParams) ! {
|
||||
routes := generate_routes[A, X](global_app)!
|
||||
controllers_sorted := check_duplicate_routes_in_controllers[A](global_app, routes)!
|
||||
prompt_to_kill_processes_listening_on_port(params.port)
|
||||
$if veb_livereload ? {
|
||||
$if A is StaticApp {
|
||||
start_livereload(global_app.static_files.clone())
|
||||
} $else {
|
||||
start_livereload(map[string]string{})
|
||||
}
|
||||
}
|
||||
|
||||
// Allocate params on the heap to keep it valid for the server lifetime
|
||||
request_params := &RequestParams{
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
"use strict";
|
||||
// veb livereload client, injected into HTML pages when the app is compiled with
|
||||
// `-d veb_livereload`. It keeps one Server-Sent Events stream open, and applies
|
||||
// changes in place: a server restart or a page change morphs the DOM (keeping
|
||||
// scroll position, focus and typed form values), a stylesheet change swaps the
|
||||
// stylesheet without reloading.
|
||||
(() => {
|
||||
if (window.__veb_livereload) {
|
||||
return;
|
||||
}
|
||||
window.__veb_livereload = true;
|
||||
const me = document.currentScript;
|
||||
const started = me.dataset.started;
|
||||
const retryMax = Math.max(Number(me.dataset.retryMax) || 250, 1);
|
||||
const retryFirst = Math.min(250, retryMax);
|
||||
const method = (me.dataset.method || "GET").toUpperCase();
|
||||
const log = (...args) => console.debug("[veb livereload]", ...args);
|
||||
let serverId = started;
|
||||
let busy = false;
|
||||
let again = false;
|
||||
|
||||
const isOurs = (node) => node.nodeType === 1 && node.hasAttribute("data-veb-livereload");
|
||||
|
||||
// ui shows a small status toast, and a panel with the compiler output when a
|
||||
// build fails. It lives in a shadow root on <html>, outside <body>, so a morph
|
||||
// never touches it and the page's CSS cannot restyle it.
|
||||
const ui = (() => {
|
||||
if (me.dataset.toast === "0") {
|
||||
return { toast() {}, hideToast() {}, showError() {}, hideError() {} };
|
||||
}
|
||||
const host = document.createElement("div");
|
||||
host.setAttribute("data-veb-livereload", "");
|
||||
const root = host.attachShadow({ mode: "open" });
|
||||
root.innerHTML = `<style>
|
||||
:host { all: initial; }
|
||||
.toast, .panel { position: fixed; z-index: 2147483647; font: 13px/1.4 system-ui, sans-serif; color: #f4f4f5; background: #18181b; box-shadow: 0 4px 16px rgba(0,0,0,.3); }
|
||||
.toast { right: 16px; bottom: 16px; padding: 8px 12px; border-radius: 8px; opacity: 0; transform: translateY(8px); transition: opacity .15s, transform .15s; pointer-events: none; }
|
||||
.toast.on { opacity: 1; transform: none; }
|
||||
.toast.busy::before { content: ""; display: inline-block; width: 8px; height: 8px; margin-right: 8px; border-radius: 50%; background: #facc15; animation: pulse 1s infinite; }
|
||||
@keyframes pulse { 50% { opacity: .3; } }
|
||||
.panel { left: 16px; right: 16px; bottom: 16px; max-height: 50vh; display: none; flex-direction: column; border-radius: 8px; border-left: 4px solid #ef4444; }
|
||||
.panel.on { display: flex; }
|
||||
.head { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; font-weight: 600; }
|
||||
button { all: unset; cursor: pointer; padding: 0 6px; font-size: 18px; color: #a1a1aa; }
|
||||
pre { margin: 0; padding: 0 12px 12px; overflow: auto; font: 12px/1.5 ui-monospace, monospace; white-space: pre-wrap; }
|
||||
</style>
|
||||
<div class="toast" role="status" aria-live="polite"></div>
|
||||
<div class="panel" role="alert"><div class="head"><span>Build failed: the previous version is still running</span><button aria-label="Dismiss">×</button></div><pre></pre></div>`;
|
||||
const toastEl = root.querySelector(".toast");
|
||||
const panelEl = root.querySelector(".panel");
|
||||
root.querySelector("button").addEventListener("click", () => panelEl.classList.remove("on"));
|
||||
const mount = () => document.documentElement.appendChild(host);
|
||||
if (document.body) {
|
||||
mount();
|
||||
} else {
|
||||
document.addEventListener("DOMContentLoaded", mount, { once: true });
|
||||
}
|
||||
let hideTimer = 0;
|
||||
return {
|
||||
// toast shows `text`; `busy` keeps it up with an activity dot until replaced.
|
||||
toast(text, busy = false) {
|
||||
clearTimeout(hideTimer);
|
||||
toastEl.textContent = text;
|
||||
toastEl.classList.toggle("busy", busy);
|
||||
toastEl.classList.add("on");
|
||||
if (!busy) {
|
||||
hideTimer = setTimeout(() => toastEl.classList.remove("on"), 1500);
|
||||
}
|
||||
},
|
||||
hideToast() {
|
||||
clearTimeout(hideTimer);
|
||||
toastEl.classList.remove("on");
|
||||
},
|
||||
showError(text) {
|
||||
panelEl.querySelector("pre").textContent = text;
|
||||
panelEl.classList.add("on");
|
||||
},
|
||||
hideError() {
|
||||
panelEl.classList.remove("on");
|
||||
},
|
||||
};
|
||||
})();
|
||||
|
||||
const reload = () => window.location.reload();
|
||||
|
||||
// refreshStylesheets re-requests every same-origin stylesheet, and removes the
|
||||
// old <link> only once the new one has loaded, so the page never flashes unstyled.
|
||||
const refreshStylesheets = () => {
|
||||
for (const old of document.querySelectorAll('link[rel~="stylesheet"]')) {
|
||||
const url = new URL(old.href, window.location.href);
|
||||
if (url.origin !== window.location.origin) {
|
||||
continue;
|
||||
}
|
||||
url.searchParams.set("veb_livereload", Date.now().toString());
|
||||
const fresh = old.cloneNode();
|
||||
fresh.href = url.href;
|
||||
fresh.addEventListener("load", () => old.remove(), { once: true });
|
||||
fresh.addEventListener("error", () => fresh.remove(), { once: true });
|
||||
old.after(fresh);
|
||||
}
|
||||
ui.toast("Styles updated");
|
||||
};
|
||||
|
||||
const sameKind = (a, b) => {
|
||||
if (a.nodeType !== b.nodeType || a.nodeName !== b.nodeName) {
|
||||
return false;
|
||||
}
|
||||
if (a.nodeType !== 1) {
|
||||
return true;
|
||||
}
|
||||
if ((a.id || "") !== (b.id || "")) {
|
||||
return false;
|
||||
}
|
||||
// Typed values belong to a particular field. Reusing a node for a newly
|
||||
// inserted field with a different name would move the user's input to it.
|
||||
if (["INPUT", "TEXTAREA", "SELECT"].includes(a.nodeName)) {
|
||||
if ((a.getAttribute("name") || "") !== (b.getAttribute("name") || "")) {
|
||||
return false;
|
||||
}
|
||||
if (a.nodeName === "INPUT" && (a.getAttribute("type") || "text").toLowerCase() !== (b.getAttribute("type") || "text").toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const syncAttributes = (oldEl, newEl) => {
|
||||
for (const attr of [...oldEl.attributes]) {
|
||||
if (!newEl.hasAttribute(attr.name)) {
|
||||
oldEl.removeAttribute(attr.name);
|
||||
}
|
||||
}
|
||||
for (const attr of newEl.attributes) {
|
||||
if (oldEl.getAttribute(attr.name) !== attr.value) {
|
||||
oldEl.setAttribute(attr.name, attr.value);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// morphNode updates `oldNode` in place to match `newNode`. Form controls keep
|
||||
// what the user typed: only their attributes (and so their defaults) change.
|
||||
const morphNode = (oldNode, newNode) => {
|
||||
if (oldNode.nodeType === 3 || oldNode.nodeType === 8) {
|
||||
if (oldNode.nodeValue !== newNode.nodeValue) {
|
||||
oldNode.nodeValue = newNode.nodeValue;
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (oldNode.nodeType !== 1) {
|
||||
return;
|
||||
}
|
||||
syncAttributes(oldNode, newNode);
|
||||
switch (oldNode.nodeName) {
|
||||
case "INPUT":
|
||||
return;
|
||||
case "TEXTAREA":
|
||||
if (oldNode.value === oldNode.defaultValue) {
|
||||
oldNode.defaultValue = newNode.defaultValue;
|
||||
}
|
||||
return;
|
||||
case "SCRIPT":
|
||||
// Not re-run: a script inserted by a morph never executes.
|
||||
if (oldNode.textContent !== newNode.textContent) {
|
||||
oldNode.textContent = newNode.textContent;
|
||||
}
|
||||
return;
|
||||
}
|
||||
morphChildren(oldNode, newNode);
|
||||
};
|
||||
|
||||
// morphChildren makes the children of `oldParent` match those of `newParent`,
|
||||
// reusing existing nodes: by `id` first, then by node type and tag in order,
|
||||
// looking a few siblings ahead past inserted or removed nodes.
|
||||
const morphChildren = (oldParent, newParent) => {
|
||||
const byId = new Map();
|
||||
for (const child of oldParent.children) {
|
||||
if (child.id) {
|
||||
byId.set(child.id, child);
|
||||
}
|
||||
}
|
||||
let cursor = oldParent.firstChild;
|
||||
const skipOurs = () => {
|
||||
while (cursor && isOurs(cursor)) {
|
||||
cursor = cursor.nextSibling;
|
||||
}
|
||||
};
|
||||
for (const next of [...newParent.childNodes]) {
|
||||
if (isOurs(next)) {
|
||||
continue;
|
||||
}
|
||||
skipOurs();
|
||||
let match = null;
|
||||
if (next.nodeType === 1 && next.id) {
|
||||
const found = byId.get(next.id);
|
||||
if (found && sameKind(found, next)) {
|
||||
match = found;
|
||||
byId.delete(next.id);
|
||||
}
|
||||
} else if (cursor && !(cursor.nodeType === 1 && cursor.id) && sameKind(cursor, next)) {
|
||||
match = cursor;
|
||||
} else if (next.nodeType === 1) {
|
||||
// Look a few siblings ahead for the same element, so that an inserted
|
||||
// sibling does not cause everything after it to be rebuilt. Skipped
|
||||
// nodes stay in place: they may match a later new node.
|
||||
let probe = cursor ? cursor.nextSibling : null;
|
||||
for (let i = 0; probe && i < 8; i++, probe = probe.nextSibling) {
|
||||
if (!isOurs(probe) && !probe.id && sameKind(probe, next)) {
|
||||
match = probe;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (match) {
|
||||
if (match === cursor) {
|
||||
cursor = cursor.nextSibling;
|
||||
} else {
|
||||
oldParent.insertBefore(match, cursor);
|
||||
}
|
||||
morphNode(match, next);
|
||||
} else {
|
||||
oldParent.insertBefore(document.importNode(next, true), cursor);
|
||||
}
|
||||
}
|
||||
while (cursor) {
|
||||
const after = cursor.nextSibling;
|
||||
if (!isOurs(cursor)) {
|
||||
cursor.remove();
|
||||
}
|
||||
cursor = after;
|
||||
}
|
||||
// Elements with an id that the new page no longer has.
|
||||
for (const stale of byId.values()) {
|
||||
if (stale.parentNode === oldParent) {
|
||||
stale.remove();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Scripts are intentionally not executed during a morph. A changed script or
|
||||
// head element therefore requires navigation, so the browser applies it.
|
||||
const pageResources = (doc) => JSON.stringify([
|
||||
[...doc.querySelectorAll("script")].filter((node) => !isOurs(node)).map((node) => node.outerHTML),
|
||||
[...doc.head.children].filter((node) => !isOurs(node) && node.nodeName !== "TITLE" && node.nodeName !== "SCRIPT").map((node) => {
|
||||
const copy = node.cloneNode(true);
|
||||
if (copy.nodeName === "LINK" && copy.hasAttribute("href")) {
|
||||
const url = new URL(copy.getAttribute("href"), window.location.href);
|
||||
url.searchParams.delete("veb_livereload");
|
||||
copy.setAttribute("href", url.href);
|
||||
}
|
||||
return copy.outerHTML;
|
||||
}),
|
||||
]);
|
||||
|
||||
// refreshPage fetches the current URL again and morphs the page into it.
|
||||
// Pages that came from a POST, failed fetches, and non-HTML answers reload.
|
||||
const refreshPage = async () => {
|
||||
if (busy) {
|
||||
again = true;
|
||||
return;
|
||||
}
|
||||
busy = true;
|
||||
try {
|
||||
if (method !== "GET") {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
const res = await fetch(window.location.href, {
|
||||
headers: { "X-Veb-Livereload": "1" },
|
||||
cache: "no-store",
|
||||
credentials: "same-origin",
|
||||
});
|
||||
const type = res.headers.get("content-type") || "";
|
||||
if (!res.ok || !type.includes("text/html")) {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
const doc = new DOMParser().parseFromString(await res.text(), "text/html");
|
||||
if (pageResources(document) !== pageResources(doc)) {
|
||||
reload();
|
||||
return;
|
||||
}
|
||||
if (document.title !== doc.title) {
|
||||
document.title = doc.title;
|
||||
}
|
||||
syncAttributes(document.body, doc.body);
|
||||
morphChildren(document.body, doc.body);
|
||||
log("page updated");
|
||||
ui.hideError();
|
||||
ui.toast("Updated");
|
||||
} catch (err) {
|
||||
log("morph failed, reloading", err);
|
||||
reload();
|
||||
} finally {
|
||||
busy = false;
|
||||
if (again) {
|
||||
again = false;
|
||||
refreshPage();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// connect opens the stream. When it drops (the app restarts), the browser's
|
||||
// own fixed-delay retry is replaced by a backoff: 250 ms, doubling up to
|
||||
// `retryMax` (`-d veb_livereload_retry_max_ms`), reset once a stream opens.
|
||||
let retryDelay = retryFirst;
|
||||
let waitingTimer = 0;
|
||||
let waitingShown = false;
|
||||
const connect = () => {
|
||||
const events = new EventSource("/veb_livereload/" + started + "/events");
|
||||
events.addEventListener("open", () => {
|
||||
retryDelay = retryFirst;
|
||||
clearTimeout(waitingTimer);
|
||||
waitingTimer = 0;
|
||||
if (waitingShown) {
|
||||
waitingShown = false;
|
||||
ui.hideToast();
|
||||
}
|
||||
});
|
||||
events.addEventListener("error", () => {
|
||||
events.close();
|
||||
setTimeout(connect, retryDelay);
|
||||
retryDelay = Math.min(retryDelay * 2, retryMax);
|
||||
// Only mention the outage if it lasts; a restart usually takes milliseconds.
|
||||
if (!waitingTimer) {
|
||||
waitingTimer = setTimeout(() => {
|
||||
waitingShown = true;
|
||||
ui.toast("Waiting for the app", true);
|
||||
}, 1500);
|
||||
}
|
||||
});
|
||||
events.addEventListener("status", (e) => {
|
||||
const nl = e.data.indexOf("\n");
|
||||
const state = (nl < 0 ? e.data : e.data.slice(0, nl)).trim();
|
||||
const details = nl < 0 ? "" : e.data.slice(nl + 1);
|
||||
log("status:", state);
|
||||
if (state === "building") {
|
||||
ui.hideError();
|
||||
ui.toast("Rebuilding", true);
|
||||
} else if (state === "failed") {
|
||||
ui.hideToast();
|
||||
ui.showError(details.trim() || "The build failed. See the terminal for details.");
|
||||
} else {
|
||||
ui.hideToast();
|
||||
}
|
||||
});
|
||||
events.addEventListener("hello", (e) => {
|
||||
if (e.data !== serverId) {
|
||||
serverId = e.data;
|
||||
log("server restarted");
|
||||
refreshPage();
|
||||
}
|
||||
});
|
||||
events.addEventListener("change", (e) => {
|
||||
log("change:", e.data);
|
||||
if (e.data === "css") {
|
||||
refreshStylesheets();
|
||||
} else if (e.data === "full") {
|
||||
reload();
|
||||
} else {
|
||||
refreshPage();
|
||||
}
|
||||
});
|
||||
};
|
||||
connect();
|
||||
})();
|
||||
+275
-31
@@ -1,48 +1,292 @@
|
||||
module veb
|
||||
|
||||
import net
|
||||
import os
|
||||
import sync
|
||||
import time
|
||||
|
||||
// Note: to use live reloading while developing, the suggested workflow is doing:
|
||||
// `v -d veb_livereload watch --keep run your_veb_server_project.v`
|
||||
// in one shell, then open the start page of your veb app in a browser.
|
||||
// Live reloading while developing. Compile your app with `-d veb_livereload`
|
||||
// and run it under `v watch`:
|
||||
//
|
||||
// While developing, just open your files and edit them, then just save your
|
||||
// changes. Once you save, the watch command from above, will restart your server,
|
||||
// and your HTML pages will detect that shortly, then they will refresh themselves
|
||||
// automatically.
|
||||
// `v watch -d veb_livereload run .`
|
||||
//
|
||||
// Every HTML page served by veb then gets a small script before `</html>`.
|
||||
// The script opens one Server-Sent Events stream to the server, and:
|
||||
// * when `v watch` restarts the server (a .v file or template changed), it
|
||||
// fetches the current page again and morphs the DOM in place, so scroll
|
||||
// position, focus and typed form values survive;
|
||||
// * when a file served by a `veb.StaticHandler` changes, the server pushes the
|
||||
// change without a rebuild: stylesheets are swapped in place, other assets reload the page.
|
||||
//
|
||||
// Compile with `-d veb_livereload_polling` as well to use the older polling
|
||||
// script instead. Apps served over HTTPS need it: the stream is written to the
|
||||
// taken-over TCP connection, which bypasses TLS. Proxies that block long-lived
|
||||
// connections need it too.
|
||||
|
||||
// veb_livereload_server_start records, when the veb server process started.
|
||||
// That is later used by the /script.js and /current endpoints, which are active,
|
||||
// if you have compiled your veb project with `-d veb_livereload`, to detect
|
||||
// whether the web server has been restarted.
|
||||
// veb_livereload_server_start records when the veb server process started.
|
||||
// Pages compare it with the id sent by the server, to detect a restart.
|
||||
const veb_livereload_server_start = time.ticks().str()
|
||||
|
||||
// handle_veb_livereload_current serves a small text file, containing the
|
||||
// timestamp/ticks corresponding to when the veb server process was started
|
||||
// veb_livereload_poll_ms is the poll interval of the legacy polling script.
|
||||
const veb_livereload_poll_ms = $d('veb_livereload_poll_ms', 250)
|
||||
|
||||
// veb_livereload_max_streams caps the number of open livereload streams.
|
||||
const veb_livereload_max_streams = $d('veb_livereload_max_streams', 32)
|
||||
|
||||
// veb_livereload_retry_max_ms caps the delay between reconnect attempts while
|
||||
// the app is down. Attempts start at 250 ms and double up to this cap, so the
|
||||
// default retries every 250 ms: with `v watch` building the new version while
|
||||
// the old one serves, the app is only down for the restart itself.
|
||||
const veb_livereload_retry_max_ms = $d('veb_livereload_retry_max_ms', 250)
|
||||
|
||||
// veb_livereload_tick_ms is how often the watcher thread checks the build
|
||||
// status written by `v watch`. It is short, so that a fast rebuild is still
|
||||
// announced before `v watch` stops this server for the new one.
|
||||
const veb_livereload_tick_ms = 50
|
||||
|
||||
// veb_livereload_asset_every is the number of ticks between static file checks (250 ms).
|
||||
const veb_livereload_asset_every = 5
|
||||
|
||||
// veb_livereload_ping_every is the number of ticks between keepalive pings (15 s).
|
||||
const veb_livereload_ping_every = 300
|
||||
|
||||
const veb_livereload_hub = &LivereloadHub{}
|
||||
|
||||
// LivereloadHub owns the open livereload streams of the process.
|
||||
@[heap]
|
||||
struct LivereloadHub {
|
||||
mut:
|
||||
mu &sync.Mutex = sync.new_mutex()
|
||||
conns []&net.TcpConn
|
||||
started bool
|
||||
// status is the last build status written by `v watch`, see `livereload_watch`.
|
||||
status string
|
||||
}
|
||||
|
||||
// veb_livereload_toast is '0' when compiled with `-d veb_livereload_no_toast`,
|
||||
// which hides the status toast and the build error panel.
|
||||
const veb_livereload_toast = $if veb_livereload_no_toast ? { '0' } $else { '1' }
|
||||
|
||||
// livereload_script_tag returns the tag injected before `</html>` in HTML responses.
|
||||
fn livereload_script_tag(method string) string {
|
||||
return '<script src="/veb_livereload/${veb_livereload_server_start}/script.js" data-veb-livereload data-started="${veb_livereload_server_start}" data-method="${method}" data-retry-max="${veb_livereload_retry_max_ms}" data-toast="${veb_livereload_toast}"></script>'
|
||||
}
|
||||
|
||||
// livereload_sse_frame formats one Server-Sent Events message. Each line of
|
||||
// `data` gets its own `data:` field; the browser joins them with newlines.
|
||||
fn livereload_sse_frame(event string, data string) string {
|
||||
mut frame := 'event: ${event}\n'
|
||||
for line in data.replace('\r\n', '\n').split('\n') {
|
||||
frame += 'data: ${line}\n'
|
||||
}
|
||||
return frame + '\n'
|
||||
}
|
||||
|
||||
// livereload_status_state returns the state on the first line of a build status.
|
||||
fn livereload_status_state(status string) string {
|
||||
return status.all_before('\n').trim_space()
|
||||
}
|
||||
|
||||
// livereload_stream_head is the HTTP response head of a livereload stream,
|
||||
// followed by the `hello` message with the process id. It sends no `retry:`
|
||||
// field: the client schedules its own reconnects, with backoff.
|
||||
fn livereload_stream_head() string {
|
||||
return 'HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nCache-Control: no-cache\r\nConnection: keep-alive\r\nX-Accel-Buffering: no\r\n\r\n' +
|
||||
livereload_sse_frame('hello', veb_livereload_server_start)
|
||||
}
|
||||
|
||||
// livereload_change_kind maps a changed static file to the change the page should apply.
|
||||
fn livereload_change_kind(path string) string {
|
||||
return if os.file_ext(path).to_lower() == '.css' { 'css' } else { 'full' }
|
||||
}
|
||||
|
||||
// livereload_script returns the client script for the current build.
|
||||
fn livereload_script() string {
|
||||
$if veb_livereload_polling ? {
|
||||
return livereload_polling_script()
|
||||
} $else {
|
||||
return $embed_file('veb_livereload.js').to_string()
|
||||
}
|
||||
}
|
||||
|
||||
// livereload_polling_script is the legacy client: it polls `/current` and reloads.
|
||||
fn livereload_polling_script() string {
|
||||
return '"use strict";
|
||||
function veb_livereload_checker_fn(started_at) {
|
||||
fetch("/veb_livereload/" + started_at + "/current", { cache: "no-cache" })
|
||||
.then((response) => response.text())
|
||||
.then(function (current_at) {
|
||||
if (started_at !== current_at) {
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
}
|
||||
const veb_livereload_checker = setInterval(veb_livereload_checker_fn, ${veb_livereload_poll_ms}, "${veb_livereload_server_start}");
|
||||
'
|
||||
}
|
||||
|
||||
// add registers a stream and sends it the response head and the `hello`
|
||||
// message. It returns false, without writing, when the stream cap is reached.
|
||||
fn (mut hub LivereloadHub) add(conn &net.TcpConn) bool {
|
||||
hub.mu.lock()
|
||||
defer {
|
||||
hub.mu.unlock()
|
||||
}
|
||||
if hub.conns.len >= veb_livereload_max_streams {
|
||||
return false
|
||||
}
|
||||
mut c := unsafe { conn }
|
||||
mut head := livereload_stream_head()
|
||||
if hub.status != '' && livereload_status_state(hub.status) != 'ok' {
|
||||
// A page that opens during a build, or after a failed one, shows it too.
|
||||
head += livereload_sse_frame('status', hub.status)
|
||||
}
|
||||
c.write_string(head) or {
|
||||
c.close() or {}
|
||||
return true
|
||||
}
|
||||
hub.conns << c
|
||||
return true
|
||||
}
|
||||
|
||||
// set_status records a new build status and sends it to every open stream.
|
||||
fn (mut hub LivereloadHub) set_status(status string) {
|
||||
hub.mu.lock()
|
||||
hub.status = status
|
||||
hub.mu.unlock()
|
||||
hub.broadcast('status', status)
|
||||
}
|
||||
|
||||
// send writes `payload` to every open stream, and drops the streams that fail.
|
||||
fn (mut hub LivereloadHub) send(payload string) {
|
||||
hub.mu.lock()
|
||||
mut alive := []&net.TcpConn{cap: hub.conns.len}
|
||||
for conn in hub.conns {
|
||||
mut c := unsafe { conn }
|
||||
c.write_string(payload) or {
|
||||
c.close() or {}
|
||||
continue
|
||||
}
|
||||
alive << c
|
||||
}
|
||||
hub.conns = alive
|
||||
hub.mu.unlock()
|
||||
}
|
||||
|
||||
// broadcast sends one event to every open stream.
|
||||
fn (mut hub LivereloadHub) broadcast(event string, data string) {
|
||||
hub.send(livereload_sse_frame(event, data))
|
||||
}
|
||||
|
||||
// start runs the watcher thread once per process.
|
||||
fn (mut hub LivereloadHub) start(static_files map[string]string) {
|
||||
hub.mu.lock()
|
||||
if hub.started {
|
||||
hub.mu.unlock()
|
||||
return
|
||||
}
|
||||
hub.started = true
|
||||
hub.mu.unlock()
|
||||
spawn livereload_watch(mut hub, static_files.values())
|
||||
}
|
||||
|
||||
struct LivereloadFileState {
|
||||
mtime i64
|
||||
size u64
|
||||
}
|
||||
|
||||
fn livereload_file_state(path string) LivereloadFileState {
|
||||
st := os.stat(path) or { return LivereloadFileState{} }
|
||||
return LivereloadFileState{
|
||||
mtime: st.mtime
|
||||
size: st.size
|
||||
}
|
||||
}
|
||||
|
||||
// livereload_watch polls the static files for changes, pushes them to the
|
||||
// pages, and pings idle streams so that closed tabs are noticed. When the app
|
||||
// runs under `v watch`, it also follows the build status that `v watch` writes
|
||||
// to the file named by `VEB_LIVERELOAD_STATUS`, and pushes each new status.
|
||||
fn livereload_watch(mut hub LivereloadHub, paths []string) {
|
||||
mut states := map[string]LivereloadFileState{}
|
||||
for path in paths {
|
||||
states[path] = livereload_file_state(path)
|
||||
}
|
||||
status_path := os.getenv('VEB_LIVERELOAD_STATUS')
|
||||
mut last_status := ''
|
||||
if status_path != '' {
|
||||
last_status = os.read_file(status_path) or { '' }
|
||||
hub.mu.lock()
|
||||
hub.status = last_status
|
||||
hub.mu.unlock()
|
||||
}
|
||||
mut ticks := 0
|
||||
for {
|
||||
time.sleep(veb_livereload_tick_ms * time.millisecond)
|
||||
ticks++
|
||||
if status_path != '' {
|
||||
// The file is a few bytes (plus compiler output after a failed build):
|
||||
// compare its content, since file times only change once a second.
|
||||
status := os.read_file(status_path) or { '' }
|
||||
if status != '' && status != last_status {
|
||||
last_status = status
|
||||
hub.set_status(status)
|
||||
}
|
||||
}
|
||||
if ticks % veb_livereload_ping_every == 0 {
|
||||
hub.send(': ping\n\n')
|
||||
}
|
||||
if ticks % veb_livereload_asset_every != 0 {
|
||||
continue
|
||||
}
|
||||
mut kind := ''
|
||||
for path, old in states {
|
||||
now := livereload_file_state(path)
|
||||
if now == old {
|
||||
continue
|
||||
}
|
||||
states[path] = now
|
||||
if kind != 'full' {
|
||||
kind = livereload_change_kind(path)
|
||||
}
|
||||
}
|
||||
if kind != '' {
|
||||
hub.broadcast('change', kind)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// start_livereload starts the static file watcher, when compiled with `-d veb_livereload`.
|
||||
@[if veb_livereload ?]
|
||||
fn start_livereload(static_files map[string]string) {
|
||||
mut hub := unsafe { veb_livereload_hub }
|
||||
hub.start(static_files)
|
||||
}
|
||||
|
||||
// handle_veb_livereload_current serves the process id, for the legacy polling script.
|
||||
@[if veb_livereload ?]
|
||||
fn (mut ctx Context) handle_veb_livereload_current() {
|
||||
ctx.send_response_to_client('text/plain', veb_livereload_server_start)
|
||||
}
|
||||
|
||||
// handle_veb_livereload_script serves a small dynamically generated .js file,
|
||||
// that contains code for polling the veb server, and reloading the page, if it
|
||||
// detects that the veb server is newer than the veb server, that served the
|
||||
// .js file originally.
|
||||
// handle_veb_livereload_script serves the client script.
|
||||
@[if veb_livereload ?]
|
||||
fn (mut ctx Context) handle_veb_livereload_script() {
|
||||
res := '"use strict";
|
||||
function veb_livereload_checker_fn(started_at) {
|
||||
fetch("/veb_livereload/" + started_at + "/current", { cache: "no-cache" })
|
||||
.then((response) => response.text())
|
||||
.then(function (current_at) {
|
||||
// console.log(started_at); console.log(current_at);
|
||||
if (started_at !== current_at) {
|
||||
// the app was restarted on the server:
|
||||
window.location.reload();
|
||||
}
|
||||
});
|
||||
ctx.send_response_to_client('text/javascript', livereload_script())
|
||||
}
|
||||
const veb_livereload_checker = setInterval(veb_livereload_checker_fn, ${ctx.livereload_poll_interval_ms}, "${veb_livereload_server_start}");
|
||||
'
|
||||
ctx.send_response_to_client('text/javascript', res)
|
||||
|
||||
// handle_veb_livereload_events takes over the connection and keeps it open as
|
||||
// a Server-Sent Events stream; the hub writes to it from then on.
|
||||
@[if veb_livereload ?]
|
||||
fn (mut ctx Context) handle_veb_livereload_events() {
|
||||
mut hub := unsafe { veb_livereload_hub }
|
||||
ctx.takeover_conn()
|
||||
if ctx.conn == unsafe { nil } {
|
||||
return
|
||||
}
|
||||
mut conn := ctx.conn
|
||||
if !hub.add(conn) {
|
||||
conn.write_string('HTTP/1.1 503 Service Unavailable\r\nContent-Type: text/plain\r\nContent-Length: 27\r\nConnection: close\r\n\r\ntoo many livereload streams') or {}
|
||||
conn.close() or {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
// Run with: node --test vlib/veb/veb_livereload_client_test.js
|
||||
const assert = require("node:assert/strict");
|
||||
const { readFileSync } = require("node:fs");
|
||||
const { test } = require("node:test");
|
||||
const { runInNewContext } = require("node:vm");
|
||||
|
||||
const source = readFileSync(__dirname + "/veb_livereload.js", "utf8");
|
||||
|
||||
function element(name, html, attrs = {}) {
|
||||
return {
|
||||
nodeType: 1, nodeName: name, outerHTML: html,
|
||||
hasAttribute: (key) => key in attrs,
|
||||
getAttribute: (key) => attrs[key],
|
||||
setAttribute(key, value) { attrs[key] = value; },
|
||||
cloneNode() { return element(name, html, { ...attrs }); },
|
||||
};
|
||||
}
|
||||
|
||||
function page(scripts = [], head = []) {
|
||||
return {
|
||||
title: "Example", head: { children: head },
|
||||
querySelectorAll: (selector) => selector === "script" ? scripts : [],
|
||||
body: { attributes: [], children: [], childNodes: [], firstChild: null },
|
||||
};
|
||||
}
|
||||
|
||||
function client(oldPage, newPage) {
|
||||
const handlers = {};
|
||||
let reloads = 0;
|
||||
let fetches = 0;
|
||||
oldPage.currentScript = { dataset: { started: "1", toast: "0" } };
|
||||
runInNewContext(source, {
|
||||
document: oldPage,
|
||||
window: { location: { href: "http://localhost/", origin: "http://localhost", reload() { reloads++; } } },
|
||||
console: { debug() {} }, URL, setTimeout, clearTimeout,
|
||||
EventSource: class { addEventListener(event, handler) { handlers[event] = handler; } },
|
||||
DOMParser: class { parseFromString() { return newPage; } },
|
||||
fetch: async () => { fetches++; return { ok: true, headers: { get: () => "text/html" }, text: async () => "page" }; },
|
||||
});
|
||||
return {
|
||||
async change(kind) { handlers.change({ data: kind }); await new Promise(setImmediate); },
|
||||
get reloads() { return reloads; }, get fetches() { return fetches; },
|
||||
};
|
||||
}
|
||||
|
||||
test("non-CSS static assets navigate so changed scripts are executed", async () => {
|
||||
const c = client(page(), page());
|
||||
await c.change("full");
|
||||
assert.equal(c.reloads, 1);
|
||||
assert.equal(c.fetches, 0);
|
||||
});
|
||||
|
||||
test("template refresh navigates when inline scripts change", async () => {
|
||||
const c = client(page([element("SCRIPT", "<script>one()</script>")]), page([element("SCRIPT", "<script>two()</script>")]));
|
||||
await c.change("page");
|
||||
assert.equal(c.reloads, 1);
|
||||
});
|
||||
|
||||
test("template refresh navigates when head resources change", async () => {
|
||||
const c = client(page([], [element("STYLE", "<style>body{color:red}</style>")]), page([], [element("STYLE", "<style>body{color:blue}</style>")]));
|
||||
await c.change("page");
|
||||
assert.equal(c.reloads, 1);
|
||||
});
|
||||
|
||||
test("unchanged resources morph without navigation and ignore the injected script", async () => {
|
||||
const c = client(page([element("SCRIPT", "<script>one()</script>"), element("SCRIPT", "old livereload", { "data-veb-livereload": "" })]), page([element("SCRIPT", "<script>one()</script>"), element("SCRIPT", "new livereload", { "data-veb-livereload": "" })]));
|
||||
await c.change("page");
|
||||
assert.equal(c.fetches, 1);
|
||||
assert.equal(c.reloads, 0);
|
||||
});
|
||||
|
||||
// This small DOM model exercises node reuse and field values, including sibling
|
||||
// insertion. Resource comparisons above need only immutable element markup.
|
||||
class FormElement {
|
||||
constructor(nodeName, attrs = {}, value = "") {
|
||||
this.nodeType = 1; this.nodeName = nodeName; this.attrs = { ...attrs };
|
||||
this.value = value; this.childNodes = [];
|
||||
}
|
||||
get attributes() { return Object.entries(this.attrs).map(([name, value]) => ({ name, value })); }
|
||||
get children() { return this.childNodes; }
|
||||
get firstChild() { return this.childNodes[0] || null; }
|
||||
get id() { return this.attrs.id || ""; }
|
||||
get nextSibling() { return this.parentNode?.childNodes[this.parentNode.childNodes.indexOf(this) + 1] || null; }
|
||||
hasAttribute(name) { return name in this.attrs; }
|
||||
getAttribute(name) { return this.attrs[name] ?? null; }
|
||||
setAttribute(name, value) { this.attrs[name] = value; }
|
||||
removeAttribute(name) { delete this.attrs[name]; }
|
||||
insertBefore(child, cursor) {
|
||||
child.remove();
|
||||
const index = cursor ? this.childNodes.indexOf(cursor) : this.childNodes.length;
|
||||
this.childNodes.splice(index, 0, child); child.parentNode = this;
|
||||
}
|
||||
remove() {
|
||||
if (this.parentNode) {
|
||||
const siblings = this.parentNode.childNodes;
|
||||
siblings.splice(siblings.indexOf(this), 1); this.parentNode = null;
|
||||
}
|
||||
}
|
||||
cloneNode() { return new FormElement(this.nodeName, this.attrs); }
|
||||
}
|
||||
|
||||
function formPage(inputs) {
|
||||
const doc = page();
|
||||
doc.body = new FormElement("BODY");
|
||||
for (const input of inputs) doc.body.insertBefore(input, null);
|
||||
doc.importNode = (node) => node.cloneNode(true);
|
||||
return doc;
|
||||
}
|
||||
|
||||
test("inserting a named field keeps typed input attached to the original field", async () => {
|
||||
const username = new FormElement("INPUT", { name: "username", type: "text" }, "Alex");
|
||||
const oldPage = formPage([username]);
|
||||
const newPage = formPage([new FormElement("INPUT", { name: "email", type: "text" }), new FormElement("INPUT", { name: "username", type: "text" })]);
|
||||
const c = client(oldPage, newPage);
|
||||
await c.change("page");
|
||||
assert.equal(c.reloads, 0);
|
||||
assert.equal(oldPage.body.children[0].getAttribute("name"), "email");
|
||||
assert.equal(oldPage.body.children[0].value, "");
|
||||
assert.equal(oldPage.body.children[1], username);
|
||||
assert.equal(username.value, "Alex");
|
||||
});
|
||||
|
||||
test("a keyed field with a changed name or type does not inherit a typed value", async () => {
|
||||
for (const attrs of [{ id: "field", name: "email", type: "text" }, { id: "field", name: "username", type: "password" }]) {
|
||||
const oldPage = formPage([new FormElement("INPUT", { id: "field", name: "username", type: "text" }, "Alex")]);
|
||||
const c = client(oldPage, formPage([new FormElement("INPUT", attrs)]));
|
||||
await c.change("page");
|
||||
assert.equal(c.reloads, 0);
|
||||
assert.equal(oldPage.body.children[0].value, "");
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
module veb
|
||||
|
||||
fn test_sse_frame_single_line() {
|
||||
assert livereload_sse_frame('change', 'css') == 'event: change\ndata: css\n\n'
|
||||
}
|
||||
|
||||
fn test_sse_frame_gives_each_line_its_own_data_field() {
|
||||
frame := livereload_sse_frame('status', 'failed\nfirst error\r\nsecond error')
|
||||
assert frame == 'event: status\ndata: failed\ndata: first error\ndata: second error\n\n'
|
||||
}
|
||||
|
||||
fn test_status_state_is_the_first_line() {
|
||||
assert livereload_status_state('building\n') == 'building'
|
||||
assert livereload_status_state('failed\nsome.v:1:1: error: oops') == 'failed'
|
||||
assert livereload_status_state('ok') == 'ok'
|
||||
assert livereload_status_state('') == ''
|
||||
}
|
||||
|
||||
fn test_change_kind() {
|
||||
assert livereload_change_kind('static/css/main.css') == 'css'
|
||||
assert livereload_change_kind('static/css/MAIN.CSS') == 'css'
|
||||
assert livereload_change_kind('static/js/app.js') == 'full'
|
||||
assert livereload_change_kind('static/img/logo.png') == 'full'
|
||||
}
|
||||
|
||||
fn test_script_tag_carries_the_client_settings() {
|
||||
tag := livereload_script_tag('POST')
|
||||
assert tag.starts_with('<script src="/veb_livereload/${veb_livereload_server_start}/script.js"')
|
||||
assert tag.contains(' data-veb-livereload ')
|
||||
assert tag.contains('data-started="${veb_livereload_server_start}"')
|
||||
assert tag.contains('data-method="POST"')
|
||||
assert tag.contains('data-retry-max="${veb_livereload_retry_max_ms}"')
|
||||
assert tag.contains('data-toast="${veb_livereload_toast}"')
|
||||
}
|
||||
|
||||
fn test_stream_head_sends_hello_and_no_retry_field() {
|
||||
head := livereload_stream_head()
|
||||
assert head.starts_with('HTTP/1.1 200 OK\r\n')
|
||||
assert head.contains('Content-Type: text/event-stream\r\n')
|
||||
assert head.contains('Cache-Control: no-cache\r\n')
|
||||
assert head.ends_with('\r\n\r\nevent: hello\ndata: ${veb_livereload_server_start}\n\n')
|
||||
assert !head.contains('retry:')
|
||||
}
|
||||
|
||||
fn test_scripts() {
|
||||
assert livereload_script().contains('new EventSource(')
|
||||
polling := livereload_polling_script()
|
||||
assert polling.contains('setInterval(veb_livereload_checker_fn, ${veb_livereload_poll_ms}')
|
||||
assert polling.contains('"${veb_livereload_server_start}"')
|
||||
}
|
||||
Reference in new issue
Block a user