| Crates.io | http-nu |
| lib.rs | http-nu |
| version | 0.10.1 |
| created_at | 2025-01-31 09:12:16.014502+00 |
| updated_at | 2026-01-22 18:30:11.559875+00 |
| description | The surprisingly performant, Datastar-ready, Nushell-scriptable HTTP server that fits in your back pocket. |
| homepage | https://github.com/cablehead/http-nu |
| repository | https://github.com/cablehead/http-nu |
| max_upload_size | |
| id | 1537322 |
| size | 1,137,271 |
The surprisingly performant, Datastar-ready, Nushell-scriptable HTTP server that fits in your back pocket.
Install
·
Reference
·
Discord
eget cablehead/http-nu
brew install cablehead/tap/http-nu
For fast installation using pre-built binaries:
cargo binstall http-nu
Or build from source:
cargo install http-nu --locked
nix-shell -p http-nu
http-nu is available in nixpkgs. For packaging and maintenance documentation, see NIXOS_PACKAGING_GUIDE.md.
$ http-nu :3001 -c '{|req| "Hello world"}'
$ curl -s localhost:3001
Hello world
Or from a file:
$ http-nu :3001 ./serve.nu
Check out the 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.
$ http-nu ./sock -c '{|req| "Hello world"}'
$ curl -s --unix-socket ./sock localhost
Hello world
Use -w / --watch to automatically reload when files change:
$ http-nu :3001 -w ./serve.nu
This watches the script's directory for any changes (including included files) and hot-reloads the handler. Useful during development. Active SSE connections are aborted on reload to trigger client reconnection.
Pass - to read the script from stdin:
$ echo '{|req| "hello"}' | http-nu :3001 -
With -w, send null-terminated scripts to hot-reload the handler:
$ (printf '{|req| "v1"}\0'; sleep 5; printf '{|req| "v2"}') | http-nu :3001 - -w
Each \0-terminated script replaces the handler.
$ http-nu :3001 -c '{|req| $in}'
$ curl -s -d Hai localhost:3001
Hai
The Request metadata is passed as an argument to the closure.
$ http-nu :3001 -c '{|req| $req}'
$ curl -s 'localhost:3001/segment?foo=bar&abc=123' # or
$ http get 'http://localhost:3001/segment?foo=bar&abc=123'
─────────────┬───────────────────────────────
proto │ HTTP/1.1
method │ GET
uri │ /segment?foo=bar&abc=123
path │ /segment
remote_ip │ 127.0.0.1
remote_port │ 52007
trusted_ip │ 127.0.0.1
│ ────────────┬────────────────
headers │ host │ localhost:3001
│ user-agent │ curl/8.7.1
│ accept │ */*
│ ────────────┴────────────────
│ ─────┬─────
query │ abc │ 123
│ foo │ bar
│ ─────┴─────
─────────────┴───────────────────────────────
$ http-nu :3001 -c '{|req| $"hello: ($req.path)"}'
$ http get 'http://localhost:3001/yello'
hello: /yello
Set HTTP response status and headers using nushell's pipeline metadata:
"body" | metadata set --merge {'http.response': {
status: <number> # Optional, HTTP status code (default: 200)
headers: { # Optional, HTTP headers
<key>: <value> # Single value: "text/plain"
<key>: [<value>, <value>] # Multiple values: ["cookie1=a", "cookie2=b"]
}
}}
Header values can be strings or lists of strings. Multiple values (e.g., Set-Cookie) are sent as separate HTTP headers per RFC 6265.
$ http-nu :3001 -c '{|req| "sorry, eh" | metadata set --merge {"http.response": {status: 404}}}'
$ curl -si localhost:3001
HTTP/1.1 404 Not Found
transfer-encoding: chunked
date: Fri, 31 Jan 2025 08:20:28 GMT
sorry, eh
Multi-value headers:
"cookies set" | metadata set --merge {'http.response': {
headers: {
"Set-Cookie": ["session=abc; Path=/", "token=xyz; Secure"]
}
}}
Content-type is determined in the following order of precedence:
Headers set via http.response metadata:
"body" | metadata set --merge {'http.response': {
headers: {"Content-Type": "text/plain"}
}}
Pipeline metadata content-type (e.g., from to yaml or
metadata set --content-type)
Inferred from value type:
application/jsonapplication/json (JSON array)application/x-ndjson (JSONL)application/octet-streamDefault: text/html; charset=utf-8
Examples:
# 1. Explicit header takes precedence
{|req| {foo: "bar"} | metadata set --merge {'http.response': {headers: {"Content-Type": "text/plain"}}} }
# 2. Pipeline metadata
{|req| ls | to yaml } # Returns as application/x-yaml
# 3. Inferred from value type
{|req| {foo: "bar"} } # Record -> application/json
{|req| [{a: 1}, {b: 2}, {c: 3}] } # List -> application/json (array)
{|req| 1..10 | each { {n: $in} } } # Stream of records -> application/x-ndjson
{|req| 0x[deadbeef] } # Binary -> application/octet-stream
{|req| null } # Empty -> no Content-Type header
# 4. Default
{|req| "Hello" } # Returns as text/html; charset=utf-8
To consume a JSONL endpoint from Nushell:
http get http://localhost:3001 | from json --objects | each {|row| ... }
Enable TLS by providing a PEM file containing both certificate and private key:
$ http-nu :3001 --tls combined.pem -c '{|req| "Secure Hello"}'
$ curl -k https://localhost:3001
Secure Hello
Generate a self-signed certificate for testing:
$ openssl req -x509 -newkey rsa:4096 -keyout key.pem -out cert.pem -days 365 -nodes
$ cat cert.pem key.pem > combined.pem
HTTP/2 is automatically enabled for TLS connections:
$ curl -k --http2 -si https://localhost:3001 | head -1
HTTP/2 200
Control log output with --log-format:
human (default): Live-updating terminal output with startup banner,
per-request progress lines showing timestamp, IP, method, path, status,
timing, and bytesjsonl: Structured JSON lines with scru128 stamps for log aggregationEach request emits 3 phases: request (received), response (headers sent), complete (body finished).
Human format
JSONL format
Events share a request_id for correlation:
$ http-nu --log-format jsonl :3001 '{|req| "hello"}'
{"stamp":"...","message":"started","address":"http://127.0.0.1:3001","startup_ms":42}
{"stamp":"...","message":"request","request_id":"...","method":"GET","path":"/","request":{...}}
{"stamp":"...","message":"response","request_id":"...","status":200,"headers":{...},"latency_ms":1}
{"stamp":"...","message":"complete","request_id":"...","bytes":5,"duration_ms":2}
Lifecycle events: started, reloaded, stopping, stopped, stop_timed_out
The print command outputs to the logging system (appears as message: "print"
in JSONL).
When behind a reverse proxy, use --trust-proxy to extract client IP from
X-Forwarded-For. Accepts CIDR notation, repeatable:
$ http-nu --trust-proxy 10.0.0.0/8 --trust-proxy 192.168.0.0/16 :3001 '{|req| $req.trusted_ip}'
The trusted_ip field is resolved by parsing X-Forwarded-For right-to-left,
stopping at the first IP not in a trusted range. Falls back to remote_ip when:
--trust-proxy flags providedX-Forwarded-For header presentYou can serve static files from a directory using the .static command. This
command takes two arguments: the root directory path and the request path.
When you call .static, it sets the response to serve the specified file, and
any subsequent output in the closure will be ignored. The content type is
automatically inferred based on the file extension (e.g., text/css for .css
files).
Here's an example:
$ http-nu :3001 -c '{|req| .static "/path/to/static/dir" $req.path}'
For single page applications you can provide a fallback file:
$ http-nu :3001 -c '{|req| .static "/path/to/static/dir" $req.path --fallback "index.html"}'
Values returned by streaming pipelines (like generate) are sent to the client
immediately as HTTP chunks. This allows real-time data transmission without
waiting for the entire response to be ready.
$ http-nu :3001 -c '{|req|
generate {|_|
sleep 1sec
{out: (date now | to text | $in + "\n") next: true }
} true
}'
$ curl -s localhost:3001
Fri, 31 Jan 2025 03:47:59 -0500 (now)
Fri, 31 Jan 2025 03:48:00 -0500 (now)
Fri, 31 Jan 2025 03:48:01 -0500 (now)
Fri, 31 Jan 2025 03:48:02 -0500 (now)
Fri, 31 Jan 2025 03:48:03 -0500 (now)
...
Use the to sse command to format records for the text/event-stream protocol.
Each input record may contain the optional fields data, id, event, and
retry which will be emitted in the resulting stream.
to sseConverts {data? id? event? retry?} records into SSE format. Non-string data
values are serialized to JSON.
Auto-sets response headers: content-type: text/event-stream,
cache-control: no-cache, connection: keep-alive.
| input | output |
|---|---|
| record | string |
Examples
> {data: 'hello'} | to sse
data: hello
> {id: 1 event: greet data: 'hi'} | to sse
id: 1
event: greet
data: hi
> {data: "foo\nbar"} | to sse
data: foo
data: bar
> {data: [1 2 3]} | to sse
data: [1,2,3]
# Note: `to sse` automatically sets content-type: text/event-stream
$ http-nu :3001 -c '{|req|
tail -F source.json | lines | from json | to sse
}'
# simulate generating events in a seperate process
$ loop {
{date: (date now)} | to json -r | $in + "\n" | save -a source.json
sleep 1sec
}
$ curl -si localhost:3001/
HTTP/1.1 200 OK
content-type: text/event-stream
transfer-encoding: chunked
date: Fri, 31 Jan 2025 09:01:20 GMT
data: {"date":"2025-01-31 04:01:23.371514 -05:00"}
data: {"date":"2025-01-31 04:01:24.376864 -05:00"}
data: {"date":"2025-01-31 04:01:25.382756 -05:00"}
data: {"date":"2025-01-31 04:01:26.385418 -05:00"}
data: {"date":"2025-01-31 04:01:27.387723 -05:00"}
data: {"date":"2025-01-31 04:01:28.390407 -05:00"}
...
Embed cross.stream for real-time state and event
streaming. Append-only frames, automatic indexing, content-addressed storage.
Enable with --store <path>. Add --services to enable xs handlers,
generators, and commands - external clients can register automation via the
store's API (e.g., xs append ./store echo.register ...).
$ http-nu :3001 --store ./store ./serve.nu
Commands available in handlers:
| Command | Description |
|---|---|
.cat |
Read frames (-f follow, -n new, -T topic) |
.last |
Get latest frame for topic (--follow stream) |
.append |
Write frame to topic (--meta for metadata) |
.get |
Retrieve frame by ID |
.remove |
Remove frame by ID |
.cas |
Content-addressable storage operations |
.id |
Generate/unpack/pack SCRU128 IDs |
SSE with store:
{|req|
.last quotes --follow
| each {|frame| $frame.meta | to dstar-patch-element }
| to sse
}
See the xs documentation to learn more.
You can proxy HTTP requests to backend servers using the .reverse-proxy
command. This command takes a target URL and an optional configuration record.
When you call .reverse-proxy, it forwards the incoming request to the
specified backend server and returns the response. Any subsequent output in the
closure will be ignored.
What gets forwarded:
preserve_host)Host header behavior:
preserve_host: true)preserve_host: false: Sets Host header to match the target backend
hostname# Simple proxy to backend server
$ http-nu :3001 -c '{|req| .reverse-proxy "http://localhost:8080"}'
The optional second parameter allows you to customize the proxy behavior:
.reverse-proxy <target_url> {
headers?: {<key>: <value>} # Additional headers to add
preserve_host?: bool # Keep original Host header (default: true)
strip_prefix?: string # Remove path prefix before forwarding
query?: {<key>: <value>} # Replace query parameters (Nu record)
}
Add custom headers:
$ http-nu :3001 -c '{|req|
.reverse-proxy "http://api.example.com" {
headers: {
"X-API-Key": "secret123"
"X-Forwarded-Proto": "https"
}
}
}'
API gateway with path stripping:
$ http-nu :3001 -c '{|req|
.reverse-proxy "http://localhost:8080" {
strip_prefix: "/api/v1"
}
}'
# Request to /api/v1/users becomes /users at the backend
Forward original request body:
$ http-nu :3001 -c '{|req| .reverse-proxy "http://backend:8080"}'
# If .reverse-proxy is first in closure, original body is forwarded (implicit $in)
Override request body:
$ http-nu :3001 -c '{|req| "custom body" | .reverse-proxy "http://backend:8080"}'
# Whatever you pipe into .reverse-proxy becomes the request body
Modify query parameters:
$ http-nu :3001 -c '{|req|
.reverse-proxy "http://backend:8080" {
query: ($req.query | upsert "context-id" "smidgeons" | reject "debug")
}
}'
# Force context-id=smidgeons, remove debug param, preserve others
Render minijinja (Jinja2-compatible) templates. Pipe a record as context.
.mj - Render inline$ http-nu :3001 -c '{|req| {name: "world"} | .mj --inline "Hello {{ name }}!"}'
$ curl -s localhost:3001
Hello world!
From a file:
$ http-nu :3001 -c '{|req| $req.query | .mj "templates/page.html"}'
File-based templates support {% extends %}, {% include %}, and
{% import %}. Referenced templates resolve from the template's directory and
subdirectories only - no parent traversal (../) or absolute paths.
.mj compile / .mj render - Precompiled templatesCompile once, render many. Syntax errors caught at compile time.
let tpl = (.mj compile --inline "{{ name }} is {{ age }}")
# Or from file
let tpl = (.mj compile "templates/user.html")
# Render with data
{name: "Alice", age: 30} | .mj render $tpl
Useful for repeated rendering:
let tpl = (.mj compile --inline "{% for i in items %}{{ i }}{% endfor %}")
[{items: [1,2,3]}, {items: [4,5,6]}] | each { .mj render $tpl }
Compile once at handler load, render per-request:
let page = .mj compile "templates/page.html"
{|req| $req.query | .mj render $page}
With HTML DSL (accepts {__html} records directly):
use http-nu/html *
let tpl = .mj compile --inline (UL (_for {item: items} (LI (_var "item"))))
{items: [a b c]} | .mj render $tpl
# <ul><li>a</li><li>b</li><li>c</li></ul>
Highlight code to HTML with CSS classes.
$ http-nu eval -c 'use http-nu/html *; PRE { "fn main() {}" | .highlight rust } | get __html'
<pre><span class="source rust">...
$ .highlight lang # list languages
$ .highlight theme # list themes
$ .highlight theme Dracula # get CSS
Convert Markdown to HTML with syntax-highlighted code blocks.
$ http-nu eval -c '"# Hello **world**" | .md | get __html'
<h1>Hello <strong>world</strong></h1>
Code blocks use .highlight internally:
$ http-nu eval -c '"```rust
fn main() {}
```" | .md | get __html'
<pre><code class="language-rust"><span class="source rust">...
In Nushell, input only streams when received implicitly. Referencing $in
collects the entire input into memory.
# Streams: command receives input implicitly
{|req| from json }
# Buffers: $in collects before piping
{|req| $in | from json }
For routing, dispatch must be first in the closure to receive the body. In
handlers, put body-consuming commands first:
{|req|
dispatch $req [
(route {method: "POST"} {|req ctx|
from json # receives body implicitly
})
]
}
Load Nushell plugins to extend available commands.
$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc :3001 '{|req| 5 | inc}'
$ curl -s localhost:3001
6
Multiple plugins:
$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc --plugin ~/.cargo/bin/nu_plugin_query :3001 '{|req| ...}'
Works with eval:
$ http-nu --plugin ~/.cargo/bin/nu_plugin_inc eval -c '1 | inc'
2
Make module paths available with -I / --include-path:
$ http-nu -I ./lib -I ./vendor :3001 '{|req| use mymod.nu; ...}'
http-nu includes an embedded routing module for declarative request handling.
The request body is available to handlers as $in.
use http-nu/router *
{|req|
dispatch $req [
# Exact path match
(route {path: "/health"} {|req ctx| "OK"})
# Method + path
(route {method: "POST", path: "/users"} {|req ctx|
"Created" | metadata set --merge {'http.response': {status: 201}}
})
# Path parameters
(route {path-matches: "/users/:id"} {|req ctx|
$"User: ($ctx.id)"
})
# Header matching
(route {has-header: {accept: "application/json"}} {|req ctx|
{status: "ok"}
})
# Fallback (always matches)
(route true {|req ctx|
"Not Found" | metadata set --merge {'http.response': {status: 404}}
})
]
}
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.
Build HTML with Nushell. Lisp-style nesting with uppercase tags.
use http-nu/html *
{|req|
(HTML
(HEAD (TITLE "Demo"))
(BODY
(H1 "Hello")
(P {class: "intro"} "Built with Nushell")
(UL { 1..3 | each {|n| LI $"Item ($n)" } })
)
)
}
HTML automatically prepends
<!DOCTYPE html>.
All HTML5 elements available as uppercase commands (DIV, SPAN, UL, etc.).
Attributes via record, children via args or closure. Lists from each are
automatically joined. Plain strings are auto-escaped for XSS protection;
{__html: "<b>trusted</b>"} bypasses escaping for pre-sanitized content.
style accepts a record; values can be lists for comma-separated CSS (e.g.
font-family): {style: {font-family: [Arial sans-serif] padding: 10px}}
class accepts a list: {class: [card active]}
Boolean attributes:
true renders the attribute, false omits it:
INPUT {type: "checkbox" checked: true disabled: false}
# <input type="checkbox" checked>
Jinja2 Template DSL
For hot paths, _var, _for, and _if generate Jinja2 syntax that can be
compiled once and rendered repeatedly (~200x faster than the runtime DSL):
_var "user.name" # {{ user.name }}
_for {item: items} (LI (_var "item")) # {% for item in items %}...{% endfor %}
_if "show" (DIV "content") # {% if show %}...{% endif %}
let tpl = .mj compile --inline (UL (_for {item: items} (LI (_var "item"))))
{items: [a b c]} | .mj render $tpl
# <ul><li>a</li><li>b</li><li>c</li></ul>
Generate Datastar SSE events for hypermedia interactions. Follows the SDK ADR.
Commands return records that pipe to to sse for streaming output.
use http-nu/datastar *
use http-nu/html *
{|req|
# Parse signals from request (GET query param or POST body)
let signals = from datastar-request $req
[
# Update DOM
(DIV {id: "notifications" class: "alert"} "Profile updated!"
| to dstar-patch-element)
# Or target by selector
(DIV {class: "alert"} "Profile updated!"
| to dstar-patch-element --selector "#notifications")
# Update signals
({count: ($signals.count + 1)} | to dstar-patch-signal)
# Execute script
("console.log('updated')" | to dstar-execute-script)
]
| to sse
}
Commands:
to dstar-patch-element [
--selector: string # CSS selector (omit if element has ID)
--mode: string # outer, inner, replace, prepend, append, before, after, remove (default: outer)
--namespace: string # Content namespace: html (default) or svg
--use_view_transition # Enable CSS View Transitions API
--id: string # SSE event ID for replay
--retry: int # Reconnection delay in ms
]: string -> record
to dstar-patch-signal [
--only_if_missing # Only set signals not present on client
--id: string
--retry: int
]: record -> record
to dstar-execute-script [
--auto_remove: bool # Remove <script> after execution (default: true)
--attributes: record # HTML attributes for <script> tag
--id: string
--retry: int
]: string -> record
to dstar-redirect []: string -> record # "/url" | to dstar-redirect
from datastar-request [req: record]: string -> record # $in | from datastar-request $req
Test http-nu commands without running a server.
# From command line
$ http-nu eval -c '1 + 2'
3
# From file
$ http-nu eval script.nu
# From stdin
$ echo '1 + 2' | http-nu eval -
3
# Test .mj commands
$ http-nu eval -c '.mj compile --inline "Hello, {{ name }}" | describe'
CompiledTemplate
This project uses Dagger for cross-platform containerized builds that run identically locally and in CI. This means you can test builds on your machine before pushing tags to trigger releases.
windows-build)darwin-build)linux-arm-64-build)linux-amd-64-build)Build a Windows binary locally:
dagger call windows-build --src upload --src "." export --path ./dist/
Get a throwaway terminal inside the Windows builder for debugging:
dagger call windows-env --src upload --src "." terminal
Note: Requires Docker and the Dagger CLI.
The upload function filters files to avoid uploading everything in your local
directory.
The GitHub workflow automatically builds all platforms and creates releases when
you push a version tag (e.g., v1.0.0). Development tags containing -dev. are
marked as prereleases.
If you prefer POSIX to Nushell, this project has a cousin called http-sh.