diff --git a/Data/ByteString/Lazy/Util.hs b/Data/ByteString/Lazy/Util.hs
new file mode 100644
--- /dev/null
+++ b/Data/ByteString/Lazy/Util.hs
@@ -0,0 +1,89 @@
+---------------------------------------------------------
+-- |
+-- Module        : Data.ByteString.Lazy.Util
+-- Copyright     : Michael Snoyman
+-- License       : BSD3
+--
+-- Maintainer    : Michael Snoyman <michael@snoyman.com>
+-- Stability     : Unstable
+-- Portability   : portable
+--
+-- Various utilities to assist in dealing with lazy bytestrings.
+--
+---------------------------------------------------------
+
+module Data.ByteString.Lazy.Util
+    ( ord
+    , stripPrefix
+    , breakAt
+    , breakAtString
+    , takeLine
+    , chompBS
+    , takeUntilBlank
+    ) where
+
+import qualified Data.ByteString.Lazy as BS
+import qualified Data.Char as C
+import Data.Word (Word8)
+
+-- | Get the ASCII value of character. Differers from regular ord in that
+-- it returns an Integral, so it is automatically cast to eg a Word8.
+ord :: Integral a => Char -> a
+ord = fromInteger . toInteger . C.ord
+
+-- | Strip a prefix from a bytestring if it's there.
+stripPrefix :: Word8 -> BS.ByteString -> BS.ByteString
+stripPrefix p bs
+    | BS.null bs = bs
+    | BS.head bs == p = BS.tail bs
+    | otherwise = bs
+
+-- | Break a bytestring into two at the first occurence of the given 'Word8'.
+-- That 'Word8' should not appear in either piece.
+breakAt :: Word8 -> BS.ByteString -> (BS.ByteString, BS.ByteString)
+breakAt p bs =
+    let (x, y) = BS.span (/= p) bs
+        y' = stripPrefix p y
+     in (x, y')
+
+-- | Same as 'breakAt', but use a bytestring instead of a 'Word8'.
+breakAtString :: BS.ByteString
+              -> BS.ByteString
+              -> (BS.ByteString, BS.ByteString)
+breakAtString p c
+    | BS.null c = (BS.empty, BS.empty)
+    | p `BS.isPrefixOf` c = (BS.empty, BS.drop (BS.length p) c)
+    | otherwise =
+        let x = BS.head c
+            xs = BS.tail c
+            (next, rest) = breakAtString p xs
+        in (BS.cons' x next, rest)
+
+-- | Take a single line from a bytestring.
+takeLine :: BS.ByteString -> (BS.ByteString, BS.ByteString)
+takeLine bs =
+    let (x, y) = BS.span (/= ord '\n') bs
+        x' = if not (BS.null x) && BS.last x == ord '\r' then BS.init x else x
+        y' = if not (BS.null y) && BS.head y == ord '\n' then BS.tail y else y
+     in (x', y')
+
+-- | Removes newline characters from the end of a string.
+chompBS :: BS.ByteString -> BS.ByteString
+chompBS s
+    | BS.null s = s
+    | BS.last s == ord '\n' =
+        if BS.length s == 1 || BS.last (BS.init s) /= ord '\r'
+            then BS.init s
+            else BS.init (BS.init s)
+    | BS.last s == ord '\r' = BS.init s
+    | otherwise = s
+
+-- | Take each line until the first blank line and return as first.
+-- The rest of the content is returned as second.
+takeUntilBlank :: BS.ByteString -> ([BS.ByteString], BS.ByteString)
+takeUntilBlank bs =
+    let (next, rest) = takeLine bs
+     in if BS.null next
+            then ([], rest)
+            else let (nexts, rest') = takeUntilBlank rest
+                  in (next : nexts, rest')
diff --git a/Data/Mime/Header.hs b/Data/Mime/Header.hs
new file mode 100644
--- /dev/null
+++ b/Data/Mime/Header.hs
@@ -0,0 +1,54 @@
+---------------------------------------------------------
+-- |
+-- Module        : Data.Mime.Header
+-- Copyright     : Michael Snoyman
+-- License       : BSD3
+--
+-- Maintainer    : Michael Snoyman <michael@snoyman.com>
+-- Stability     : Unstable
+-- Portability   : portable
+--
+-- Functions for parsing MIME headers (Key: value; k1=v1; k2=v2)
+--
+---------------------------------------------------------
+
+module Data.Mime.Header
+    ( Header
+    , parseHeader
+    , lookupHeader
+    ) where
+
+import qualified Data.ByteString.Lazy.UTF8 as BSLU
+import Data.String.Util
+
+type SMap = [(String, String)]
+
+-- | A single MIME header.
+type Header = (String, String)
+
+-- | Parse a header line in the format
+-- Name: value; attkey=attval; attkey2=attval2.
+parseHeader :: BSLU.ByteString -> Header
+parseHeader bs =
+    let s = BSLU.toString bs
+        (k, rest) = span (/= ':') s
+     in (k, tail $ tail rest)
+
+{-
+lookupHeaderAttr :: Monad m => String -> String -> [Header] -> m String
+lookupHeaderAttr k1 k2 [] =
+    fail $ "Could not find header when looking for attr: " ++ 
+           k1 ++ ":" ++ k2
+lookupHeaderAttr k1 k2 ((key, _, vals):rest)
+    | k1 == key = case lookup k2 vals of
+                    Nothing -> fail $ "Could not find header attr "
+                                      ++ k1 ++ ":" ++ k2
+                    Just v -> return v
+    | otherwise = lookupHeaderAttr k1 k2 rest
+-}
+
+lookupHeader :: Monad m => String -> [Header] -> m String
+lookupHeader k [] = fail $ "Header " ++ k ++ " not found"
+lookupHeader k ((key, val):rest)
+    | k == key = return val
+    | otherwise = lookupHeader k rest
diff --git a/Data/String/Util.hs b/Data/String/Util.hs
new file mode 100644
--- /dev/null
+++ b/Data/String/Util.hs
@@ -0,0 +1,55 @@
+---------------------------------------------------------
+-- |
+-- Module        : Data.String.Util
+-- Copyright     : Michael Snoyman
+-- License       : BSD3
+--
+-- Maintainer    : Michael Snoyman <michael@snoyman.com>
+-- Stability     : Unstable
+-- Portability   : portable
+--
+-- Various utilities to assist in dealing with Strings.
+--
+---------------------------------------------------------
+
+module Data.String.Util
+    ( chomp
+    , dropPrefix
+    , dropQuotes
+    , splitList
+    ) where
+
+import Data.List (isPrefixOf)
+
+-- | Removes newline characters from the end of a string.
+chomp :: String -> String
+chomp s
+    | null s = s
+    | last s == '\n' =
+        if length s == 1 || last (init s) /= '\r'
+            then init s
+            else init (init s)
+    | last s == '\r' = init s
+    | otherwise = s
+
+-- | Drop a string from the beginning of another, if present.
+dropPrefix :: Eq a => [a] -> [a] -> [a]
+dropPrefix x y
+    | x `isPrefixOf` y = drop (length x) y
+    | otherwise = y
+
+-- | Drop surrounding quotes, if present.
+dropQuotes :: String -> String
+dropQuotes s
+    | length s > 2 && head s == '"' && last s == '"' = tail $ init s
+    | otherwise = s
+
+-- | Split up a list into sublists at every occurence of the split
+-- element. That element is thrown away.
+splitList :: Eq a => a -> [a] -> [[a]]
+splitList c s = helper s [[]] where
+    helper [] res = filter (not . null) $ reverse $ map reverse res
+    helper (x:xs) (y:ys)
+        | x == c = helper xs ([]:y:ys)
+        | otherwise = helper xs ((x:y):ys)
+    helper _ [] = error "This case should never be"
diff --git a/Hack/Handler/SimpleServer.hs b/Hack/Handler/SimpleServer.hs
new file mode 100644
--- /dev/null
+++ b/Hack/Handler/SimpleServer.hs
@@ -0,0 +1,141 @@
+---------------------------------------------------------
+-- |
+-- Module        : Hack.Handler.SimpleServer
+-- Copyright     : Michael Snoyman
+-- License       : BSD3
+--
+-- Maintainer    : Michael Snoyman <michael@snoyman.com>
+-- Stability     : Unstable
+-- Portability   : portable
+--
+-- A simplistic HTTP server handler for Hack.
+--
+---------------------------------------------------------
+module Hack.Handler.SimpleServer
+    ( run
+    ) where
+
+import Prelude ( ($), (++), map, IO, String, null, return
+               , length, (==), fail, break, tail, Monad, Int
+               , fromIntegral, head, (>=), fmap, (.), uncurry, words, show
+               , Read, reads)
+
+import Hack
+import Data.Default
+
+import Data.ByteString.Lazy.Util (takeUntilBlank)
+import Data.Mime.Header (parseHeader, lookupHeader)
+import Web.Encodings (decodeUrlPairs, decodeCookies, parsePost)
+
+import qualified Data.ByteString.Lazy as BS
+import qualified Data.ByteString.Lazy.UTF8 as BSLU
+import Network
+    ( listenOn, accept, sClose, PortID(PortNumber), Socket
+    , withSocketsDo)
+import Control.Exception (bracket, finally)
+import System.IO (Handle, hClose)
+import Control.Concurrent
+import Control.Monad (unless)
+import Data.Maybe (isJust, fromJust, fromMaybe)
+
+run :: Port -> Application -> IO ()
+run port = withSocketsDo .
+    bracket
+        (listenOn $ PortNumber $ fromIntegral port)
+        sClose .
+        serveConnections port
+type Port = Int
+
+serveConnections :: Port -> Application -> Socket -> IO ()
+serveConnections port app socket = do
+    (conn, _, _) <- accept socket
+    forkIO $ serveConnection port app conn
+    serveConnections port app socket
+
+serveConnection :: Port -> Application -> Handle -> IO ()
+serveConnection port app conn =
+    finally
+        serveConnection'
+        (hClose conn)
+    where
+        serveConnection' = do
+            env <- hParseEnv port conn
+            res <- app env
+            sendResponse conn res
+
+hParseEnv :: Port -> Handle -> IO Env
+hParseEnv port conn = do
+    content' <- BS.hGetContents conn
+    let (headers, body) = takeUntilBlank content'
+    parseEnv port headers body
+
+safeRead :: Read a => a -> String -> a
+safeRead d s =
+  case reads s of
+    ((x, _):_) -> x
+    [] -> d
+
+-- | Parse a set of header lines and body into a 'Env'.
+parseEnv :: Monad m => Port -> [BS.ByteString] -> BS.ByteString -> m Env
+parseEnv port lines' body = do
+    let lines = map BSLU.toString lines'
+    unless (length lines >= 2) $ fail "Invalid request (not enough lines)"
+    (method', rpath', gets) <- parseFirst $ head lines
+    let method = safeRead GET method'
+    let rpath = '/' : case rpath' of
+                        ('/':x) -> x
+                        _ -> rpath'
+    let heads = map parseHeader $ tail lines'
+    let host' = lookupHeader "Host" heads
+    unless (isJust host') $ fail "Invalid request (does not include host)"
+    let host = fromJust host'
+    let len = fromMaybe "0" $ lookupHeader "Content-Length" heads
+    let body' = BS.take (safeRead 0 len) body
+    return $ def
+                { requestMethod = method
+                , pathInfo = rpath
+                , queryString = gets
+                , serverName = host
+                , serverPort = port
+                , http = heads
+                , hackInput = body'
+                }
+
+parseFirst :: Monad m =>
+              String
+           -> m (String, String, String)
+parseFirst s = do
+    let pieces = words s
+    unless (length pieces == 3) $ fail "Invalid request (bad first line)"
+    let [method, query, http] = pieces
+    unless (http == "HTTP/1.1") $
+        fail "Invalid request (only handle HTTP/1.1)"
+    let (rpath, qstring) = break (== '?') query
+    return (method, rpath, qstring)
+
+bsFromResponse :: Response -> BS.ByteString
+bsFromResponse res = BS.concat
+    [ f "HTTP/1.1 "
+    , f $ show $ status res
+    , f "\r\n"
+    , BS.concat $ map (\(x, y) -> BS.concat
+        [ f x
+        , f ": "
+        , f y
+        , f "\r\n"
+        ]) $ headers res
+    , f "\r\n"
+    , body res
+    ] where f = BSLU.fromString
+{-
+    hPutStr conn $ "HTTP/1.1 " ++ code res ++ "\r\n"
+    hPutStr conn $ "Content-type: " ++ contentType res ++ "\r\n"
+    let headers' = map (\(x, y) -> x ++ ": " ++ y ++ "\r\n") $ headers res
+    hPutStr conn $ concat headers'
+    hPutStr conn "\r\n"
+    BS.hPutStr conn $ content res
+-}
+
+sendResponse :: Handle -> Response -> IO ()
+sendResponse conn = do
+    BS.hPut conn . bsFromResponse
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,25 @@
+The following license covers this documentation, and the source code, except
+where otherwise indicated.
+
+Copyright 2008, Michael Snoyman. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+* Redistributions of source code must retain the above copyright notice, this
+  list of conditions and the following disclaimer.
+
+* Redistributions in binary form must reproduce the above copyright notice,
+  this list of conditions and the following disclaimer in the documentation
+  and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS "AS IS" AND ANY EXPRESS OR
+IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
+MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
+EVENT SHALL THE COPYRIGHT HOLDERS BE LIABLE FOR ANY DIRECT, INDIRECT,
+INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT
+NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
+OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
+OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF
+ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
diff --git a/Setup.lhs b/Setup.lhs
new file mode 100644
--- /dev/null
+++ b/Setup.lhs
@@ -0,0 +1,7 @@
+#!/usr/bin/env runhaskell
+
+> module Main where
+> import Distribution.Simple
+
+> main :: IO ()
+> main = defaultMain
diff --git a/hack-handler-simpleserver.cabal b/hack-handler-simpleserver.cabal
new file mode 100644
--- /dev/null
+++ b/hack-handler-simpleserver.cabal
@@ -0,0 +1,27 @@
+name:            hack-handler-simpleserver
+version:         0.0.0
+license:         BSD3
+license-file:    LICENSE
+author:          Michael Snoyman <michael@snoyman.com>
+maintainer:      Michael Snoyman <michael@snoyman.com>
+synopsis:        A simplistic HTTP server handler for Hack.
+description:     FIXME
+category:        Web
+stability:       unstable
+cabal-version:   >= 1.2
+build-type:      Simple
+homepage:        http://github.com/snoyberg/hack-handler-simpleserver/tree/master
+
+library
+    build-depends:   base >= 4 && < 5,
+                     network,
+                     hack >= 2009.5.19,
+                     utf8-string,
+                     bytestring >= 0.9.1.4,
+                     web-encodings,
+                     data-default >= 0.2
+    exposed-modules: Hack.Handler.SimpleServer
+    other-modules:   Data.Mime.Header,
+                     Data.ByteString.Lazy.Util,
+                     Data.String.Util
+    ghc-options:     -Wall
