diff --git a/README.md b/README.md
index 96f437e..0e87ad8 100644
--- a/README.md
+++ b/README.md
@@ -133,9 +133,12 @@ Or from a file:
$ http-nu :3001 ./serve.nu
```
-Check out the [`examples/basic.nu`](examples/basic.nu) file in the repository
-for a complete example that implements a mini web server with multiple routes,
-form handling, and streaming responses.
+Run all examples under one server with the [examples hub](examples/README.md):
+
+```bash
+$ http-nu --datastar :3001 examples/serve.nu
+$ http-nu --datastar --store ./store :3001 examples/serve.nu # enables store-dependent examples
+```
### UNIX domain sockets
@@ -793,6 +796,19 @@ Make module paths available with `-I` / `--include-path`:
$ http-nu -I ./lib -I ./vendor :3001 '{|req| use mymod.nu; ...}'
```
+### Runtime Constants
+
+The `$HTTP_NU` const is available in all scripts and reflects the CLI options
+the server was started with:
+
+```nushell
+$HTTP_NU
+# => {dev: false, datastar: true, watch: false, store: "./store", topic: null, expose: null, tls: null, services: false}
+
+$HTTP_NU.store != null # check if store is available
+$HTTP_NU.dev # true when --dev was passed
+```
+
### Embedded Modules
#### Routing
@@ -835,6 +851,28 @@ Routes match in order. First match wins. Closure tests return a record (match,
context passed to handler) or null (no match). If no routes match, returns
`501 Not Implemented`.
+**Mounting sub-handlers:**
+
+`mount` serves a handler under a path prefix. Requests to `/prefix` redirect to
+`/prefix/`, then the prefix is stripped before dispatching to the handler.
+Sub-handlers see `$req.mount_prefix` for absolute URL reconstruction.
+
+```nushell
+let api = source api/serve.nu
+let docs = source docs/serve.nu
+
+{|req|
+ dispatch $req [
+ (mount "/api" $api)
+ (mount "/docs" $docs)
+ (route true {|req ctx| "Not Found"})
+ ]
+}
+```
+
+Mounts compose -- a mounted handler can mount further sub-handlers, and
+`$req.mount_prefix` accumulates the full prefix chain.
+
#### HTML DSL
Build HTML with Nushell. Lisp-style nesting with uppercase tags.
diff --git a/examples/README.md b/examples/README.md
new file mode 100644
index 0000000..fd725ce
--- /dev/null
+++ b/examples/README.md
@@ -0,0 +1,38 @@
+# Examples
+
+## Running all examples
+
+The examples hub mounts individual examples under one server:
+
+```bash
+http-nu --datastar :3001 examples/serve.nu
+```
+
+With a store (enables quotes):
+
+```bash
+http-nu --datastar --store ./store :3001 examples/serve.nu
+```
+
+Then visit http://localhost:3001.
+
+## Individual examples
+
+Each example can also be run standalone.
+
+| Example | Command | Description |
+|---------|---------|-------------|
+| basic | `http-nu :3001 examples/basic.nu` | Minimal routes, JSON, streaming, POST echo |
+| datastar-counter | `http-nu --datastar :3001 examples/datastar-counter/serve.nu` | Client-side reactive counter |
+| datastar-sdk | `http-nu --datastar :3001 examples/datastar-sdk/serve.nu` | Datastar SDK feature demo |
+| mermaid-editor | `http-nu --datastar :3001 examples/mermaid-editor/serve.nu` | Live Mermaid diagram editor |
+| templates | `http-nu --datastar --store ./store :3001 examples/templates/serve.nu` | `.mj` file, inline, and topic modes |
+| quotes | `http-nu --datastar --store ./store :3001 examples/quotes/serve.nu` | Live quotes board with SSE |
+| tao | `http-nu --datastar --dev -w :3001 examples/tao/serve.nu` | The Tao of Datastar |
+
+## Store-dependent examples
+
+Quotes and the `/topic` route in templates require `--store`. The hub
+detects `$HTTP_NU.store` at runtime and greys out unavailable examples.
+When `--store` is provided, templates automatically seeds its topics on
+startup.
diff --git a/examples/basic.nu b/examples/basic.nu
index 7e7247e..aa5d809 100644
--- a/examples/basic.nu
+++ b/examples/basic.nu
@@ -9,11 +9,11 @@
"
http-nu demo
"
}
@@ -52,6 +52,7 @@
# Time stream example
"/time" => {
+ let _ = $in
generate {|_|
sleep 1sec
{out: $"Current time: (date now | format date '%Y-%m-%d %H:%M:%S')\n" next: true}
diff --git a/examples/datastar-sdk/serve.nu b/examples/datastar-sdk/serve.nu
index 96a1ed2..1aac4e6 100644
--- a/examples/datastar-sdk/serve.nu
+++ b/examples/datastar-sdk/serve.nu
@@ -24,18 +24,18 @@ use http-nu/html *
DIV
(H3 "to datastar-patch-signals")
(P "Count: " (SPAN {"data-text": "$count"} "0"))
- (BUTTON {"data-on:click": "@post('/increment')"} "Increment")
+ (BUTTON {"data-on:click": "@post('./increment')"} "Increment")
)
(
DIV
(H3 "to datastar-execute-script")
- (BUTTON {"data-on:click": "@post('/hello')"} "Say Hello")
+ (BUTTON {"data-on:click": "@post('./hello')"} "Say Hello")
)
(
DIV
(H3 "to datastar-patch-elements")
(DIV {id: "time"} "--:--:--.---")
- (BUTTON {"data-on:click": "@post('/time')"} "Get Time")
+ (BUTTON {"data-on:click": "@post('./time')"} "Get Time")
)
)
)
diff --git a/examples/mermaid-editor/serve.nu b/examples/mermaid-editor/serve.nu
index 9dac13e..997a807 100644
--- a/examples/mermaid-editor/serve.nu
+++ b/examples/mermaid-editor/serve.nu
@@ -38,7 +38,7 @@ def mermaid-el []: string -> record {
(META {name: "viewport" content: "width=device-width, initial-scale=1"})
(TITLE "dia2")
(SCRIPT {type: "module" src: $DATASTAR_JS_PATH})
- (SCRIPT {type: "module" src: "/mermaid-diagram.js"})
+ (SCRIPT {type: "module" src: "./mermaid-diagram.js"})
(STYLE "
* { box-sizing: border-box; margin: 0; padding: 0; }
body { height: 100dvh; display: flex; font-family: system-ui, sans-serif; }
@@ -61,7 +61,7 @@ mermaid-diagram {
(DIV {class: "pane"}
(TEXTAREA {
"data-bind:source": true
- "data-on:input__debounce.500ms": "@post('/')"
+ "data-on:input__debounce.500ms": "@post('./')"
} $default_source)
)
(DIV {class: "pane"}
diff --git a/examples/quotes/serve.nu b/examples/quotes/serve.nu
index 13a5a2b..2b186a4 100644
--- a/examples/quotes/serve.nu
+++ b/examples/quotes/serve.nu
@@ -2,6 +2,21 @@ use http-nu/router *
use http-nu/datastar *
use http-nu/html *
+# Filter for `.last --follow` / `.cat --follow` streams.
+# Buffers frames until xs.threshold is seen, then emits the last
+# buffered frame (or null if none) followed by all subsequent frames.
+def threshold-gate [] {
+ generate {|frame, state = {}|
+ if $frame.topic == "xs.threshold" {
+ return {out: $state.last?, next: {reached: true}}
+ }
+ if ("reached" in $state) {
+ return {out: $frame, next: $state}
+ }
+ {next: ($state | upsert last $frame)}
+ }
+}
+
def quote-html []: record -> record {
let q = $in
(
@@ -45,7 +60,9 @@ def quote-html []: record -> record {
dispatch $req [
(
route {method: GET path: "/" has-header: {accept: "text/event-stream"}} {|req ctx|
- .head quotes --follow
+ .last quotes --follow
+ | threshold-gate
+ | default {meta: {quote: "Waiting for quotes..."}}
| each {|frame|
$frame.meta | quote-html | to datastar-patch-elements
}
@@ -72,7 +89,7 @@ def quote-html []: record -> record {
(SCRIPT {type: "module" src: $DATASTAR_JS_PATH})
)
(
- BODY {data-init: "@get('/')"}
+ BODY {data-init: "@get('./')"}
({quote: "Waiting for quotes..."} | quote-html)
)
)
diff --git a/examples/serve.nu b/examples/serve.nu
new file mode 100644
index 0000000..df08992
--- /dev/null
+++ b/examples/serve.nu
@@ -0,0 +1,69 @@
+# http-nu examples hub
+#
+# Run: http-nu --datastar :3001 examples/serve.nu
+# With store: http-nu --datastar --store ./store :3001 examples/serve.nu
+
+use http-nu/router *
+use http-nu/html *
+
+let basic = source basic.nu
+let counter = source datastar-counter/serve.nu
+let sdk = source datastar-sdk/serve.nu
+let mermaid = source mermaid-editor/serve.nu
+let templates = source templates/serve.nu
+let quotes = source quotes/serve.nu
+
+let has_store = $HTTP_NU.store != null
+
+def example-link [href: string label: string desc: string --disabled] {
+ if $disabled {
+ LI (SPAN {style: {color: "#9ca3af"}} $label) $" --($desc) " (SPAN {style: {color: "#9ca3af" font-size: "0.85em"}} "(requires --store)")
+ } else {
+ LI (A {href: $href} $label) $" --($desc)"
+ }
+}
+
+let routes = [
+ (
+ route {method: GET path: "/"} {|req ctx|
+ HTML (
+ HEAD
+ (META {charset: "UTF-8"})
+ (META {name: "viewport" content: "width=device-width, initial-scale=1"})
+ (TITLE "http-nu examples")
+ (STYLE "
+body { font-family: system-ui, sans-serif; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
+a { color: #2563eb; }
+li { margin: 0.5rem 0; }
+")
+ ) (
+ BODY
+ (H1 "http-nu examples")
+ (P (A {href: "https://github.com/cablehead/http-nu/tree/main/examples"} "source on GitHub"))
+ (UL
+ (example-link "./basic/" "basic" "minimal routes, JSON, streaming")
+ (example-link "./datastar-counter/" "datastar-counter" "reactive counter")
+ (example-link "./datastar-sdk/" "datastar-sdk" "SDK feature demo")
+ (example-link "./mermaid-editor/" "mermaid-editor" "live diagram editor")
+ (example-link "./templates/" "templates" ".mj template modes")
+ (example-link "./quotes/" "quotes" "live quotes board" --disabled=(not $has_store))
+ )
+ )
+ }
+ )
+
+ (mount "/basic" $basic)
+ (mount "/datastar-counter" $counter)
+ (mount "/datastar-sdk" $sdk)
+ (mount "/mermaid-editor" $mermaid)
+ (mount "/templates" $templates)
+ ...(if $has_store {
+ [(mount "/quotes" $quotes)]
+ } else {
+ []
+ })
+]
+
+{|req|
+ dispatch $req $routes
+}
diff --git a/examples/templates/nav.html b/examples/templates/nav.html
index d14b29b..9f0b7c3 100644
--- a/examples/templates/nav.html
+++ b/examples/templates/nav.html
@@ -1 +1 @@
-Home | Page (from disk)
+Home | Page (from disk)
diff --git a/examples/templates/serve.nu b/examples/templates/serve.nu
index 29d4389..f84662a 100644
--- a/examples/templates/serve.nu
+++ b/examples/templates/serve.nu
@@ -1,3 +1,10 @@
+# Seed store topics from disk files when --store is enabled
+if $HTTP_NU.store != null {
+ open examples/templates/topics/page.html | .append page.html
+ open examples/templates/topics/base.html | .append base.html
+ open examples/templates/topics/nav.html | .append nav.html
+}
+
{|req|
match $req.path {
"/file" => { {name: "World"} | .mj "examples/templates/page.html" }
@@ -6,8 +13,8 @@
{} | .mj --inline 'Templates
This page is rendered with .mj --inline.
'
}
}
diff --git a/examples/templates/topics/nav.html b/examples/templates/topics/nav.html
index ca055fd..4401070 100644
--- a/examples/templates/topics/nav.html
+++ b/examples/templates/topics/nav.html
@@ -1 +1 @@
-Home | Page (from store)
+Home | Page (from store)
diff --git a/src/engine.rs b/src/engine.rs
index 78f384d..a7dbafb 100644
--- a/src/engine.rs
+++ b/src/engine.rs
@@ -26,6 +26,19 @@ use crate::logging::log_error;
use crate::stdlib::load_http_nu_stdlib;
use crate::Error;
+/// CLI options exposed to scripts as the `$HTTP_NU` const
+#[derive(Clone, Default)]
+pub struct HttpNuOptions {
+ pub dev: bool,
+ pub datastar: bool,
+ pub watch: bool,
+ pub store: Option,
+ pub topic: Option,
+ pub expose: Option,
+ pub tls: Option,
+ pub services: bool,
+}
+
#[derive(Clone)]
pub struct Engine {
pub state: EngineState,
@@ -55,18 +68,30 @@ impl Engine {
})
}
- /// Sets `$env.HTTP_NU` with server configuration for stdlib modules
- pub fn set_http_nu_env(&mut self, dev: bool) -> Result<(), Error> {
- let mut stack = Stack::new();
+ /// Sets `$HTTP_NU` const with server configuration for stdlib modules
+ pub fn set_http_nu_const(&mut self, options: &HttpNuOptions) -> Result<(), Error> {
let span = Span::unknown();
+ let opt_str = |v: &Option| match v {
+ Some(s) => Value::string(s, span),
+ None => Value::nothing(span),
+ };
let record = Value::record(
nu_protocol::record! {
- "dev" => Value::bool(dev, span),
+ "dev" => Value::bool(options.dev, span),
+ "datastar" => Value::bool(options.datastar, span),
+ "watch" => Value::bool(options.watch, span),
+ "store" => opt_str(&options.store),
+ "topic" => opt_str(&options.topic),
+ "expose" => opt_str(&options.expose),
+ "tls" => opt_str(&options.tls),
+ "services" => Value::bool(options.services, span),
},
span,
);
- stack.add_env_var("HTTP_NU".to_string(), record);
- self.state.merge_env(&mut stack)?;
+ let mut working_set = StateWorkingSet::new(&self.state);
+ let var_id = working_set.add_variable(b"$HTTP_NU".into(), span, Type::record(), false);
+ working_set.set_variable_const_val(var_id, record);
+ self.state.merge_delta(working_set.render())?;
Ok(())
}
diff --git a/src/main.rs b/src/main.rs
index a3c2fba..5875dfa 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -9,7 +9,7 @@ use std::time::Duration;
use arc_swap::ArcSwap;
use clap::Parser;
use http_nu::{
- engine::script_to_engine,
+ engine::{script_to_engine, HttpNuOptions},
handler::{handle, AppConfig},
listener::TlsConfig,
logging::{
@@ -138,12 +138,12 @@ fn create_base_engine(
plugins: &[PathBuf],
include_paths: &[PathBuf],
store: Option<&Store>,
- dev: bool,
+ options: &HttpNuOptions,
) -> Result> {
let mut engine = Engine::new()?;
engine.add_custom_commands()?;
engine.set_lib_dirs(include_paths)?;
- engine.set_http_nu_env(dev)?;
+ engine.set_http_nu_const(options)?;
for plugin_path in plugins {
engine.load_plugin(plugin_path)?;
@@ -539,7 +539,10 @@ async fn main() -> Result<(), Box> {
let mut engine = Engine::new()?;
engine.add_custom_commands()?;
engine.set_lib_dirs(&args.include_paths)?;
- engine.set_http_nu_env(args.dev)?;
+ engine.set_http_nu_const(&HttpNuOptions {
+ dev: args.dev,
+ ..Default::default()
+ })?;
for plugin_path in &args.plugins {
engine.load_plugin(plugin_path)?;
@@ -593,13 +596,37 @@ async fn main() -> Result<(), Box> {
#[cfg(not(feature = "cross-stream"))]
let store: Option = None;
+ // Build $HTTP_NU options from CLI args
+ let http_nu_options = HttpNuOptions {
+ dev: args.dev,
+ datastar: args.datastar,
+ watch: args.watch,
+ tls: args.tls.as_ref().map(|p| p.display().to_string()),
+ #[cfg(feature = "cross-stream")]
+ store: args.store.as_ref().map(|p| p.display().to_string()),
+ #[cfg(not(feature = "cross-stream"))]
+ store: None,
+ #[cfg(feature = "cross-stream")]
+ topic: args.topic.clone(),
+ #[cfg(not(feature = "cross-stream"))]
+ topic: None,
+ #[cfg(feature = "cross-stream")]
+ expose: args.expose.clone(),
+ #[cfg(not(feature = "cross-stream"))]
+ expose: None,
+ #[cfg(feature = "cross-stream")]
+ services: args.services,
+ #[cfg(not(feature = "cross-stream"))]
+ services: false,
+ };
+
// Create base engine with commands, signals, and plugins
let base_engine = create_base_engine(
interrupt.clone(),
&args.plugins,
&args.include_paths,
store.as_ref(),
- args.dev,
+ &http_nu_options,
)?;
// Source: --topic (direct store read, with optional watch for live-reload)
diff --git a/src/stdlib/http/mod.nu b/src/stdlib/http/mod.nu
index f9478a4..149ead1 100644
--- a/src/stdlib/http/mod.nu
+++ b/src/stdlib/http/mod.nu
@@ -42,7 +42,7 @@ export def "cookie set" [
$"($name)=($value)"
$"Path=($path)"
(if not $no_httponly { "HttpOnly" })
- (if (not $env.HTTP_NU.dev) and (not $no_secure) { "Secure" })
+ (if (not $HTTP_NU.dev) and (not $no_secure) { "Secure" })
$"SameSite=($same_site)"
(if $max_age != null { $"Max-Age=($max_age)" })
(if $domain != null { $"Domain=($domain)" })
diff --git a/src/stdlib/router/mod.nu b/src/stdlib/router/mod.nu
index 8806730..cf4ad05 100644
--- a/src/stdlib/router/mod.nu
+++ b/src/stdlib/router/mod.nu
@@ -225,6 +225,34 @@ def dispatch-execute [
#
# Routes are tested in order until one matches (returns non-null context).
# The matched route's handler receives the request, context, and body as $in.
+# Mount a sub-handler under a path prefix
+#
+# Requests matching the prefix are forwarded to the handler with the prefix
+# stripped from $req.path. Bare prefix requests (without trailing slash) get
+# a 302 redirect so relative URLs resolve correctly.
+#
+# The handler receives $req with:
+# - path: prefix stripped (e.g. "/examples/basic/" becomes "/basic/")
+# - mount_prefix: the full prefix chain (composes for nested mounts)
+#
+# The request body ($in) streams through to the handler.
+export def mount [prefix: string handler: closure] {
+ route {|req|
+ if ($req.path == $prefix) or ($req.path | str starts-with $"($prefix)/") {
+ {prefix: $prefix}
+ }
+ } {|req ctx|
+ let body = $in
+ if $req.path == $ctx.prefix {
+ "" | metadata set { merge {'http.response': {status: 302 headers: {location: $"($ctx.prefix)/"}}} }
+ } else {
+ let path = $req.path | str substring ($ctx.prefix | str length)..
+ let base = $req.mount_prefix? | default ""
+ $body | do $handler ($req | upsert path $path | upsert mount_prefix $"($base)($ctx.prefix)")
+ }
+ }
+}
+
@example "dispatch to matching route" {
let routes = [
(route {path: "/health"} {|req ctx| "OK" })
diff --git a/src/test_handler.rs b/src/test_handler.rs
index 31b6cb1..4f423b4 100644
--- a/src/test_handler.rs
+++ b/src/test_handler.rs
@@ -346,7 +346,12 @@ fn test_engine_with_dev(script: &str, dev: bool) -> crate::Engine {
Box::new(PrintCommand::new()),
])
.unwrap();
- engine.set_http_nu_env(dev).unwrap();
+ engine
+ .set_http_nu_const(&crate::engine::HttpNuOptions {
+ dev,
+ ..Default::default()
+ })
+ .unwrap();
engine.parse_closure(script, None).unwrap();
engine
}
diff --git a/tests/eval_test.rs b/tests/eval_test.rs
index 01fc752..b48a338 100644
--- a/tests/eval_test.rs
+++ b/tests/eval_test.rs
@@ -73,6 +73,32 @@ fn test_eval_print() {
.stdout(predicates::str::contains(r#""content":"hello""#));
}
+#[test]
+fn test_http_nu_const_defaults() {
+ Command::new(assert_cmd::cargo::cargo_bin!("http-nu"))
+ .args(["eval", "-c", "$HTTP_NU.dev"])
+ .assert()
+ .success()
+ .stdout("false\n");
+}
+
+#[test]
+fn test_http_nu_const_dev_flag() {
+ Command::new(assert_cmd::cargo::cargo_bin!("http-nu"))
+ .args(["--dev", "eval", "-c", "$HTTP_NU.dev"])
+ .assert()
+ .success()
+ .stdout("true\n");
+}
+
+#[test]
+fn test_http_nu_const_is_immutable() {
+ Command::new(assert_cmd::cargo::cargo_bin!("http-nu"))
+ .args(["eval", "-c", "$HTTP_NU = {}"])
+ .assert()
+ .failure();
+}
+
#[test]
fn test_eval_syntax_error() {
Command::new(assert_cmd::cargo::cargo_bin!("http-nu"))
diff --git a/www/serve.nu b/www/serve.nu
index d4fc2b4..1b8ee0f 100644
--- a/www/serve.nu
+++ b/www/serve.nu
@@ -76,6 +76,7 @@ def header-bar [] {
(DIV {class: [font-mono text-header text-fluid-xl font-bold]} "http-nu")
(
NAV {class: [flex gap-4]} [
+ (A {href: "/examples/"} "Examples")
(A {href: "https://github.com/cablehead/http-nu"} "GitHub")
(A {href: "https://discord.gg/sGgYVKnk73"} "Discord")
]
@@ -349,8 +350,12 @@ def install-section [] {
]
}
+let examples = source ../examples/serve.nu
+
{|req|
dispatch $req [
+ (mount "/examples" $examples)
+
(
route {method: GET path: "/syntax.css"} {|req ctx|
.highlight theme Dracula | metadata set --content-type "text/css"