Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agents/skills/marmite/references/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -201,8 +201,8 @@ marmite [site_folder] atproto publish --dry-run
|------|-------|---------|-------------|
| `--verbose` | `-v` | 0 (warn) | Verbosity: `-v` info, `-vv` debug, `-vvv` trace, `-vvvv` trace all |
| `--watch` | `-w` | false | Auto-rebuild on file changes |
| `--serve` | | false | Start built-in HTTP server |
| `--bind <ADDR>` | | `0.0.0.0:8000` | Server bind address (requires `--serve`) |
| `--serve` | | false | Start development-only HTTP server (not for production) |
| `--bind <ADDR>` | | `127.0.0.1:8000` | Server bind address (requires `--serve`) |
| `--config <FILE>` | `-c` | `marmite.yaml` | Path to config file |
| `--force` | | false | Force full rebuild |

Expand Down
6 changes: 4 additions & 2 deletions example/content/docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,13 +193,15 @@ webserver such as **Apache** or **Nginx**, or most probably use a free
static hosting service such as **Github pages**, **Netlify** or **CLoudflare**.

However, during the content writing you want to check how the website looks,
so the built-in server comes handy, just add `--serve`
so the built-in development-only server comes handy, just add `--serve`.
It defaults to `127.0.0.1:8000` and is not for production deployments.

```
marmite myblog site --watch --serve
...
Watching for changes in folder: myblog
Starting built-in HTTP server
Development-only server. Not for production deployments.
Server started at http://localhost:8000/ - Type ^C to stop.
```

Expand All @@ -210,7 +212,7 @@ When the server is running, a [[marmite-toolbar]] icon appears at the top-left c
The server also exposes a [[content-management-api]] under `/__marmite__/` for programmatic content and config management.

If you want to share your site with others in the same network, just
pass `--bind "0.0.0.0:8000` and then share your local IP address.
pass `--bind 0.0.0.0:8000` and then share your local IP address.

## Media

Expand Down
16 changes: 10 additions & 6 deletions example/content/docs/marmite-command-line-interface.md
Original file line number Diff line number Diff line change
Expand Up @@ -174,19 +174,23 @@ Watching for changed on: myblog/
Marmite generates a flat site, which means you can open it directly on your browser (with some limitations)
or use any web server to serve it.

Marmite comes with a built-in server to use only locally.
Marmite comes with a development-only server, not for production deployments.
It binds to `127.0.0.1:8000` by default.

Use `--serve` to start the server.

```console
$ /marmite myblog /var/www/myblog -w
$ /marmite myblog /var/www/myblog -w --serve
Site generated at: /var/www/myblog
Watching for changed on: myblog/
Starting built-in HTTP server...
Server started at http://0.0.0.0:8000/ - Type ^C to stop.
Development-only server. Not for production deployments.
Server started at http://127.0.0.1:8000/ - Type ^C to stop.
```

If you want to change the address use `--bind ip:port`
If you want to change the address use `--bind ip:port`. For access from other
machines on your network, use `--bind 0.0.0.0:8000`. If the requested port is
unavailable, the server uses an OS-assigned port on the requested interface.

> [!IMPORTANT]
> The built-in server is not suitable for production, when deploying use a webserver such as [Nginx] or read the [[hosting]] guide to learn how to deploy to Github pages, Gitlab pages, Codeberg Pages, Netlify and more.
Expand Down Expand Up @@ -591,9 +595,9 @@ Options:
-w, --watch
Detect changes and rebuild the site automatically
--serve
Serve the site with a built-in HTTP server
Serve the site with a development-only HTTP server (not for production)
--bind <BIND>
Address to bind the server [default: 0.0.0.0:8000]
Address to bind the server [default: 127.0.0.1:8000]
-c, --config <CONFIG>
Path to custom configuration file [default: marmite.yaml]
--init-templates
Expand Down
19 changes: 19 additions & 0 deletions example/content/release-notes/marmite-0-4-3-release-notes.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
---
date: 2026-09-29
title: Marmite 0.4.3 Release Notes
slug: marmite-0-4-3-release-notes
stream: draft
tags: [release-notes, marmite]
---

## Bug Fixes

### Development server binding

`--serve` now defaults to `127.0.0.1:8000`. If the requested port is unavailable,
the fallback uses an OS-assigned port while preserving the requested interface,
instead of binding to all interfaces. Use `--bind 0.0.0.0:8000` explicitly for
access from other machines on your network.

The CLI help and server startup message now explicitly identify the built-in
server as development-only, not for production deployments.
4 changes: 2 additions & 2 deletions src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,12 @@ pub struct Cli {
#[arg(long, short)]
pub watch: bool,

/// Serve the site with a built-in HTTP server
/// Serve the site with a development-only HTTP server (not for production)
#[arg(long)]
pub serve: bool,

/// Address to bind the server
#[arg(long, default_value = "0.0.0.0:8000", requires = "serve")]
#[arg(long, default_value = "127.0.0.1:8000", requires = "serve")]
pub bind: String,

/// Path to custom configuration file
Expand Down
34 changes: 22 additions & 12 deletions src/server.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
//! Development-only preview server and editor API, not for production deployments.

use chrono::Utc;
use log::{error, info, warn};
use serde_json::json;
use std::fmt::Write as _;
use std::io::{Cursor, ErrorKind};
use std::net::ToSocketAddrs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;
Expand All @@ -23,7 +26,6 @@ pub struct ServerContext {
pub watch_enabled: bool,
}

const FALLBACK_BIND_ADDRESS: &str = "0.0.0.0:0";
const LIVE_RELOAD_SCRIPT_PATH: &str = "__marmite__/livereload.js";
const TOOLBAR_JS_PATH: &str = "__marmite__/toolbar.js";
const TOOLBAR_CSS_PATH: &str = "__marmite__/toolbar.css";
Expand Down Expand Up @@ -94,23 +96,31 @@ static EDITOR_HTML: LazyLock<String> = LazyLock::new(|| {
.expect("embedded editor.html missing - this is a build-time error")
});

fn bind_server(bind_address: &str) -> Result<Server, Box<dyn std::error::Error + Send + Sync>> {
let mut addresses: Vec<_> = bind_address.to_socket_addrs()?.collect();
Server::http(addresses.as_slice()).or_else(|err| {
warn!(
"Failed to start server on address {bind_address}: {err:?}. Falling back to OS-assigned port on the same interface."
);
// Only change the port; retain the requested IPv4/IPv6 interfaces.
for address in &mut addresses {
address.set_port(0);
}
Server::http(addresses.as_slice())
})
}

pub fn start(bind_address: &str, ctx: &ServerContext, live_reload: Option<&LiveReload>) {
let server = match Server::http(bind_address) {
let server = match bind_server(bind_address) {
Ok(server) => server,
Err(e) => {
warn!(
"Failed to start server on address {bind_address}: {e:?}. Falling back to OS-assigned port."
);
match Server::http(FALLBACK_BIND_ADDRESS) {
Ok(server) => server,
Err(e) => {
error!("Failed to start server on fallback address: {e:?}");
return;
}
}
error!("Failed to start development server: {e:?}");
return;
}
};

info!("Development-only server. Not for production deployments.");

let Some(server_addr) = server.server_addr().to_ip() else {
warn!("Failed to get server IP address, using fallback display");
// Use a fallback approach for display purposes
Expand Down
35 changes: 35 additions & 0 deletions src/tests/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,41 @@ use std::fs;
use std::io::Read;
use tempfile::TempDir;

#[test]
fn test_default_bind_is_loopback_and_custom_bind_is_preserved() {
use clap::Parser;

let args = crate::cli::Cli::try_parse_from(["marmite", ".", "--serve"]).unwrap();
assert_eq!(args.bind, "127.0.0.1:8000");
let args =
crate::cli::Cli::try_parse_from(["marmite", ".", "--serve", "--bind", "0.0.0.0:9000"])
.unwrap();
assert_eq!(args.bind, "0.0.0.0:9000");
}

#[test]
fn test_bind_fallback_preserves_interface() {
for ip in ["127.0.0.1", "0.0.0.0", "::1"] {
let ip: std::net::IpAddr = ip.parse().unwrap();
let occupied = match std::net::TcpListener::bind((ip, 0)) {
Ok(listener) => listener,
Err(err) if ip.is_ipv6() && err.kind() == ErrorKind::AddrNotAvailable => continue,
Err(err) => panic!("Failed to bind test listener: {err}"),
};
let requested = occupied.local_addr().unwrap();
let server = bind_server(&requested.to_string()).unwrap();
let actual = server.server_addr().to_ip().unwrap();
assert_eq!(actual.ip(), requested.ip());
assert_ne!(actual.port(), 0);
assert_ne!(actual.port(), requested.port());
}
}

#[test]
fn test_invalid_bind_does_not_fall_back_to_all_interfaces() {
assert!(bind_server("invalid-address").is_err());
}

#[test]
fn test_render_not_found_with_file() {
let temp_dir = TempDir::new().unwrap();
Expand Down
Loading