Skip to content

Commit 67327f0

Browse files
committed
feat!: add -w/--watch flag for file watching and hot reload
BREAKING CHANGE: Stdin mode (`-`) now requires the `-w` flag because stdin is specifically for hot-reload scenarios (null-terminated scripts). For one-shot stdin input, use `http-nu eval -` instead. Before: `http-nu :3001 -` After: `http-nu :3001 - -w` New features: - File watch mode: `http-nu :3001 -w ./handler.nu` watches the script's directory for changes and hot-reloads on any file modification - Uses `notify` crate for cross-platform file system watching - 100ms debounce to coalesce rapid file system events - Watches recursively, so changes to included files trigger reload too - The `-w` flag is incompatible with `-c` (inline commands)
1 parent a5a5ce7 commit 67327f0

File tree

5 files changed

+479
-121
lines changed

5 files changed

+479
-121
lines changed

Cargo.lock

Lines changed: 51 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,8 @@ xxhash-rust = { version = "0.8.15", features = ["xxh3"] }
6767
syntect = "5.3.0"
6868
syntect-assets = "0.23.6"
6969
pulldown-cmark = "0.12.2"
70+
notify = "8"
71+
notify-debouncer-mini = "0.6"
7072

7173
[build-dependencies]
7274
syntect = "5.3.0"

README.md

Lines changed: 11 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,8 @@
4242
- [Reference](#reference)
4343
- [GET: Hello world](#get-hello-world)
4444
- [UNIX domain sockets](#unix-domain-sockets)
45-
- [Reading closures from stdin](#reading-closures-from-stdin)
46-
- [Dynamic reloads](#dynamic-reloads)
45+
- [Watch Mode](#watch-mode)
46+
- [Reading scripts from stdin](#reading-scripts-from-stdin)
4747
- [POST: echo](#post-echo)
4848
- [Request metadata](#request-metadata)
4949
- [Response metadata](#response-metadata)
@@ -130,47 +130,33 @@ $ curl -s --unix-socket ./sock localhost
130130
Hello world
131131
```
132132

133-
### Reading scripts from stdin
133+
### Watch Mode
134134

135-
Pass `-` to read the script from stdin:
135+
Use `-w` / `--watch` to automatically reload when files change:
136136

137137
```bash
138-
$ echo '{|req| "Hello from stdin"}' | http-nu :3001 -
139-
$ curl -s localhost:3001
140-
Hello from stdin
138+
$ http-nu :3001 -w ./handler.nu
141139
```
142140

143-
Or pipe a file:
144-
145-
```bash
146-
$ cat handler.nu | http-nu :3001 -
147-
```
141+
This watches the script's directory for any changes (including included files)
142+
and hot-reloads the handler. Useful during development.
148143

149144
Check out the [`examples/basic.nu`](examples/basic.nu) file in the repository
150145
for a complete example that implements a mini web server with multiple routes,
151146
form handling, and streaming responses.
152147

153-
#### Dynamic reloads
148+
#### Reading scripts from stdin
154149

155-
When reading from stdin, you can send multiple null-terminated scripts to
156-
hot-reload the handler without restarting the server. This example starts with
157-
"v1", then after 5 seconds switches to "v2":
150+
For programmatic control, pass `-` with `-w` to read null-terminated scripts
151+
from stdin. Each script hot-reloads the handler:
158152

159153
```bash
160-
$ (printf '{|req| "v1"}\0'; sleep 5; printf '{|req| "v2"}') | http-nu :3001 -
154+
$ (printf '{|req| "v1"}\0'; sleep 5; printf '{|req| "v2"}') | http-nu :3001 - -w
161155
```
162156

163157
JSON status is emitted to stdout: `"start"` on first load, `"reload"` on
164158
updates, `"error"` on parse failures.
165159

166-
Watch a file and reload on changes:
167-
168-
```nushell
169-
watch ./serve.nu | prepend {operation: Write} | each {
170-
(cat serve.nu) + (char -i 0)
171-
} | to text | http-nu /run/sock -
172-
```
173-
174160
### POST: echo
175161

176162
```bash

src/main.rs

Lines changed: 110 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ use hyper::service::service_fn;
2222
use hyper_util::rt::{TokioExecutor, TokioIo};
2323
use hyper_util::server::conn::auto::Builder as HttpConnectionBuilder;
2424
use hyper_util::server::graceful::GracefulShutdown;
25+
use notify::{RecursiveMode, Watcher};
2526
use tokio::signal;
2627
use tokio::sync::mpsc;
2728

@@ -48,9 +49,15 @@ struct Args {
4849
script: Option<String>,
4950

5051
/// Run script from command line instead of file
51-
#[clap(short = 'c', long = "commands")]
52+
#[clap(short = 'c', long = "commands", conflicts_with = "watch")]
5253
commands: Option<String>,
5354

55+
/// Watch for script changes and reload automatically.
56+
/// For file scripts: watches the script's directory for any changes.
57+
/// For stdin (-): reads null-terminated scripts for hot reload.
58+
#[clap(short = 'w', long = "watch")]
59+
watch: bool,
60+
5461
/// Log format: human (live-updating) or jsonl (structured)
5562
#[clap(long, default_value = "human")]
5663
log_format: LogFormat,
@@ -105,6 +112,66 @@ fn create_base_engine(
105112
Ok(engine)
106113
}
107114

115+
/// Spawns a file watcher that watches the script's directory for any changes.
116+
/// When a change is detected, re-reads the script file and sends it.
117+
fn spawn_file_watcher(script_path: PathBuf, tx: mpsc::Sender<String>) {
118+
std::thread::spawn(move || {
119+
let watch_dir = script_path.parent().unwrap_or(&script_path).to_path_buf();
120+
121+
let (raw_tx, raw_rx) = std::sync::mpsc::channel();
122+
123+
let mut watcher = notify::recommended_watcher(raw_tx).expect("Failed to create watcher");
124+
125+
watcher
126+
.watch(&watch_dir, RecursiveMode::Recursive)
127+
.expect("Failed to watch directory");
128+
129+
// Keep watcher alive
130+
let _watcher = watcher;
131+
132+
// Set to past time so first event isn't debounced
133+
let mut last_reload = std::time::Instant::now() - Duration::from_secs(1);
134+
let debounce = Duration::from_millis(100);
135+
136+
for result in raw_rx {
137+
match result {
138+
Ok(event) => {
139+
// Only react to modifications, not access/open events
140+
use notify::EventKind;
141+
let is_modification = matches!(
142+
event.kind,
143+
EventKind::Create(_) | EventKind::Modify(_) | EventKind::Remove(_)
144+
);
145+
if !is_modification {
146+
continue;
147+
}
148+
149+
// Debounce rapid events
150+
if last_reload.elapsed() < debounce {
151+
continue;
152+
}
153+
last_reload = std::time::Instant::now();
154+
155+
// Re-read and send the script file
156+
match std::fs::read_to_string(&script_path) {
157+
Ok(content) => {
158+
if tx.blocking_send(content).is_err() {
159+
break;
160+
}
161+
}
162+
Err(e) => {
163+
eprintln!("Error reading script file: {e}");
164+
}
165+
}
166+
}
167+
Err(e) => {
168+
eprintln!("Watch error: {e:?}");
169+
}
170+
}
171+
}
172+
});
173+
}
174+
108175
/// Spawns a dedicated OS thread that reads null-terminated scripts from stdin and sends them.
109176
/// Uses blocking I/O to avoid async stdin issues with piped input.
110177
fn spawn_stdin_reader(tx: mpsc::Sender<String>) {
@@ -419,42 +486,59 @@ async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
419486
// Server mode (default)
420487
let addr = args.addr.expect("addr required for server mode");
421488

422-
// Determine script source: -c flag, stdin (-), or file
423-
let (script_content, read_stdin) = match (&args.script, &args.commands) {
424-
(Some(_), Some(_)) => {
489+
// Create base engine with commands, signals, and plugins
490+
let base_engine = create_base_engine(interrupt.clone(), &args.plugins, &args.include_paths)?;
491+
492+
// Create channel for scripts
493+
let (tx, rx) = mpsc::channel::<String>(1);
494+
495+
// Determine script source and set up appropriate watcher/reader
496+
match (&args.script, &args.commands, args.watch) {
497+
(Some(_), Some(_), _) => {
425498
eprintln!("Error: cannot specify both script file and --commands");
426499
std::process::exit(1);
427500
}
428-
(None, None) => {
501+
(None, None, _) => {
429502
eprintln!("Error: provide a script file or use --commands");
430503
std::process::exit(1);
431504
}
432-
(None, Some(cmd)) => (Some(cmd.clone()), false),
433-
(Some(path), None) if path == "-" => (None, true),
434-
(Some(path), None) => {
505+
// -c flag: use command content directly (conflicts_with prevents -w)
506+
(None, Some(cmd), false) => {
507+
tx.send(cmd.clone())
508+
.await
509+
.expect("channel closed unexpectedly");
510+
drop(tx);
511+
}
512+
// stdin without -w: error
513+
(Some(path), None, false) if path == "-" => {
514+
eprintln!("Error: stdin mode (-) requires --watch flag");
515+
std::process::exit(1);
516+
}
517+
// stdin with -w: spawn stdin reader for null-terminated scripts
518+
(Some(path), None, true) if path == "-" => {
519+
spawn_stdin_reader(tx);
520+
}
521+
// file without -w: read once
522+
(Some(path), None, false) => {
435523
let content = std::fs::read_to_string(path).unwrap_or_else(|e| {
436524
eprintln!("Error reading {path}: {e}");
437525
std::process::exit(1);
438526
});
439-
(Some(content), false)
527+
tx.send(content).await.expect("channel closed unexpectedly");
528+
drop(tx);
440529
}
441-
};
442-
443-
// Create base engine with commands, signals, and plugins
444-
let base_engine = create_base_engine(interrupt.clone(), &args.plugins, &args.include_paths)?;
445-
446-
// Create channel for scripts
447-
let (tx, rx) = mpsc::channel::<String>(1);
448-
449-
if read_stdin {
450-
// Spawn dedicated stdin reader thread
451-
spawn_stdin_reader(tx);
452-
} else {
453-
// Send the script content
454-
tx.send(script_content.unwrap())
455-
.await
456-
.expect("channel closed unexpectedly");
457-
drop(tx); // Close the channel
530+
// file with -w: read initial content and spawn file watcher
531+
(Some(path), None, true) => {
532+
let script_path = PathBuf::from(path);
533+
let content = std::fs::read_to_string(&script_path).unwrap_or_else(|e| {
534+
eprintln!("Error reading {path}: {e}");
535+
std::process::exit(1);
536+
});
537+
tx.send(content).await.expect("channel closed unexpectedly");
538+
spawn_file_watcher(script_path, tx);
539+
}
540+
// -c with -w is prevented by clap conflicts_with
541+
(None, Some(_), true) => unreachable!(),
458542
}
459543

460544
serve(

0 commit comments

Comments
 (0)