turnout 0.23.0

A developer's switchyard: point local apps at any backend stand, keep servers and secrets at hand, build and deploy from any directory
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
//! The front door: one address per app.
//!
//! Dev servers take their port in the order they start - Vite hands out 5173,
//! then 5174 - so "which app is on which port" is a fresh question every
//! morning. The front door answers it by name instead: the gateway listens on
//! one well-known port and routes by host, `myapp.localhost` to whatever dev
//! server turnout started for `myapp`. Every name under `.localhost` resolves
//! to loopback (RFC 6761), so the address works without a hosts file, and the
//! port behind it is turnout's business.
//!
//! The door is port 80 when the machine hands it out (Windows and macOS do,
//! Linux asks for privileges) and 7000 otherwise; `TURNOUT_FRONT_PORT` pins
//! one. A door that cannot open is a warning, not the end of the gateway: the
//! per-app stand proxies still work, and that is the daily flow.

use std::sync::OnceLock;

use anyhow::{Context, Result, anyhow, bail};
use axum::body::Body;
use axum::extract::ws::WebSocketUpgrade;
use axum::extract::{FromRequestParts, Request, State};
use axum::http::header::{COOKIE, HOST, LOCATION, SEC_WEBSOCKET_PROTOCOL};
use axum::http::{HeaderMap, HeaderValue, StatusCode};
use axum::response::Response;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;

use crate::model::App;
use crate::store;

/// The port the door opens on when it can.
pub const PORT: u16 = 80;
/// Where it opens when 80 is taken or privileged.
pub const FALLBACK_PORT: u16 = 7000;
/// Pins the door to one port; `0` keeps it shut.
pub const ENV_PORT: &str = "TURNOUT_FRONT_PORT";

/// The port the door should open on: pinned, or the first of 80 and 7000
/// that binds. `None` means no door - pinned shut, or both ports taken.
pub fn pick_port() -> Option<u16> {
    if let Some(pinned) = std::env::var(ENV_PORT).ok().and_then(|value| value.trim().parse::<u16>().ok()) {
        return (pinned != 0).then_some(pinned);
    }
    [PORT, FALLBACK_PORT]
        .into_iter()
        .find(|port| std::net::TcpListener::bind(("127.0.0.1", *port)).is_ok())
}

/// The door itself: `http://localhost`, with the port when it is not 80.
pub fn door(front_port: u16) -> String {
    if front_port == PORT {
        "http://localhost".to_string()
    } else {
        format!("http://localhost:{front_port}")
    }
}

/// The app's address behind a door on `front_port`.
pub fn address(app: &str, front_port: u16) -> String {
    if front_port == PORT {
        format!("http://{app}.localhost")
    } else {
        format!("http://{app}.localhost:{front_port}")
    }
}

/// The app a `Host` header names: `myapp.localhost[:port]` gives `myapp`.
pub fn app_of_host(host: &str) -> Option<&str> {
    let host = host
        .rsplit_once(':')
        .map_or(host, |(name, port)| if port.chars().all(|c| c.is_ascii_digit()) { name } else { host });
    let name = host.strip_suffix(".localhost")?;
    (!name.is_empty() && !name.contains('.')).then_some(name)
}

/// Serve the door on `listener` until the gateway stops.
pub async fn serve(listener: tokio::net::TcpListener, front_port: u16) {
    let router = axum::Router::new().fallback(route).with_state(front_port);
    let _ = axum::serve(listener, router).await;
}

async fn route(State(front_port): State<u16>, req: Request) -> Response {
    match dispatch(front_port, req).await {
        Ok(response) => response,
        Err(err) => page(StatusCode::BAD_GATEWAY, &format!("turnout front door: {err:#}")),
    }
}

async fn dispatch(front_port: u16, req: Request) -> Result<Response> {
    let host = req.headers().get(HOST).and_then(|value| value.to_str().ok()).unwrap_or_default();
    let Some(name) = app_of_host(host) else {
        return Ok(page(
            StatusCode::NOT_FOUND,
            &format!("turnout front door: '{host}' names no app - apps answer at http://NAME.localhost, see `turnout app list`"),
        ));
    };
    let apps = store::load_apps()?;
    let Some(app) = apps.iter().find(|app| app.name == name) else {
        return Ok(page(StatusCode::NOT_FOUND, &format!("turnout: no app named '{name}' - see `turnout app list`")));
    };
    let Some(dev_port) = app.dev_port else {
        return Ok(not_running(app));
    };
    if crate::gateway::is_websocket_upgrade(req.headers()) {
        return websocket(app, dev_port, req).await;
    }
    match forward(app, dev_port, front_port, req).await {
        Ok(response) => Ok(response),
        Err(err) if err.downcast_ref::<reqwest::Error>().is_some_and(reqwest::Error::is_connect) => Ok(not_running(app)),
        Err(err) => Err(err),
    }
}

/// The page for a name whose dev server is not answering: what to run, not
/// a reset connection.
fn not_running(app: &App) -> Response {
    page(
        StatusCode::SERVICE_UNAVAILABLE,
        &format!("'{0}' is not running - start it with `turnout dev {0}`", app.name),
    )
}

fn page(status: StatusCode, text: &str) -> Response {
    let body = format!(
        "<!doctype html><meta charset=\"utf-8\"><title>turnout</title>\
         <style>body{{font:16px/1.5 system-ui,sans-serif;margin:3rem auto;max-width:40rem;color:#1b2126}}code{{background:#eee;padding:.1em .3em}}</style>\
         <p>{}</p>",
        html_escape(text)
    );
    Response::builder()
        .status(status)
        .header("content-type", "text/html; charset=utf-8")
        .body(Body::from(body))
        .expect("static response")
}

fn html_escape(text: &str) -> String {
    let mut out = String::with_capacity(text.len());
    let mut in_code = false;
    for c in text.chars() {
        match c {
            '<' => out.push_str("&lt;"),
            '>' => out.push_str("&gt;"),
            '&' => out.push_str("&amp;"),
            '`' => {
                out.push_str(if in_code { "</code>" } else { "<code>" });
                in_code = !in_code;
            }
            c => out.push(c),
        }
    }
    out
}

fn client() -> &'static reqwest::Client {
    static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
    CLIENT.get_or_init(|| {
        reqwest::Client::builder()
            .redirect(reqwest::redirect::Policy::none())
            .build()
            .expect("a plain HTTP client builds")
    })
}

/// Plain pass-through to the dev server. No cookie jar: the browser talks to
/// the app by name and keeps the app's own cookies itself; the stand's
/// cookies live in the per-app gateway jar as before.
async fn forward(app: &App, dev_port: u16, front_port: u16, req: Request) -> Result<Response> {
    let (parts, body) = req.into_parts();
    let path_query = parts.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
    // `localhost`, not `127.0.0.1`: Vite binds the name, and Node resolves it
    // to `::1` first on many machines, so the server answers on IPv6 only.
    // The connector tries every address the name resolves to; a fixed
    // address would try one and call a running server "not running".
    let target = format!("http://localhost:{dev_port}{path_query}");
    let mut headers = parts.headers;
    crate::gateway::strip_hop_headers(&mut headers);
    // The dev server sees itself as localhost:PORT; Vite checks the Host it
    // is asked for against its own name and answers that one without a
    // configuration line.
    headers.remove(HOST);
    let body = axum::body::to_bytes(body, crate::gateway::MAX_REQUEST_BODY)
        .await
        .context("request body too large")?;
    let upstream = client().request(parts.method, &target).headers(headers).body(body).send().await?;

    let status = upstream.status();
    let mut resp_headers = upstream.headers().clone();
    crate::gateway::strip_hop_headers(&mut resp_headers);
    rewrite_location(&mut resp_headers, dev_port, &address(&app.name, front_port))?;
    let mut response = Response::builder().status(status);
    if let Some(headers_mut) = response.headers_mut() {
        *headers_mut = resp_headers;
    }
    Ok(response.body(Body::from_stream(upstream.bytes_stream()))?)
}

/// A dev server that redirects to its own port is redirected back to the name.
fn rewrite_location(headers: &mut HeaderMap, dev_port: u16, public: &str) -> Result<()> {
    let Some(location) = headers.get(LOCATION).and_then(|v| v.to_str().ok()).map(str::to_string) else {
        return Ok(());
    };
    for own in [format!("http://localhost:{dev_port}"), format!("http://127.0.0.1:{dev_port}")] {
        if let Some(rest) = location.strip_prefix(&own) {
            let rewritten = format!("{public}{rest}");
            headers.insert(
                LOCATION,
                HeaderValue::from_str(&rewritten).context("rewritten location is not a valid header value")?,
            );
            break;
        }
    }
    Ok(())
}

/// HMR and the like: accept the browser's upgrade, open the same socket to
/// the dev server and pump frames both ways.
async fn websocket(app: &App, dev_port: u16, req: Request) -> Result<Response> {
    let (mut parts, _body) = req.into_parts();
    let mut upgrade = WebSocketUpgrade::from_request_parts(&mut parts, &())
        .await
        .map_err(|err| anyhow!("invalid websocket upgrade: {err}"))?;
    let path_query = parts.uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");
    // The name, for the same reason as in `forward`: the socket tries each
    // address it resolves to.
    let target = format!("ws://localhost:{dev_port}{path_query}");
    let mut request = target.into_client_request().context("cannot build the upstream websocket request")?;
    if let Some(cookie) = parts.headers.get(COOKIE) {
        request.headers_mut().insert(COOKIE, cookie.clone());
    }
    if let Some(protocol) = parts.headers.get(SEC_WEBSOCKET_PROTOCOL) {
        request.headers_mut().insert(SEC_WEBSOCKET_PROTOCOL, protocol.clone());
        if let Ok(protocols) = protocol.to_str() {
            upgrade = upgrade.protocols(protocols.split(',').map(|p| p.trim().to_string()).collect::<Vec<_>>());
        }
    }
    let name = app.name.clone();
    Ok(upgrade.on_upgrade(move |client| async move {
        match tokio_tungstenite::connect_async(request).await {
            Ok((upstream, _response)) => crate::gateway::pump(client, upstream).await,
            Err(err) => eprintln!("turnout front door: websocket to '{name}' failed: {err}"),
        }
    }))
}

/// Ports the dev servers are handed out from, one per app, fixed on first
/// `dev`. A hundred is more apps than a workstation runs; the range sits
/// clear of the 5173 Vite defaults to, so a server started by hand does
/// not collide with one turnout placed.
pub const DEV_PORTS: std::ops::RangeInclusive<u16> = 5100..=5199;

/// Ports the per-app gateway listeners are handed out from, one per app,
/// fixed when the app is registered. Same size and the same reasoning as
/// [`DEV_PORTS`]; 7100 is where the wizard used to start counting, so a
/// catalog filled in by hand keeps the numbers it already had.
pub const GATEWAY_PORTS: std::ops::RangeInclusive<u16> = 7100..=7199;

/// A dev port for `app`: its own when it has one, else the first free port
/// of the range no other app holds. `None` when the range is exhausted.
///
/// Assigned late, on the first `dev`: nothing needs the number until a
/// server is actually started.
pub fn dev_port_for(apps: &[App], app: &str) -> Option<u16> {
    free_port(DEV_PORTS, apps, apps.iter().find(|a| a.name == app).and_then(|a| a.dev_port))
}

/// A gateway port for a new app: the first of the range no app holds and
/// nothing on the machine is listening on. `None` when the range is
/// exhausted.
///
/// Assigned early, when the app is registered - unlike a dev port. The
/// number is written into the app's dotenv file and substituted into
/// `{gateway}` command lines straight away, so an app without one is an app
/// whose IDE-run `pnpm dev` talks to nothing.
pub fn free_gateway_port(apps: &[App]) -> Option<u16> {
    free_port(GATEWAY_PORTS, apps, None)
}

/// The first port of `range` that no app in `apps` holds - either kind, so
/// the two ranges could overlap without two apps colliding - and that the
/// machine will actually hand out. `held` short-circuits: an app that
/// already has its port keeps it, taken or not, because moving it would
/// break the address its dotenv file already carries.
fn free_port(range: std::ops::RangeInclusive<u16>, apps: &[App], held: Option<u16>) -> Option<u16> {
    if held.is_some() {
        return held;
    }
    let taken: Vec<u16> = apps.iter().flat_map(|a| a.dev_port.into_iter().chain(a.gateway_port)).collect();
    range
        .filter(|port| !taken.contains(port))
        .find(|port| std::net::TcpListener::bind(("127.0.0.1", *port)).is_ok())
}

/// A pinned dev port belongs to one app, like a gateway port.
pub fn ensure_dev_port_is_free(apps: &[App], port: u16, this: &str) -> Result<()> {
    if let Some(other) = apps.iter().find(|a| a.name != this && a.dev_port == Some(port)) {
        bail!("dev port {port} is already used by app '{}' - pick another", other.name);
    }
    Ok(())
}

/// Open a URL in the default browser, the platform's way.
pub fn open_in_browser(url: &str) -> Result<()> {
    #[cfg(windows)]
    let status = std::process::Command::new("cmd").args(["/C", "start", "", url]).status();
    #[cfg(target_os = "macos")]
    let status = std::process::Command::new("open").arg(url).status();
    #[cfg(all(unix, not(target_os = "macos")))]
    let status = std::process::Command::new("xdg-open").arg(url).status();
    let status = status.with_context(|| format!("cannot open {url}"))?;
    if !status.success() {
        bail!("the browser did not open {url} ({status})");
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::collections::BTreeMap;

    fn app(name: &str, dev_port: Option<u16>, gateway_port: Option<u16>) -> App {
        App {
            name: name.to_string(),
            path: String::new(),
            commands: BTreeMap::new(),
            dist_dir: None,
            gateway_port,
            gateway_env: None,
            env_file: None,
            dev_port,
            servers: Vec::new(),
        }
    }

    #[test]
    fn the_host_names_the_app_with_or_without_a_port() {
        assert_eq!(app_of_host("myapp.localhost"), Some("myapp"));
        assert_eq!(app_of_host("myapp.localhost:7000"), Some("myapp"));
        assert_eq!(app_of_host("my-app.localhost:80"), Some("my-app"));
        for host in [
            "localhost",
            "localhost:7000",
            ".localhost",
            "a.b.localhost",
            "myapp.example.com",
            "127.0.0.1:7000",
            "",
        ] {
            assert_eq!(app_of_host(host), None, "{host}");
        }
    }

    #[test]
    fn the_address_hides_port_80_and_shows_any_other() {
        assert_eq!(address("myapp", 80), "http://myapp.localhost");
        assert_eq!(address("myapp", 7000), "http://myapp.localhost:7000");
        assert_eq!(door(80), "http://localhost");
        assert_eq!(door(7000), "http://localhost:7000");
    }

    #[test]
    fn a_dev_port_is_kept_once_given_and_skips_ports_other_apps_hold() {
        let apps = [app("web", Some(5100), None), app("api", None, Some(5101)), app("new", None, None)];
        assert_eq!(dev_port_for(&apps, "web"), Some(5100));
        // 5100 is web's, 5101 is api's gateway port: the next free one is 5102.
        let picked = dev_port_for(&apps, "new").unwrap();
        assert!(picked >= 5102, "{picked}");
        assert!(DEV_PORTS.contains(&picked));
        assert!(ensure_dev_port_is_free(&apps, 5100, "web").is_ok());
        let error = ensure_dev_port_is_free(&apps, 5100, "new").unwrap_err().to_string();
        assert!(error.contains("already used by app 'web'"), "{error}");
    }

    #[test]
    fn a_gateway_port_comes_from_its_own_range_and_skips_what_apps_hold() {
        assert_eq!(free_gateway_port(&[]), Some(*GATEWAY_PORTS.start()));
        let apps = [app("web", None, Some(7100)), app("api", Some(7101), None)];
        // 7100 is web's gateway port and 7101 is api's dev port, pinned there
        // by hand: both are out, whichever kind of port they are.
        let picked = free_gateway_port(&apps).unwrap();
        assert!(picked >= 7102, "{picked}");
        assert!(GATEWAY_PORTS.contains(&picked));
    }

    #[test]
    fn a_port_already_held_is_kept_even_when_the_machine_holds_it_too() {
        // Something else is listening on the port the app was given. Moving
        // it would break the address its dotenv file carries, so it stays.
        let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).unwrap();
        let busy = listener.local_addr().unwrap().port();
        assert_eq!(free_port(DEV_PORTS, &[], Some(busy)), Some(busy));
    }

    #[test]
    fn a_redirect_to_the_dev_servers_own_port_comes_back_by_name() {
        let mut headers = HeaderMap::new();
        headers.insert(LOCATION, HeaderValue::from_static("http://localhost:5100/after?x=1"));
        rewrite_location(&mut headers, 5100, "http://myapp.localhost").unwrap();
        assert_eq!(headers.get(LOCATION).unwrap(), "http://myapp.localhost/after?x=1");
        headers.insert(LOCATION, HeaderValue::from_static("https://elsewhere.example.com/"));
        rewrite_location(&mut headers, 5100, "http://myapp.localhost").unwrap();
        assert_eq!(headers.get(LOCATION).unwrap(), "https://elsewhere.example.com/");
    }

    #[test]
    fn the_page_marks_code_and_escapes_markup() {
        assert_eq!(html_escape("run `turnout dev x` <now>"), "run <code>turnout dev x</code> &lt;now&gt;");
    }

    #[test]
    fn a_pinned_port_wins_and_zero_keeps_the_door_shut() {
        // Set and unset around the call: the variable is process-wide.
        unsafe { std::env::set_var(ENV_PORT, "7123") };
        assert_eq!(pick_port(), Some(7123));
        unsafe { std::env::set_var(ENV_PORT, "0") };
        assert_eq!(pick_port(), None);
        unsafe { std::env::remove_var(ENV_PORT) };
    }
}