Skip to content

Instantly share code, notes, and snippets.

@jonas1ara
Last active March 10, 2026 07:27
Show Gist options
  • Select an option

  • Save jonas1ara/58c88c96e010f3ea16af07d26a9b4696 to your computer and use it in GitHub Desktop.

Select an option

Save jonas1ara/58c88c96e010f3ea16af07d26a9b4696 to your computer and use it in GitHub Desktop.
Simple web server in F#
open System.Net
open System.Net.Sockets
open System.IO
open System.Text.RegularExpressions
open System.Text
/// A table of MIME content types.
let mimeTypes =
dict [".html", "text/html";
".htm", "text/html";
".txt", "text/plain";
".gif", "image/gif";
".jpg", "image/jpeg";
".png", "image/png"]
/// Compute a MIME type from a file extension.
let getMimeType(ext) =
if mimeTypes.ContainsKey(ext) then mimeTypes.[ext]
else "binary/octet"
/// The pattern Regex1 uses a regular expression to match one element.
let (|Regex1|_|) (patt : string) (inp : string) =
try Some(Regex.Match(inp, patt).Groups.Item(1).Captures.Item(0).Value)
with _ -> None
/// The root for the data we serve
let root = @"~/wsf/wwwroot"
/// Handle a TCP connection for an HTTP GET. We use an asynchronous task in
/// case any future actions in handling a request need to be asynchronous.
let handleRequest(client : TcpClient) = async {
use stream = client.GetStream()
let out = new StreamWriter(stream)
let headers (lines : seq<string>) =
let printLine s = s |> fprintf out "%s\r\n"
lines |> Seq.iter printLine
// An empty line is required before content, if any.
printLine ""
out.Flush()
let notFound () = headers ["HTTP/1.0 404 Not Found"]
let inp = new StreamReader(stream)
let request = inp.ReadLine()
match request with
| "GET / HTTP/1.0" | "GET / HTTP/1.1" ->
// From the root, redirect to the start page.
headers ["HTTP/1.0 302 Found"; "Location: http://localhost:8090/iisstart.htm"]
| Regex1 "GET /(.*?) HTTP/1\\.[01]$" fileName ->
let fname = Path.Combine(root, fileName)
let mimeType = getMimeType(Path.GetExtension(fname))
if not(File.Exists(fname)) then notFound()
else
let content = File.ReadAllBytes fname
["HTTP/1.0 200 OK";
sprintf "Content-Length: %d" content.Length;
sprintf "Content-Type: %s" mimeType]
|> headers
stream.Write(content, 0, content.Length)
| _ -> notFound()}
/// The server as an asynchronous process. We handle requests sequentially.
let server = async {
let socket = new TcpListener(IPAddress.Parse("127.0.0.1"), 8090)
socket.Start()
while true do
use client = socket.AcceptTcpClient()
do! handleRequest client}
Async.RunSynchronously server
@jonas1ara
Copy link
Author

jonas1ara commented Mar 6, 2026

WebServer.fsx

A minimal HTTP/1.x web server implemented in a single F# script — no frameworks, no NuGet packages, just raw sockets and the .NET standard library.

Ws

dotnet fsi WebServer.fsx


What it does

WebServer.fsx listens on 127.0.0.1:8090 and handles HTTP GET requests by serving static files from a configurable root directory. It uses F#'s async workflows to keep the connection handling non-blocking and composable.

Features

  • Raw TCP socket via TcpListener — no HttpListener, no ASP.NET
  • Static file serving with MIME type detection
  • Root redirect (//iisstart.htm) via HTTP 302
  • 404 Not Found for missing files or unrecognised requests
  • Async workflow (async { }) for the server loop and each request handler
  • Active pattern (Regex1) for clean, declarative URL parsing

How it works

Architecture

TcpListener (port 8090)
    └── AcceptTcpClient()          ← blocks until a connection arrives
         └── handleRequest(client) ← async { } workflow per connection
              ├── Read request line from StreamReader
              ├── Match against known patterns
              │    ├── "GET / HTTP/1.x"    → 302 redirect to iisstart.htm
              │    ├── "GET /<file> HTTP/1.x" → serve file from root dir
              │    └── anything else        → 404 Not Found
              └── Write HTTP response headers + body to StreamWriter/stream

Key pieces

Element Purpose
mimeTypes Dictionary mapping file extensions to MIME strings
getMimeType Looks up MIME type, defaults to binary/octet
`( Regex1
root Base directory from which files are served
handleRequest Async function that reads one HTTP request and writes the response
server Infinite async loop that accepts TCP clients sequentially

Request flow

Client           WebServer.fsx
  │── GET /index.html HTTP/1.1 ──▶│
  │                                │  resolve root + "index.html"
  │                                │  read bytes from disk
  │◀── HTTP/1.0 200 OK ────────────│
  │◀── Content-Length: 1234 ───────│
  │◀── Content-Type: text/html ────│
  │◀── (blank line) ───────────────│
  │◀── <file bytes> ───────────────│

Running it

Prerequisites

  • .NET SDK (any modern version — 6, 7, 8, or 9)

1. Configure the root directory

Open WebServer.fsx and update the root value to point to the folder containing your static files:

let root = @"C:\path\to\your\wwwroot"

On Linux/macOS use a forward-slash path: let root = "/home/user/wwwroot"

2. Start the server

dotnet fsi WebServer.fsx

The process will block — that's the server running. Open your browser and navigate to:

http://localhost:8090/

3. Checking if the server is running

PowerShell / Windows:

netstat -ano | findstr 8090

Bash / Unix (Linux & macOS):

netstat -ano | grep 8090

Both commands show any active TCP connections or listeners on port 8090. If the server is up, you will see a line with LISTENING (Windows) or LISTEN (Unix) and the process ID.


Serving an HTML file

Place any .html, .htm, .txt, .jpg, .png, or .gif file in the root directory you configured. For example:

wwwroot/hello.html

<!DOCTYPE html>
<html>
  <head><title>Hello</title></head>
  <body><h1>Hello from F#!</h1></body>
</html>

Then browse to:

http://localhost:8090/hello.html

The root redirect (/) always points to iisstart.htm — rename your default page to that, or change the redirect in handleRequest:

| "GET / HTTP/1.0" | "GET / HTTP/1.1" ->
    headers ["HTTP/1.0 302 Found"; "Location: http://localhost:8090/hello.html"]

Testing with curl

# Request an existing file
curl -v http://localhost:8090/hello.html

# Trigger the root redirect
curl -v http://localhost:8090/

# Request a missing file (expect 404)
curl -v http://localhost:8090/doesnotexist.html

Supported MIME types

Extension MIME type
.html, .htm text/html
.txt text/plain
.gif image/gif
.jpg image/jpeg
.png image/png
(anything else) binary/octet

Limitations

  • Handles one request at a time (sequential loop — no parallel handling)
  • Only GET is supported; POST, HEAD, etc. return 404
  • No TLS/HTTPS
  • No query string parsing
  • Listens only on 127.0.0.1 (localhost)

These are intentional — the goal is clarity, not production use.


Credits

The design of this web server is based on an example from Expert F# by Don Syme, Adam Granicz, and Antonio Cisternino. All credit for the original architecture goes to those authors.


Great for learning

  • How HTTP really works at the TCP level
  • F# async workflows and use resource management
  • Active patterns for expressive pattern matching
  • Building protocols without any framework magic

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment