diff --git a/cmd/tools/vwatch.v b/cmd/tools/vwatch.v index 0da360f7a0..f0b4cde48f 100644 --- a/cmd/tools/vwatch.v +++ b/cmd/tools/vwatch.v @@ -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) } diff --git a/cmd/tools/vwatch_test.v b/cmd/tools/vwatch_test.v index c01b651d2d..1bd21187ac 100644 --- a/cmd/tools/vwatch_test.v +++ b/cmd/tools/vwatch_test.v @@ -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")! diff --git a/examples/vwatch/web_server/main.v b/examples/vwatch/web_server/main.v index 27e439ee7f..ed75fb9802 100644 --- a/examples/vwatch/web_server/main.v +++ b/examples/vwatch/web_server/main.v @@ -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);')! diff --git a/tutorials/building_a_simple_web_blog_with_veb/README.md b/tutorials/building_a_simple_web_blog_with_veb/README.md index afde2770b5..e70a69db6d 100644 --- a/tutorials/building_a_simple_web_blog_with_veb/README.md +++ b/tutorials/building_a_simple_web_blog_with_veb/README.md @@ -111,7 +111,8 @@ Custom routes can be defined using attributes like @['/index']. -> 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. diff --git a/vlib/v/help/common/watch.txt b/vlib/v/help/common/watch.txt index b86727a10b..56d8f8190f 100644 --- a/vlib/v/help/common/watch.txt +++ b/vlib/v/help/common/watch.txt @@ -32,6 +32,12 @@ Options: --after 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 diff --git a/vlib/v/skills/v-veb/references/ROUTES.md b/vlib/v/skills/v-veb/references/ROUTES.md index c19785b95c..7116eb053b 100644 --- a/vlib/v/skills/v-veb/references/ROUTES.md +++ b/vlib/v/skills/v-veb/references/ROUTES.md @@ -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 ``, from `$veb.html()` and `ctx.html()` alike. ## Reading the request diff --git a/vlib/veb/README.md b/vlib/veb/README.md index e8b4a781a9..236c70adde 100644 --- a/vlib/veb/README.md +++ b/vlib/veb/README.md @@ -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 `\n') + livereload_script_tag(ctx.req.method.str()) + '\n') } else { ctx.res.body = response.clone() } diff --git a/vlib/veb/tests/livereload_test.v b/vlib/veb/tests/livereload_test.v new file mode 100644 index 0000000000..697ae7cf2e --- /dev/null +++ b/vlib/veb/tests/livereload_test.v @@ -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('' +} + +// 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 {} + } } diff --git a/vlib/veb/veb_livereload_client_test.js b/vlib/veb/veb_livereload_client_test.js new file mode 100644 index 0000000000..400c678196 --- /dev/null +++ b/vlib/veb/veb_livereload_client_test.js @@ -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", "")]), page([element("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", "")]), page([], [element("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", ""), element("SCRIPT", "old livereload", { "data-veb-livereload": "" })]), page([element("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, ""); + } +}); diff --git a/vlib/veb/veb_livereload_test.v b/vlib/veb/veb_livereload_test.v new file mode 100644 index 0000000000..0a1ae486c9 --- /dev/null +++ b/vlib/veb/veb_livereload_test.v @@ -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('