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:
Michael St.ClaireandAlexander Medvednikov authored and GitHub committed 2026-10-12 00:14:46 +03:00
1 parent 084481c3e9
commit 640dda22aa
16 files changed
+1557 -53

No files matched your search

+273 -5
View File
@@ -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) }
+141
View File
@@ -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")!
+6 -4
View File
@@ -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.
+6
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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()
}
+218
View File
@@ -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
}
+33
View File
@@ -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
View File
@@ -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
+7
View File
@@ -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{
+365
View File
@@ -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">&times;</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
View File
@@ -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 {}
}
}
+131
View File
@@ -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, "");
}
});
+50
View File
@@ -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}"')
}