smoothdev.web
0.0.0.1-alpha.gf6c6e2c
Prefix Reserved
dotnet tool install --global smoothdev.web --version 0.0.0.1-alpha.gf6c6e2c
dotnet new tool-manifest
dotnet tool install --local smoothdev.web --version 0.0.0.1-alpha.gf6c6e2c
#tool dotnet:?package=smoothdev.web&version=0.0.0.1-alpha.gf6c6e2c&prerelease
nuke :add-package smoothdev.web --version 0.0.0.1-alpha.gf6c6e2c
smoothdev.web
A bird's-eye view of a Vite + Fable + .NET web app while you work on it: one command starts the dev
server under dotnet watch --non-interactive, Fable and Vite on free ports, builds the static bundle,
runs the published production server, shows the logs, opens the right URL, and stops everything again
with no process left behind. From the command line, a terminal UI, or a small web GUI it serves itself.
smoothdev-web dev start # dotnet watch + Fable + Vite, ports picked free
smoothdev-web status # what runs, PID, port, URL
smoothdev-web dist # production bundle into dist/ (or client.distCommand)
smoothdev-web open dist # serve dist/ like a static host would, open it
smoothdev-web stop # stop all of it, process groups included
Why
An F# web app usually has three or four long-running processes in development (the server under
dotnet watch, Fable, Vite, sometimes a proxy) and another shape in production (a published server
and a static bundle). Each app ends up with its own concurrently line, its own hard-coded ports
that collide with the app next door, and its own way of leaving a stray dotnet or node process
behind after Ctrl+C. smoothdev.web is that wiring done once:
- ports are never a reason to fail: the preferred port is probed and the next free one is used,
then handed to the server (
ASPNETCORE_URLS) and to Vite (--port, and the proxy target in an environment variable the vite config reads); - every component is its own process group, recorded in a state file, so a second invocation (or
another terminal, or the GUI) sees what runs and
stopends the whole tree, including the MSBuild nodes and esbuild workers it spawned; - one entry point per app for dev, prod, static preview and bundle, scriptable (
status --json) and with a UI when you want to watch.
Install
smoothdev.web is a .NET tool (needs the .NET 10 SDK; runs on macOS and Linux).
# from a clone of this repository, in tools/smoothdev-web
dotnet fsi build.fsx -- install
smoothdev-web --help
install packs artifacts/smoothdev.web.0.1.0.nupkg and installs it as a global tool. Run it again
after a change: the same version is uninstalled and installed again, which is what dotnet tool update
will not do while the package version stays 0.1.0. The source is this repository, not nuget.org.
Or run it from source without installing: dotnet run --project src/SmoothDev.Web -- status.
Quick start
In the folder of a Vite app (or above it: see detection below):
smoothdev-web config show # the resolved config
smoothdev-web config diff # what the file changes versus detection of the same folder
smoothdev-web config init # write it to smoothdev.web.json, then adjust
smoothdev-web dev start
smoothdev-web open dev
smoothdev-web logs -f # follow every running component's log
smoothdev-web dev stop
Sample session (a static F# app: Fable compiled outside Vite, npm, no server):
$ smoothdev-web dev start
installing Node packages with npm
$ npm ci --no-audit --no-fund
install | added 16 packages in 5s
$ dotnet tool restore
tools | Restore was successful.
started fable (pid 61721)
waiting for Fable's first compilation
started vite (pid 61777, port 5291)
vite ready: http://127.0.0.1:5291/
$ smoothdev-web dev start # in another app whose preferred port is 5291 too
port 5291 is busy: Vite uses 5292
started vite (pid 61782, port 5292)
vite ready: http://127.0.0.1:5292/
$ smoothdev-web dist
dist | dist/index.html 0.34 kB │ gzip: 0.25 kB
dist | dist/assets/index-BOFfZ5-O.js 74.03 kB │ gzip: 24.15 kB
dist ready: ~/src/rpp-browser/dist (4 files, 82 KiB)
$ smoothdev-web stop
stopped gui (pid 61952)
stopped preview (pid 61955)
stopped vite (pid 61777)
stopped fable (pid 61721)
Commands
| command | what it does |
|---|---|
status [--json] |
every component: state, PID, port, URL, uptime |
dev start\|stop\|restart |
the server under dotnet watch --non-interactive, Fable watch (when Fable runs outside Vite), Vite |
prod start\|stop |
dotnet publish -c Release, then the published server (Production) serving dist |
preview start\|stop |
dist served as static files by the tool (what GitHub Pages, S3 or nginx would serve) |
build |
compile once to check: dotnet build of the server, Fable for the client |
dist |
the production bundle: Fable, then vite build --outDir <dist> |
open [dev\|dist\|prod] |
open that version in a browser; dist starts the preview if needed; no target: dev, else prod, else preview, else dist |
logs [name…] [-f] [-n N] |
last N lines of component logs (server, fable, vite, prod, preview, gui, dist, build, publish, install); -f follows |
stop |
stop everything this app runs |
tui |
terminal UI; on exit it prints the command line that started it, to re-run it |
gui [--port N] [--detach] |
web GUI on 127.0.0.1 (default port 5399); --detach runs it in the background |
config [show\|init] |
print the resolved config / write smoothdev.web.json from detection |
scan [tui\|gui [--classic]] |
list documentation sites, Vite apps, Web SDK projects and smoothdev.web.json files under this folder |
site [plan] [--json] |
the documentation sites (Nacara) under this folder, where each Vite app mounts and why, and the dev wiring |
site dev [--port N] |
the site's watch, each mounted Vite app under its route, and one front port for all of them |
site dist / site preview |
the site's build with each app built into its route, in .smoothdev/web/site-dist; served as a static host would |
site stop\|status [--json] |
stop / list what site dev and site preview started (--site <name> picks one of several sites); --json: [{ name, pid, port, url, log }], the front first |
launches [--json] [--all] |
every smoothdev-web launch on this machine, any folder: who started it (parent processes, SMOOTHDEV_WEB_LAUNCHER), its folder, command, ports and processes |
launches stop <id> |
stop what that launch started |
who <port> |
the launch that holds the port, else the process listening on it |
others [--json] [--all] |
other dev and web servers listening here that no launch holds: kind (Vite, webpack, Next, Nacara, dotnet watch, Fable, npm scripts, http.server, Jupyter...), folder, URL, pid, age, who started it |
others stop <pid> |
SIGTERM to one of them |
Global options: --dir <app folder> (default: the current folder; the nearest smoothdev.web.json at
or above it wins), --no-browser (print URLs instead; also SMOOTHDEV_WEB_NO_BROWSER=1).
Exit code 0 on success, 1 on failure, 2 on a usage error.
scan
scan walks the current folder (or --dir) and prints one line per app it can run. It does not start
anything. Each line is a kind, a path relative to the folder, and a note:
| kind | what was found | note |
|---|---|---|
config |
a folder with smoothdev.web.json |
the name in that file |
vite |
a Vite app (a Vite config, or a manifest that depends on vite) |
the package name |
server |
an .fsproj or .csproj that uses Microsoft.NET.Sdk.Web |
the project file name |
site |
an F# project that references Nacara.Core and calls Nacara.run |
the project file name |
A folder that has smoothdev.web.json is one hit. The walk does not also list the Vite app or the Web
SDK projects inside it. Directories named node_modules, bin, obj, dist, fable_modules, .git,
.smoothdev, artifacts, paket-files, .paket, target and packages are not entered.
Progress goes to stderr (scanning 250 … every 250 folders, then done). The hits are written to
~/.smoothdev/scan/. If a cache of a parent folder already covers this one and is less than 30 minutes
old, scan prints those hits and does not walk (cached N apps from <root>). A cache of this exact folder is not reused by plain
scan; the walk runs again and replaces it.
scan tui and scan gui show the same hits as a tree. The first two levels start open. A folder can
be folded; a leaf can be opened, which runs the tool in that app the same way tui or gui would.
A cache is shown at once, then the walk refreshes it, unless a parent cache already covers the folder.
Apps that are already running are marked. The TUI keys are j/k to move, space to fold, enter to
open, l for the next log, q to quit. It needs an interactive terminal.
scan gui serves a console page on 127.0.0.1 (default port 5399, --port N). It lists the hits,
each documentation site with the Vite apps mounted under it and the reasons, the front's routing,
what runs, the logs, and buttons for site dev, site stop, site dist and site preview. The page
is F# compiled by Fable to Lit web components (gui/, built by dotnet fsi build.fsx -- check or
pack). Without the built page, or with --classic, scan gui serves the earlier server-rendered tree.
How sites and apps are found and wired is Docs sites and Vite apps.
Components
| name | started by | command | output |
|---|---|---|---|
server |
dev start |
dotnet watch --non-interactive --no-launch-profile --project <server> |
.smoothdev/web/logs/server.log |
fable |
dev start (Fable CLI mode) |
dotnet fable watch <project> -o <outDir> |
fable.log |
vite |
dev start |
<client>/node_modules/.bin/vite --port <p> --strictPort --host 127.0.0.1 |
vite.log |
prod |
prod start |
dotnet <published>.dll |
prod.log |
preview |
preview start, open dist |
smoothdev-web __serve-static <dist> <base> <port> |
preview.log (one line per request) |
gui |
gui --detach |
smoothdev-web gui --port <p> |
gui.log |
dev start brings them up in order: Node packages installed if node_modules is missing (frozen to the
lockfile), dotnet tool restore when a tool manifest is found, the server, Fable (waiting for its first
compilation so Vite does not serve a 404 for the entry module), Vite, then it waits until each URL
answers HTTP. If a step fails, the log tail is printed and what that call started is stopped again.
smoothdev.web.json
Optional: without it the tool detects the app (see below). With it, the folder holding the file is the app root; relative paths resolve against it. Comments and trailing commas are allowed; unknown keys are rejected (typos do not pass silently).
{
"name": "shop",
"packageManager": "pnpm", // pnpm | npm | yarn | bun; default: from the lockfile
"client": {
"dir": "src/Client", // the Vite app (package.json + vite config)
"fable": "plugin", // "plugin": vite-plugin-fable compiles inside Vite
// { "project": "Client.fsproj", "outDir": "out", "extension": ".js" }:
// dotnet fable watch beside Vite ("cli" = detect the project)
// "none": plain Vite
"port": 5173, // preferred Vite port
"dist": "dist", // bundle folder, relative to client.dir
"distCommand": ["dotnet", "fsi", "build.fsx"], // optional: `dist` runs this in client.dir
// instead of Fable then vite build. A string is split on spaces.
"base": "/shop/" // Vite base for the bundle; omitted: the vite config's own
},
"server": {
"project": "src/Server/Server.fsproj",
"port": 5000, // preferred dev port
"prodPort": 8080, // preferred port of the published server
"serveDist": true // prod: web root = client dist (default when there is a client)
},
"previewPort": 4173,
"guiPort": 5399
}
A static app (no backend) simply has no server; prod then reports that the production form of the
app is its dist, and open prod opens the preview.
Detection
When there is no smoothdev.web.json at or above the folder:
- client: the first of
.,src/Client,src/client,client,src/Web,web,frontendthat is a Vite app: avite.config.*, orpackage.json/package.yaml/packages.yamldepending onvite.pnpm-lock.yamlnext to a vite config counts even when there is no manifest; - Fable mode:
pluginwhenpackage.jsonor the vite config mentionvite-plugin-fable; elsecliwith the client folder's.fsproj, the output folder read from the moduleindex.htmlimports (import … from "./out-js/App.js"→out-js); elsenone; - server: the shortest-path project with
Sdk="Microsoft.NET.Sdk.Web"up to four folders down (bin,obj,node_modules,dist… skipped); - package manager: the lockfile (
pnpm-lock.yaml,package-lock.json,yarn.lock,bun.lock), else thepackageManagerfield, else pnpm.
The port and environment contract
Ports are picked at start: the preferred one if nothing listens on it (a TCP connect to 127.0.0.1 and ::1 must fail and a bind must succeed), else the next free one up to +100, else one the OS hands out. Ports held by the app's other running components are never reused. Everything binds 127.0.0.1.
What each process receives:
| variable / argument | server (dev) | server (prod) | Vite |
|---|---|---|---|
ASPNETCORE_URLS=http://127.0.0.1:<port> |
✓ | ✓ | |
ASPNETCORE_ENVIRONMENT |
Development |
Production |
|
PORT, SMOOTHDEV_WEB_SERVER_PORT |
✓ | ✓ | SMOOTHDEV_WEB_SERVER_PORT when there is a server |
SMOOTHDEV_WEB_SERVER_URL |
✓ | ✓ when there is a server: the proxy target | |
SMOOTHDEV_WEB_DIST, ASPNETCORE_WEBROOT = client dist |
✓ when serveDist |
||
DOTNET_WATCH_RESTART_ON_RUDE_EDIT=1, …SUPPRESS_LAUNCH_BROWSER=1 |
✓ | ||
--no-launch-profile (launchSettings.json would override the URL) |
✓ | ||
SMOOTHDEV_WEB_VITE_PORT, --port <p> --strictPort --host 127.0.0.1 |
✓ |
A server honours ASPNETCORE_URLS. ASP.NET Core, Giraffe and Saturn do that unless the program
replaces the URL. A Vite config that proxies API calls reads its target from the environment:
// vite.config.js
import { defineConfig } from "vite";
import fable from "vite-plugin-fable";
const server = process.env.SMOOTHDEV_WEB_SERVER_URL ?? "http://127.0.0.1:5000";
export default defineConfig({
plugins: [fable()],
server: { proxy: { "/api": server } },
});
Vite gets --strictPort because the port was already checked: if something grabs it in between, Vite
fails loudly instead of silently moving to a port the tool does not know.
<a id="server-url"></a>
The server must use the URL it was given
ASP.NET server is the page for this. An ASP.NET server listens on the URL in ASPNETCORE_URLS.
<a id="vite-proxy"></a>
The Vite proxy must target the URL it was given
Vite proxy is the page for this. At least one server.proxy prefix targets SMOOTHDEV_WEB_SERVER_URL. A fixed localhost:5000 misses the port the tool picked.
Making an app smoothdev.web-enabled
- Vite app with a
package.jsonand a lockfile; Fable either throughvite-plugin-fableor through the Fable dotnet tool (pinned in.config/dotnet-tools.json). - Server, if any: an ASP.NET Core project that takes its URL from
ASPNETCORE_URLS; in production it serves static files from its web root (app.UseStaticFiles(); the tool pointsASPNETCORE_WEBROOTat the client'sdist). - The vite proxy target from
SMOOTHDEV_WEB_SERVER_URL(above). smoothdev-web config init, review, commitsmoothdev.web.json; add.smoothdev/to.gitignore.
Terminal UI and web GUI
smoothdev-web tui (keys: d dev on/off, p prod, v preview, b dist, o open,
l next log, q quit and stop what it started, x quit and leave things running):
smoothdev-web rpp-browser ~/src/rpp-browser/smoothdev.web.json
╭───────────┬───────────────────────────┬───────┬──────┬────────────────────────┬───────┬───────────────────────╮
│ component │ state │ pid │ port │ url │ up │ role │
├───────────┼───────────────────────────┼───────┼──────┼────────────────────────┼───────┼───────────────────────┤
│ server │ - no server │ │ │ │ │ dev server │
│ fable │ running │ 60009 │ │ │ 2m06s │ Fable watch │
│ vite │ running │ 60080 │ 5291 │ http://127.0.0.1:5291/ │ 1m57s │ Vite dev server │
│ prod │ - static app: see preview │ │ │ │ │ production server │
│ preview │ running │ 60389 │ 5290 │ http://127.0.0.1:5290/ │ 1m20s │ dist preview (static) │
╰───────────┴───────────────────────────┴───────┴──────┴────────────────────────┴───────┴───────────────────────╯
┌─log: fable (~/src/rpp-browser/.smoothdev/web/logs/fable.log)───────────────────────────────────────────────────┐
│ Project and references (12 source files) parsed in 6150ms │
│ Started Fable compilation... │
│ Fable compilation finished in 1598ms │
│ Watching .. │
└────────────────────────────────────────────────────────────────────────────────────────────────────────────────┘
d dev p prod v preview b dist o open l next log q quit (stops what it started) x quit, leave running
smoothdev-web gui serves the same view as a page on 127.0.0.1 (server-rendered HTML with plain
forms and a 2-second refresh: nothing to build, no script). GET /api/status returns the status as JSON.
The TUI and the GUI stop what they started when they exit; components started from the command line
keep running until stop.
Process tracking
- Each component is started with
posix_spawnas the leader of a new process group, stdin from/dev/null, stdout and stderr appended to its log file, signal dispositions reset (so a child does not inherit an ignoredSIGPIPE), and no other descriptor of the tool kept open, sosmoothdev-web dev start | tee start.logends when the command does. It survives the terminal that started it, and Ctrl+C in that terminal does not reach it. .smoothdev/web/state.jsonin the app root records name, PID, group, port, URL, log, command, start time and owner. Writes are atomic under a lock file. Entries whose processes are gone are dropped on the next read; a recorded PID only counts if it still leads its recorded group (PID reuse).stopsnapshots the process table, sendsSIGTERMto the group and to every descendant (also those that moved to another group), waits up to 8 s, thenSIGKILLs what is left. Zombies count as gone.- A component whose leader exited while other members of its group still run shows as
orphaned;stopends them.
Launch registry
Every invocation that starts something records a launch in ~/.smoothdev/launches/<id>.json
(SMOOTHDEV_HOME moves it): its PID, parent processes, SMOOTHDEV_WEB_LAUNCHER when the caller
sets it, user, folder, command, the ports it claimed with their role and URL, and the processes it
started. Ports are claimed under ~/.smoothdev/launches/.lock, skipping every port a live launch
holds, before anything binds them, so launches started at the same time, in one folder or several,
get distinct ports. A launch whose command and processes are gone moves to launches/ended/.
What a launch starts gets SMOOTHDEV_WEB_LAUNCH=<id>. launches, who <port> and the web console
read it. The chain of parent processes is read as smoothdev-web starts, and handed to what it
starts, so a launch run in the background or detached still names who started it. others and the
console's Other running instances card list the servers that no launch holds. Details: Launches and ports.
Limitations and roadmap
Works today: everything above, on macOS (tested with a static Fable + Vite app: dev, second instance on the next port, dist, preview, GUI actions, stop with nothing left running) and, by construction, Linux.
Not done yet:
- Windows: process groups are POSIX here; a Job Object based runner is the plan.
- Server path not exercised end to end yet:
dotnet watchandprod startfollow the contract above but were only tested on a static app so far; a sample client + server app in this repository is next. - Fable CLI + Vite proxy readiness:
dev startwaits for Fable's first "Watching" line; there is no health URL setting for servers that answer/with an error until warmed up (any HTTP answer counts). - Logs: plain files truncated at each start; no rotation, no merged timeline across components.
- TUI: no scrolling in the log pane; the GUI refreshes the whole page every 2 seconds.
- Config: JSON only (no TOML); no per-component extra arguments or environment yet.
- Packaging: not published to nuget.org yet.
dotnet fsi build.fsx -- installpacks and replaces the global tool fromartifacts/.
Development
dotnet fsi build.fsx -- check # restore, build, tests
dotnet fsi build.fsx -- pack # artifacts/smoothdev.web.<version>.nupkg
dotnet fsi build.fsx -- install # pack, then replace the global tool
House style: F# with 2-space indentation (.editorconfig), formatted by hand (vertical alignment of
record fields and tabular code; no whole-file formatter), dependencies through paket (paket.dependencies at the
repository root, paket.references per project), Expecto tests, one-shot commands through CliWrap
(long-running components use posix_spawn because they need their own process group), build steps
in a Partas.Build build.fsx.
Layout: src/SmoothDev.Web (Types model, Ports, Config, Posix, State, Runner,
StaticServer, Actions, Tui, Gui, Program), tests/SmoothDev.Web.Tests (port picking,
config parsing and detection, process tracking, static file resolution).
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
This package has no dependencies.
| Version | Downloads | Last Updated |
|---|---|---|
| 0.0.0.1-alpha.gf6c6e2c | 73 | 10/26/2026 |