diff --git a/Data/Warc.hs b/Data/Warc.hs
new file mode 100644
--- /dev/null
+++ b/Data/Warc.hs
@@ -0,0 +1,68 @@
+{-# LANGUAGE RankNTypes #-}
+
+module Data.Warc
+    ( Record(..)
+    , Warc(..)
+    , parseWarc
+    , iterRecords
+    ) where
+
+import Data.Char (ord)
+import Pipes (Producer, yield)
+import qualified Pipes.ByteString as PBS
+import Control.Lens
+import qualified Pipes.Attoparsec as PA
+import qualified Data.ByteString as BS
+import Data.ByteString (ByteString)
+import Control.Monad (void)
+import Control.Monad.Trans.Class
+import Control.Monad.Trans.Either
+import Control.Monad.Trans.Free
+import Control.Monad.Trans.State.Strict
+import Control.Error
+
+import Data.Warc.Header
+
+-- | A WARC record
+data Record m r = Record { recWarcVersion :: Version
+                         , recHeader      :: [Field]
+                         , recContent     :: Producer BS.ByteString m r
+                         }
+
+instance Monad m => Functor (Record m) where
+    fmap f (Record ver hdr r) = Record ver hdr (fmap f r)
+
+-- | A WARC archive
+data Warc m = Warc (FreeT (Record m) m (Producer BS.ByteString m ()))
+
+instance Monad m => Monoid (Warc m) where
+    Warc a `mappend` Warc b = Warc (a >> b)
+    mempty = Warc (return (return ()))
+
+parseWarc :: (Functor m, Monad m) => Producer ByteString m () -> Warc m
+parseWarc = Warc . loop
+  where
+    loop upstream = FreeT $ do
+        (hdr, rest) <- runStateT (PA.parse header) upstream
+        go hdr rest
+
+    go hdr rest
+      | Nothing <- hdr                    = return $ Pure rest
+      | Just (Left err) <- hdr            = error $ show err
+      | Just (Right (ver, fields)) <- hdr = do
+            let [len] = toListOf (each . _ContentLength) fields
+            let produceBody = fmap consumeWhitespace . view (PBS.splitAt len)
+                consumeWhitespace = PBS.dropWhile isEOL
+                isEOL c = c == ord8 '\r' || c == ord8 '\n'
+                ord8 = fromIntegral . ord
+            return $ Free $ Record ver fields $ fmap loop $ produceBody rest
+
+iterRecords :: Monad m => (forall a. Record m a -> m a) -> Warc m -> m (Producer BS.ByteString m ())
+iterRecords f (Warc free) = go free
+  where
+    go (FreeT action) = action >>= \next -> do
+        case next of
+          Pure a -> return a
+          Free record -> do
+              rest <- f record
+              go rest
diff --git a/Data/Warc/Header.hs b/Data/Warc/Header.hs
new file mode 100644
--- /dev/null
+++ b/Data/Warc/Header.hs
@@ -0,0 +1,241 @@
+{-# LANGUAGE OverloadedStrings #-}
+{-# LANGUAGE TemplateHaskell #-}
+
+module Data.Warc.Header
+    ( Version(..)
+    , WarcType (..)
+    , RecordId (..)
+    , TruncationReason (..)
+    , Digest (..)
+    , header
+      -- * Header field types
+    , Field (..)
+      -- ** Prisms
+    , _WarcRecordId
+    , _ContentLength
+    , _WarcDate
+    , _WarcType
+    , _ContentType
+    , _WarcConcurrentTo
+    , _WarcBlockDigest
+    , _WarcPayloadDigest
+    , _WarcIpAddress
+    , _WarcRefersTo
+    , _WarcTargetUri
+    , _WarcTruncated
+    , _WarcWarcinfoId
+    , _WarcFilename
+    , _WarcProfile
+    , _WarcIdentifiedPayloadType
+    , _WarcSegmentNumber
+    , _WarcSegmentOriginId
+    , _WarcSegmentTotalLength
+    ) where
+
+import Control.Applicative
+import Control.Monad (void)
+import Data.Maybe (catMaybes)
+import Data.Monoid ((<>))
+import Data.Time.Clock
+import Data.Time.Format
+import Data.Char (ord)
+
+import Data.Attoparsec.ByteString.Char8
+import Data.Text (Text)
+import qualified Data.Text as T
+import qualified Data.Text.Encoding as TE
+import qualified Data.Text.Lazy as TL
+import Data.ByteString.Char8 (ByteString)
+import qualified Data.ByteString.Char8 as BS
+
+import Control.Lens
+
+withName :: String -> Parser a -> Parser a
+withName name parser = parser <?> name
+
+data Version = Version {versionMajor, versionMinor :: !Int}
+             deriving (Show, Read, Eq, Ord)
+
+version :: Parser Version
+version = withName "version" $ do
+    "WARC/"
+    major <- decimal
+    char '.'
+    minor <- decimal
+    return (Version major minor)
+
+newtype FieldName = FieldName {getFieldName :: Text}
+                  deriving (Show, Read)
+
+instance Eq FieldName where
+    FieldName a == FieldName b = T.toCaseFold a == T.toCaseFold b
+
+instance Ord FieldName where
+    FieldName a `compare` FieldName b = T.toCaseFold a `compare` T.toCaseFold b
+
+separators :: String
+separators = "()<>@,;:\\\"/[]?={}"
+
+crlf :: Parser ()
+crlf = void $ string "\r\n"
+
+token :: Parser ByteString
+token = takeTill (inClass $ separators++" \t\n\r")
+
+utf8Token :: Parser Text
+utf8Token = TE.decodeUtf8 <$> token
+
+fieldName :: Parser FieldName
+fieldName = FieldName . TE.decodeUtf8 <$> token
+
+ord' = fromIntegral . ord
+
+text :: Parser Text
+text = do
+    let content :: TL.Text -> Parser TL.Text
+        content accum = do
+            satisfy (isHorizontalSpace . ord')
+            c <- takeTill (isEndOfLine . ord')
+            continuation (accum <> TL.fromStrict (TE.decodeUtf8 c))
+        continuation :: TL.Text -> Parser TL.Text
+        continuation accum = content accum <|> return accum
+    firstLine <- takeTill (isEndOfLine . ord')
+    TL.toStrict <$> continuation (TL.fromStrict $ TE.decodeUtf8 firstLine)
+
+quotedString :: Parser Text
+quotedString = do
+    char '"'
+    c <- TE.decodeUtf8 <$> takeTill (== '"')
+    char '"'
+    return c
+
+field :: Parser name -> Parser a -> Parser a
+field name content = do
+    try name
+    char ':'
+    skipSpace
+    content <* endOfLine
+
+data WarcType = WarcInfo
+              | Response
+              | Resource
+              | Request
+              | Metadata
+              | Revisit
+              | Conversion
+              | Continuation
+              | FutureType !Text
+              deriving (Show, Read, Ord, Eq)
+
+warcType :: Parser WarcType
+warcType = choice
+     [ "warcinfo"     *> pure WarcInfo
+     , "response"     *> pure Response
+     , "resource"     *> pure Resource
+     , "request"      *> pure Request
+     , "metadata"     *> pure Metadata
+     , "revisit"      *> pure Revisit
+     , "conversion"   *> pure Conversion
+     , "continuation" *> pure Continuation
+     , FutureType <$> utf8Token
+     ]
+     
+newtype RecordId = RecordId ByteString
+                 deriving (Show, Read, Eq, Ord)
+
+uri :: Parser ByteString
+uri = do
+    char '<'
+    s <- takeTill (== '>')
+    char '>'
+    return s
+
+recordId :: Parser RecordId
+recordId = RecordId <$> uri
+
+data TruncationReason = TruncLength
+                      | TruncTime
+                      | TruncDisconnect
+                      | TruncUnspecified
+                      | TruncOther !Text
+                      deriving (Show, Read, Ord, Eq)
+
+truncationReason :: Parser TruncationReason
+truncationReason = choice
+    [ "length" *> pure TruncLength
+    , "time"   *> pure TruncTime
+    , "disconnect" *> pure TruncDisconnect
+    , "unspecified" *> pure TruncUnspecified
+    , TruncOther <$> utf8Token
+    ]
+
+data Digest = Digest { digestAlgorithm, digestHash :: !ByteString }
+            deriving (Show, Read, Eq, Ord)
+
+digest :: Parser Digest
+digest = do
+    algo <- token <* char ':'
+    hash <- token
+    return $ Digest algo hash
+
+data Field = WarcRecordId !RecordId
+           | ContentLength !Integer
+           | WarcDate !UTCTime
+           | WarcType !WarcType
+           | ContentType !ByteString
+           | WarcConcurrentTo [RecordId]
+           | WarcBlockDigest !Digest
+           | WarcPayloadDigest !Digest
+           | WarcIpAddress !ByteString
+           | WarcRefersTo !ByteString
+           | WarcTargetUri !ByteString
+           | WarcTruncated !TruncationReason
+           | WarcWarcinfoId !RecordId
+           | WarcFilename !Text
+           | WarcProfile !ByteString
+           | WarcIdentifiedPayloadType !ByteString
+           | WarcSegmentNumber !Integer
+           | WarcSegmentOriginId !ByteString
+           | WarcSegmentTotalLength !Integer
+           deriving (Show, Read)
+
+makePrisms ''Field
+
+date :: Parser UTCTime
+date = do
+    s <- takeTill isSpace
+    parseTimeM False defaultTimeLocale fmt (BS.unpack s)
+  where fmt = iso8601DateFormat (Just "%H:%M:%SZ")
+           
+warcField :: Parser Field
+warcField = choice
+    [ field "WARC-Record-ID" (WarcRecordId <$> recordId)
+    , field "Content-Length" (ContentLength <$> decimal)
+    , field "WARC-Date" (WarcDate <$> date)
+    , field "WARC-Type" (WarcType <$> warcType)
+    , field "Content-Type" (ContentType <$> takeTill (isEndOfLine . ord'))
+    , field "WARC-Concurrent-To" (WarcConcurrentTo <$> many1 recordId)
+    , field "WARC-Block-Digest" (WarcBlockDigest <$> digest)
+    , field "WARC-Payload-Digest" (WarcPayloadDigest <$> digest)
+    , field "WARC-IP-Address" (WarcIpAddress <$> takeTill (isEndOfLine . ord'))
+    , field "WARC-Refers-To" (WarcRefersTo <$> uri)
+    , field "WARC-Target-URI" (WarcTargetUri <$> takeTill (isEndOfLine . ord'))
+    , field "WARC-Truncated" (WarcTruncated <$> truncationReason)
+    , field "WARC-Warcinfo-ID" (WarcWarcinfoId <$> recordId)
+    , field "WARC-Filename" (WarcFilename <$> (text <|> quotedString))
+    , field "WARC-Profile" (WarcProfile <$> uri)
+    -- , field "WARC-Identified-Payload-Type" (WarcIdentifiedPayloadType <$> mediaType)
+    , field "WARC-Segment-Number" (WarcSegmentNumber <$> decimal)
+    --, field "WARC-Segment-Origin-ID" (WarcSegmentOriginId <$> msgId)
+    , field "WARC-Segment-Total-Length" (WarcSegmentTotalLength <$> decimal)
+    ]
+
+-- | A WARC header
+header :: Parser (Version, [Field])
+header = withName "header" $ do
+    skipSpace
+    ver <- version <* endOfLine
+    let unknownField = field token (takeTill (isEndOfLine . ord') *> return Nothing)
+    fields <- withName "fields" $ many $ (Just <$> warcField) <|> unknownField
+    endOfLine
+    return (ver, catMaybes fields)
diff --git a/LICENSE b/LICENSE
new file mode 100644
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,30 @@
+Copyright (c) 2015, Ben Gamari
+
+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.
+
+    * Neither the name of Ben Gamari nor the names of other
+      contributors may be used to endorse or promote products derived
+      from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"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
+OWNER OR CONTRIBUTORS 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.hs b/Setup.hs
new file mode 100644
--- /dev/null
+++ b/Setup.hs
@@ -0,0 +1,2 @@
+import Distribution.Simple
+main = defaultMain
diff --git a/warc.cabal b/warc.cabal
new file mode 100644
--- /dev/null
+++ b/warc.cabal
@@ -0,0 +1,35 @@
+name:                warc
+version:             0.1.1.1
+synopsis:            A parser for the Web Archive (WARC) format
+description:         A streaming parser for the Web Archive (WARC) format.
+homepage:            http://github.com/bgamari/warc
+license:             BSD3
+license-file:        LICENSE
+author:              Ben Gamari
+maintainer:          ben@smart-cactus.org
+copyright:           (c) 2015 Ben Gamari
+category:            Data
+build-type:          Simple
+cabal-version:       >=1.10
+
+source-repository head
+  type:                git
+  location:            git://github.com/bgamari/warc
+
+library
+  exposed-modules:     Data.Warc, Data.Warc.Header
+  other-extensions:    RankNTypes, OverloadedStrings, TemplateHaskell
+  build-depends:       base >=4.8 && <4.9,
+                       pipes >=4.1 && <4.2,
+                       attoparsec >=0.12 && <0.14,
+                       bytestring >=0.10 && <0.11,
+                       pipes-bytestring >=2.1 && <2.2,
+                       transformers >=0.4 && <0.5,
+                       lens >=4.7 && <4.13,
+                       pipes-attoparsec >=0.5 && <0.6,
+                       either >=4.3 && <4.5,
+                       free >=4.10 && <4.13,
+                       errors >=1.4 && <3.0,
+                       time >=1.5 && <1.6,
+                       text >=1.2 && <1.3
+  default-language:    Haskell2010
