What is a lightweight way to stream "server-sent events (SSE) style" events to the front-end in F#, using the System.Net.Http library? I understand the Event stream format (e.g. this PHP example code), but I am seeking some guidance to implement the streaming part in a server-side F# application (I'm on .Net Framework 4.8).
You could use Suave. The below example sends a message every second (using SSE) I haven't tried it in .net 47 ( I tried in .net 5 in Mac) but it should work.
open Suave
open Suave.Sockets
open Suave.Sockets.Control
open Suave.EventSource
open Suave.Operators
open Suave.Filters
let write i out =
socket {
let msg = { id = string i; data = string i; ``type`` = None }
do! msg |> send out
return! SocketOp.ofAsync (Async.Sleep 1000)
}
let app =
choose [
GET >=> request (fun _ ->
handShake (fun out ->
socket {
let actions =
Seq.initInfinite (fun n -> n + 1)
|> Seq.map (fun i -> write i out)
for a in actions do
do! a
return out
}))
[<EntryPoint>]
let main _ =
startWebServer defaultConfig app
0
The following minimalistic, rudimentary code works (OS: Windows10, Browser: Google Chrome 92.0.4515, .Net Framework 4.8) :
F# client-side code:
module SSE0 =
open System
open System.IO
open System.Net
let pipeUTF8 (data: string) (sink: Stream) : Async<unit> = async {
let bytes = System.Text.Encoding.UTF8.GetBytes data
use src = new MemoryStream(bytes)
do! src.CopyToAsync(sink) |> Async.AwaitTask }
let private (=>=) data sink = pipeUTF8 data sink
type Msg = { id: string; event: string; data: string } with
member this.send (sink: Stream) : Async<unit> = async {
do! (sprintf "id:%s\n" this.id) =>= sink
do! (sprintf "event:%s\n" this.event) =>= sink
do! (sprintf "data:%s\n\n" this.data) =>= sink // Only works for single-line data payloads (won't work if eol included)
do! " \n" =>= sink
do! Async.Sleep 1000 // only for this basic example
Console.WriteLine(sprintf "id: %s, event: %s, data: %s" this.id this.event this.data)
do! sink.FlushAsync() |> Async.AwaitTask}
let sse_count (ctx : HttpListenerContext) : Async<unit> =
let output = ctx.Response.OutputStream
let message (i: int) : Msg = { id = sprintf "id#%02d" i; event = "message"; data = sprintf "data#%02d" i }
let msgs = seq { for i in 0 .. 59 -> let msg = message i in async { do! msg.send output } }
msgs |> Async.Sequential |> Async.Ignore
let startServer (url: string) (handler: HttpListenerContext -> Async<unit>) (cts: Threading.CancellationTokenSource) : Threading.CancellationTokenSource =
let task = async {
use listener = new HttpListener()
listener.Prefixes.Add(url)
listener.Start()
while true do
let! context = listener.GetContextAsync() |> Async.AwaitTask
let resp = context.Response
[ ("Content-Type", "text/event-stream; charset=utf-8")
; ("Cache-Control", "no-cache")
; ("Access-Control-Allow-Origin", "*") ] // or Access-Control-Allow-Origin: http://localhost:3000
|> List.iter(fun (k, v) -> resp.AddHeader(k, v))
Async.Start (handler context, cts.Token)
}
Async.Start (task, cts.Token)
cts
[<EntryPoint>]
let main argv =
let cts' = defaultArg None <| new Threading.CancellationTokenSource()
Console.WriteLine("Press return to start.")
Console.ReadLine() |> ignore
Console.WriteLine("Running...")
let cts = startServer "http://localhost:8080/events/" sse_count cts'
Console.WriteLine("Press return to exit.")
Console.ReadLine() |> ignore
cts.Cancel()
0
html document:
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>SSE test</title>
</head>
<body>
<button id="btn">Close the connection</button>
<ul id="msglist"></ul>
<script>
var es = new EventSource("http://localhost:8080/events/");
es.onopen = function() {
console.log("Connection to server opened.");
};
var msgList = document.getElementById("msglist");
es.onmessage = function(e) {
console.log("type: " + e.type + ", id: " + e.lastEventId + ", data: " + e.data);
var newElement = document.createElement("li");
newElement.textContent = "type: " + e.type + ", id: " + e.lastEventId + ", data: " + e.data;
msgList.appendChild(newElement);
};
var btn = document.getElementById("btn");
btn.onclick = function() {
console.log("Connection closed");
es.close();
}
es.onerror = function(e) {
console.log("Error found.");
};
</script>
</body>
The following resources were useful to get this done :
https://github.com/mdn/dom-examples/tree/master/server-sent-events
https://github.com/haf/FSharp.EventSource
Related
I am quite new to using WebSharper and I might be doing things the wrong way.
My goal is to be able to update the contents of my page as a result of user actions by updating a Var<Doc> variable representing a portion of the page to be updated. I'd be happy to know if I could update a Var<Doc> from server-side code and have it reflect in the user's browser.
Below is a quick example:
let TestPage ctx =
let clientPart = Var.Create <| Doc.Empty
clientPart .Value <- div [] [ text "This content is dynamically inserted" ]
Templating.Main ctx EndPoint.Home "Home" [
h1 [] [text "Below is a dynamically inserted content:"]
div [] [ client <# clientPart .View |> Doc.EmbedView #> ]
]
The error I receive is:
System.Exception: Error during RPC JSON conversion ---> System.Exception: Failed to look up translated field name for write' in type WebSharper.UI.Elt with fields: docNode, elt, rvUpdates, updates
The WebSharper 4 documentation regarding Views also states:
It will only be run while the resulting View is included in the document using one of these methods:
Doc.BindView
Doc.EmbedView
textView
and etc.
A similar error is produced if I try this instead:
type SomeTemplate = Template<"SomeTemplate.html">
clientDoc.Value <- SomeTemplate().Doc()
In the above code, Templating.Main is the same as in the default WebSharper project:
module Templating =
...
let Main ctx action (title: string) (body: Doc list) =
let t = MainTemplate().Title(title).MenuBar(MenuBar ctx action).With("Body", body)
let doc : Doc = t.Doc()
doc |> Content.Page
Here is an example calling an RPC on the server side and storing it into a client Var<>:
module ServerFunctions =
let mutable ServerState = ("Zero", 0)
let [< Rpc >] addToState n = async {
let state, counter = ServerState
let newCounter = counter + n
let newState = if newCounter = 0 then "Zero" else "NonZero"
ServerState <- newState, newCounter
return newState
}
[< JavaScript >]
module ClientFunctions =
open WebSharper
open WebSharper.UI
open WebSharper.UI.Html
open ServerFunctions
let zeroState = Var.Create "do not know"
let clientDoc() =
div [] [
h1 [] [ text "State of zero on server:" ]
h2 [] [ text zeroState.V ]
Doc.Button "increment" [] (fun () -> async { let! state = addToState 1
zeroState.Set state
} |> Async.Start)
Doc.Button "decrement" [] (fun () -> async { let! state = addToState -1
zeroState.Set state
} |> Async.Start)
]
module Server =
open global.Owin
open Microsoft.Owin.Hosting
open Microsoft.Owin.StaticFiles
open Microsoft.Owin.FileSystems
open WebSharper.Owin
open WebSharper.UI.Server
open WebSharper.UI.Html
type EndPointServer =
| [< EndPoint "/" >] Hello
| About
let url = "http://localhost:9006/"
let rootdir = #"..\website"
let site() = WebSharper.Application.MultiPage(fun context (s:EndPointServer) ->
printfn "Serving page: %A" s
Content.Page(
Title= ( sprintf "Test %A" s)
, Body = [ h1 [] [ text <| sprintf "%A" s ]
Html.client <# ClientFunctions.clientDoc() #> ])
)
How do I get items from an RSS feed using .Net Core?
The following code doesn't appear to work:
open Microsoft.SyndicationFeed
open Microsoft.SyndicationFeed.Rss
[<Test>]
let ``Get links from iTunes RSS Feed`` () =
let url = "http://www.pwop.com/feed.aspx?show=dotnetrocks&filetype=master&tags=F%23"
use reader = XmlReader.Create(url)
let feedReader = RssFeedReader(reader)
let mutable linkTemplate = {
Title= ""
Url= ""
}
let links =
async {
let links = Collections.Generic.List<Link>()
while feedReader.Read() |> Async.AwaitTask |> Async.RunSynchronously do
match feedReader.ElementType with
| SyndicationElementType.Link ->
let item = feedReader.ReadLink() |> Async.AwaitTask |> Async.RunSynchronously
let link = { linkTemplate with Title= item.Title; Url= item.Uri.AbsolutePath }
links.Add(link)
| _ -> ()
return links
} |> Async.RunSynchronously
reader.Close()
System.Diagnostics.Debug.WriteLine(links.[0].Title)
links.[0].Title |> should not' (equal "")
Specifically, items are read but there's no actual data after the read.
I used the XElement class as recommended:
[<Test>]
let ``Get links from iTunes RSS Feed`` () =
let toLink (item:XElement) = {
Id = -1
ProfileId = "to be derived..."
Title= item.Element(XName.Get("title")) |> string
Url= item.Element(XName.Get("link")) |> string
Description = item.Element(XName.Get("description")) |> string
ContentType= Podcast |> contentTypeToString
Topics = []
IsFeatured= false
}
let baseAddress = "http://www.pwop.com/"
let url = "feed.aspx?show=dotnetrocks&filetype=master&tags=F%23"
use client = httpClient baseAddress
let response = client.GetAsync(url) |> Async.AwaitTask
|> Async.RunSynchronously
let links =
if response.IsSuccessStatusCode
then let text = response.Content.ReadAsStringAsync() |> Async.AwaitTask |> Async.RunSynchronously
XElement.Parse(text).Descendants(XName.Get("item")) |> Seq.toList |> List.map toLink
else []
links |> List.isEmpty |> should equal false
I have a piece of code that adds a row to a database when a MailboxProcessor receives a message. It works correctly when run in fsi, but it hangs when compiled to an exe. The script is as follows:
#r "../packages/Newtonsoft.Json/lib/net40/Newtonsoft.Json.dll"
#r "../packages/SQLProvider/lib/FSharp.Data.SqlProvider.dll"
open Newtonsoft.Json
open FSharp.Data.Sql
open System
let [<Literal>] ResolutionPath = __SOURCE_DIRECTORY__ + "/../build/"
let [<Literal>] ConnectionString = "Data Source=" + __SOURCE_DIRECTORY__ + #"/test.db;Version=3"
// test.db is initialized as follows:
//
// BEGIN TRANSACTION;
// CREATE TABLE "Events" (
// `id`INTEGER PRIMARY KEY AUTOINCREMENT,
// `timestamp` DATETIME NOT NULL
// );
// COMMIT;
type Sql = SqlDataProvider<
ConnectionString = ConnectionString,
DatabaseVendor = Common.DatabaseProviderTypes.SQLITE,
ResolutionPath = ResolutionPath,
IndividualsAmount = 1000,
UseOptionTypes = true >
let ctx = Sql.GetDataContext()
let agent = MailboxProcessor.Start(fun (inbox:MailboxProcessor<String>) ->
let rec loop() =
async {
let! msg = inbox.Receive()
match msg with
| _ ->
let row = ctx.Main.Events.Create()
row.Timestamp <- DateTime.Now
printfn "Submitting"
ctx.SubmitUpdates()
printfn "Submitted"
return! loop()
}
loop()
)
agent.Post "Hello"
When compiled to an exe, "Submitting" is printed, but then it hangs. If you want to try it out, the full code is on github here
It seems the problem was that the main thread was exiting before the MailboxProcessor could process it's mailbox. FSI is long-lived and so this wasn't happening there. I changed:
[<EntryPoint>]
let main argv =
agent.Post "Hello"
agent.Post "Hello again"
0
to
[<EntryPoint>]
let main argv =
agent.Post "Hello"
agent.Post "Hello again"
let waitLoop = async {
while agent.CurrentQueueLength > 0 do
printfn "Sleeping"
do! Async.Sleep 1000
}
Async.RunSynchronously waitLoop
0
and now the code executes as I had intended.
I have encountered a problem with a simple pub-sub example in ZeroMQ. I have read plenty of documentation, but I cannot seem to find an answer.
I got libzmq and clrzmq from NuGet. For both the functions below the socket address is:
let sktAddr = "tcp://127.0.0.1:3456"
Here is a simple publisher, that queues a message every second.
// Publisher - this seems to work fine
let publisher () : unit =
let skt = (new ZMQ.Context()).Socket(ZMQ.SocketType.PUB)
skt.SetSockOpt(ZMQ.SocketOpt.LINGER, 0)
skt.Bind sktAddr
skt.SendMore("TEST_TOPIC", Text.Encoding.Unicode) |> ignore
let rec h1 () : unit =
let nv = DateTime.Now.ToUniversalTime().ToString()
printfn "Sending value: %s" nv
skt.Send(Text.Encoding.Unicode.GetBytes nv) |> ignore
Threading.Thread.Sleep 1000
let swt = new Threading.SpinWait()
swt.SpinOnce()
if Console.KeyAvailable then
match Console.ReadKey().Key with
| ConsoleKey.Q -> ()
| _ -> h1()
else
h1()
h1()
The following simple subscriber throws no error, but hangs at the line indicated below.
// Subscriber
let subscriber () : unit =
let skt = (new ZMQ.Context()).Socket(ZMQ.SocketType.SUB)
skt.Connect sktAddr
skt.Subscribe("TEST_TOPIC", Text.Encoding.Unicode)
let rec h1 () : unit =
let oDat = skt.Recv() // THE PROGRAMME HANGS HERE!
let strODat = (new Text.UnicodeEncoding()).GetString oDat
if oDat <> null then
printfn "Received: %s" strODat
else
printfn "No data received"
let swt = new System.Threading.SpinWait()
swt.SpinOnce()
if Console.KeyAvailable then
match Console.ReadKey().Key with
| ConsoleKey.Q -> ()
| _ -> h1()
else
h1()
h1()
I have read this question, but no solution is provided. So I am posting a new question here.
Thanks in advance for your help.
I believe the problem is in the publisher:
skt.SendMore("TEST_TOPIC", Text.Encoding.Unicode)
Not knowing F#, it appears the above statement happens outside the loop. If the subscriber is listening on TEST_TOPIC, any messages originating from the publisher require the topic name to precede content for each message, so the publisher must do this each time it sends:
skt.SendMore("TEST_TOPIC", Text.Encoding.Unicode)
skt.Send("some data here", Text.Encoding.Unicode)
..try this...
let publisher () : unit =
let skt = (new ZMQ.Context()).Socket(ZMQ.SocketType.PUB)
skt.SetSockOpt(ZMQ.SocketOpt.LINGER, 0)
skt.Bind sktAddr
let rec h1 () : unit =
let nv = DateTime.Now.ToUniversalTime().ToString()
printfn "Sending value: %s" nv
skt.SendMore("TEST_TOPIC", Text.Encoding.Unicode) |> ignore
skt.Send(Text.Encoding.Unicode.GetBytes nv) |> ignore
Threading.Thread.Sleep 1000
let swt = new Threading.SpinWait()
swt.SpinOnce()
if Console.KeyAvailable then
match Console.ReadKey().Key with
| ConsoleKey.Q -> ()
| _ -> h1()
else
h1()
h1()
..and the subscriber has to receive twice for each message:
// Subscriber
let subscriber () : unit =
let skt = (new ZMQ.Context()).Socket(ZMQ.SocketType.SUB)
skt.Connect sktAddr
skt.Subscribe("TEST_TOPIC", Text.Encoding.Unicode)
let rec h1 () : unit =
let topicName = skt.Recv()
let oDat = skt.Recv()
let strODat = (new Text.UnicodeEncoding()).GetString oDat
if oDat <> null then
printfn "Received: %s" strODat
else
printfn "No data received"
let swt = new System.Threading.SpinWait()
swt.SpinOnce()
if Console.KeyAvailable then
match Console.ReadKey().Key with
| ConsoleKey.Q -> ()
| _ -> h1()
else
h1()
h1()
Not quite sure if it is ok to do this but, my question is: Is there something wrong with my code ? It doesn't go as fast as I would like, and since I am using lots of async workflows maybe I am doing something wrong. The goal here is to build something that can crawl 20 000 pages in less than an hour.
open System
open System.Text
open System.Net
open System.IO
open System.Text.RegularExpressions
open System.Collections.Generic
open System.ComponentModel
open Microsoft.FSharp
open System.Threading
//This is the Parallel.Fs file
type ComparableUri ( uri: string ) =
inherit System.Uri( uri )
let elts (uri:System.Uri) =
uri.Scheme, uri.Host, uri.Port, uri.Segments
interface System.IComparable with
member this.CompareTo( uri2 ) =
compare (elts this) (elts(uri2 :?> ComparableUri))
override this.Equals(uri2) =
compare this (uri2 :?> ComparableUri ) = 0
override this.GetHashCode() = 0
///////////////////////////////////////////////Functions to retrieve html string//////////////////////////////
let mutable error = Set.empty<ComparableUri>
let mutable visited = Set.empty<ComparableUri>
let getHtmlPrimitiveAsyncDelay (delay:int) (uri : ComparableUri) =
async{
try
let req = (WebRequest.Create(uri)) :?> HttpWebRequest
// 'use' is equivalent to ‘using’ in C# for an IDisposable
req.UserAgent<-"Mozilla"
//Console.WriteLine("Waiting")
do! Async.Sleep(delay * 250)
let! resp = (req.AsyncGetResponse())
Console.WriteLine(uri.AbsoluteUri+" got response after delay "+string delay)
use stream = resp.GetResponseStream()
use reader = new StreamReader(stream)
let html = reader.ReadToEnd()
return html
with
| _ as ex -> Console.WriteLine( ex.ToString() )
lock error (fun () -> error<- error.Add uri )
lock visited (fun () -> visited<-visited.Add uri )
return "BadUri"
}
///////////////////////////////////////////////Active Pattern Matching to retreive href//////////////////////////////
let (|Matches|_|) (pat:string) (inp:string) =
let m = Regex.Matches(inp, pat)
// Note the List.tl, since the first group is always the entirety of the matched string.
if m.Count > 0
then Some (List.tail [ for g in m -> g.Value ])
else None
let (|Match|_|) (pat:string) (inp:string) =
let m = Regex.Match(inp, pat)
// Note the List.tl, since the first group is always the entirety of the matched string.
if m.Success then
Some (List.tail [ for g in m.Groups -> g.Value ])
else
None
///////////////////////////////////////////////Find Bad href//////////////////////////////
let isEmail (link:string) =
link.Contains("#")
let isMailto (link:string) =
if Seq.length link >=6 then
link.[0..5] = "mailto"
else
false
let isJavascript (link:string) =
if Seq.length link >=10 then
link.[0..9] = "javascript"
else
false
let isBadUri (link:string) =
link="BadUri"
let isEmptyHttp (link:string) =
link="http://"
let isFile (link:string)=
if Seq.length link >=6 then
link.[0..5] = "file:/"
else
false
let containsPipe (link:string) =
link.Contains("|")
let isAdLink (link:string) =
if Seq.length link >=6 then
link.[0..5] = "adlink"
elif Seq.length link >=9 then
link.[0..8] = "http://adLink"
else
false
///////////////////////////////////////////////Find Bad href//////////////////////////////
let getHref (htmlString:string) =
let urlPat = "href=\"([^\"]+)"
match htmlString with
| Matches urlPat urls -> urls |> List.map( fun href -> match href with
| Match (urlPat) (link::[]) -> link
| _ -> failwith "The href was not in correct format, there was more than one match" )
| _ -> Console.WriteLine( "No links for this page" );[]
|> List.filter( fun link -> not(isEmail link) )
|> List.filter( fun link -> not(isMailto link) )
|> List.filter( fun link -> not(isJavascript link) )
|> List.filter( fun link -> not(isBadUri link) )
|> List.filter( fun link -> not(isEmptyHttp link) )
|> List.filter( fun link -> not(isFile link) )
|> List.filter( fun link -> not(containsPipe link) )
|> List.filter( fun link -> not(isAdLink link) )
let treatAjax (href:System.Uri) =
let link = href.ToString()
let firstPart = (link.Split([|"#"|],System.StringSplitOptions.None)).[0]
new Uri(firstPart)
//only follow pages with certain extnsion or ones with no exensions
let followHref (href:System.Uri) =
let valid2 = set[".py"]
let valid3 = set[".php";".htm";".asp"]
let valid4 = set[".php3";".php4";".php5";".html";".aspx"]
let arrLength = href.Segments |> Array.length
let lastExtension = (href.Segments).[arrLength-1]
let lengthLastExtension = Seq.length lastExtension
if (lengthLastExtension <= 3) then
not( lastExtension.Contains(".") )
else
//test for the 2 case
let last4 = lastExtension.[(lengthLastExtension-1)-3..(lengthLastExtension-1)]
let isValid2 = valid2|>Seq.exists(fun validEnd -> last4.EndsWith( validEnd) )
if isValid2 then
true
else
if lengthLastExtension <= 4 then
not( last4.Contains(".") )
else
let last5 = lastExtension.[(lengthLastExtension-1)-4..(lengthLastExtension-1)]
let isValid3 = valid3|>Seq.exists(fun validEnd -> last5.EndsWith( validEnd) )
if isValid3 then
true
else
if lengthLastExtension <= 5 then
not( last5.Contains(".") )
else
let last6 = lastExtension.[(lengthLastExtension-1)-5..(lengthLastExtension-1)]
let isValid4 = valid4|>Seq.exists(fun validEnd -> last6.EndsWith( validEnd) )
if isValid4 then
true
else
not( last6.Contains(".") ) && not(lastExtension.[0..5] = "mailto")
//Create the correct links / -> add the homepage , make then a comparabel Uri
let hrefLinksToUri ( uri:ComparableUri ) (hrefLinks:string list) =
hrefLinks
|> List.map( fun link -> try
if Seq.length link <4 then
Some(new Uri( uri, link ))
else
if link.[0..3] = "http" then
Some(new Uri(link))
else
Some(new Uri( uri, link ))
with
| _ as ex -> Console.WriteLine(link);
lock error (fun () ->error<-error.Add uri)
None
)
|> List.filter( fun link -> link.IsSome )
|> List.map( fun o -> o.Value)
|> List.map( fun uri -> new ComparableUri( string uri ) )
//Treat uri , removing ajax last part , and only following links specified b Benoit
let linksToFollow (hrefUris:ComparableUri list) =
hrefUris
|>List.map( treatAjax )
|>List.filter( fun link -> followHref link )
|>List.map( fun uri -> new ComparableUri( string uri ) )
|>Set.ofList
let needToVisit uri =
( lock visited (fun () -> not( visited.Contains uri) ) ) && (lock error (fun () -> not( error.Contains uri) ))
let getLinksToFollowAsyncDelay (delay:int) ( uri: ComparableUri ) =
//write
async{
let! links = getHtmlPrimitiveAsyncDelay delay uri
lock visited (fun () ->visited<-visited.Add uri)
let linksToFollow = getHref links
|> hrefLinksToUri uri
|> linksToFollow
|> Set.filter( needToVisit )
return linksToFollow
}
let getDelay(uri:ComparableUri) (authorityDelay:Dictionary<string,System.Diagnostics.Stopwatch >) =
let uriAuthority = uri.Authority
let hasAuthority,watch = authorityDelay.TryGetValue(uriAuthority)
if hasAuthority then
let elapsed = watch.Elapsed
let s = TimeSpan(0,0,0,0,500)-elapsed
if s.TotalMilliseconds < 0.0 then
0
else
int(s.TotalMilliseconds)
else
let temp = System.Diagnostics.Stopwatch()
temp.Start()
authorityDelay.Add(uriAuthority,temp)
0
let rec getLinksToFollowFromSetAsync maxIteration ( uris: seq<ComparableUri> ) =
let authorityDelay = Dictionary<string,System.Diagnostics.Stopwatch>()
if maxIteration = 100 then
Console.WriteLine("Finished")
else
//Unite by authority add delay for those we same authority others ignore
let stopwatch= System.Diagnostics.Stopwatch()
stopwatch.Start()
let newLinks = uris
|> Seq.map( fun uri -> let delay = lock authorityDelay (fun () -> getDelay uri authorityDelay )
getLinksToFollowAsyncDelay delay uri )
|> Async.Parallel
|> Async.RunSynchronously
|> Seq.concat
stopwatch.Stop()
Console.WriteLine("\n\n\n\n\n\n\nTimeElapse : "+string stopwatch.Elapsed+"\n\n\n\n\n\n\n\n\n")
getLinksToFollowFromSetAsync (maxIteration+1) newLinks
seq[set[ComparableUri( "http://rue89.com/" )]]
|>PSeq.ofSeq
|>PSeq.iter(getLinksToFollowFromSetAsync 0 )
getLinksToFollowFromSetAsync 0 (seq[ComparableUri( "http://twitter.com/" )])
Console.WriteLine("Finished")
Some feedBack would be great ! Thank you (note this is just something I am doing for fun)
I think the culprit is the line do! Async.Sleep(delay * 250) - you gradually wait longer and longer. What is the reason for it?