├── .gitignore ├── README.md ├── buffer.sml ├── http.sml ├── main.sml ├── server.mlb ├── server.sml └── util.sml /.gitignore: -------------------------------------------------------------------------------- 1 | *~ -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # SML Server 2 | ###### An attempt at a web server in Standard ML 3 | 4 | ## Usage 5 | 6 | ### With `SML/NJ` 7 | 8 | - Start `sml` in the project directory 9 | - Enter `use "buffer.sml" ; use "parser.sml" ; use "server.sml" ; use "main.sml" ;` 10 | - Navigate to `http://localhost:8181` 11 | 12 | ### With `mlton` 13 | 14 | - `cd` into the project directory 15 | - Enter `mlton server.mlb` 16 | - Run `./server 8181` 17 | - Navigate to `http://localhost:8181` 18 | 19 | ## Status 20 | 21 | - Don't use it yet, it's nowhere near done 22 | - Now have minimally working "hello world" server 23 | - Still need to think about how I'm going to want to define handlers 24 | 25 | ### ToDo 26 | 27 | ##### Items 28 | 29 | - Do a cleanup round 30 | - Start URI decoding parameters 31 | - Think about how to do `application/json` and `multipart/form-data` parsing for POST bodies 32 | - Start thinking about the general handler structure 33 | - routing needs to be handled (with path variables) 34 | - defining handlers needs to be handled (hehe). Including 35 | - setting headers, 36 | - setting response type 37 | - sending a body easily (take a string, compute length from it, attach the header if not otherwise provided and send out the response) 38 | - Deal elegantly with making requests from handler bodies 39 | 40 | ##### Relevant Musings 41 | 42 | - The basic server is now `structure`/`functor`-ified 43 | - Because of the way it's done, this doesn't necessarily have to represent an HTTP server. This particular implementation happens to, but it seems like this could let you build a server for any stream-socket protocol. Not actually sure about "any", but it seems like it can do more than just HTTP. 44 | - Response-wise, we want to support (in descending order of priority) 45 | - Standard HTTP responses 46 | - SSE interaction (keep the socket off to the side, complete with channels, periodically send things to them) 47 | - Websockets (keep sockets in the main listen loop, but do different things with them. Not sure how this is actually going to work, given that the main listener loop will always expect `\r\n` delimiters, it seems this would limit what kind of protocol you could set up. Do we need an arbitrary async read from handlers?) 48 | - Static file serving 49 | - Static directory-tree serving 50 | - It'd be nice if we had a baked-in async client that worked with the same main event-loop to let you make low-friction requests between servers 51 | - What we ultimately want to pass into `serve` is s function that does `(Request -> socket -> ServAction)`. Providing a simple and flexible way of constructing that function is going to be most of the rest of the work. 52 | - We'll want an easy way of associating routes (including path variables) with functions of `(Parameter list -> Response)`. Maybe even push that to `string` at the output (just the response body). Ditto for SSE sends to a particular channel. Arbitrary socket sends (`Websockets`) will be more involved and fine-grained, because they'll by definition depend on the application-specific message format being used. 53 | - Do we want routing to be a separate component entirely? Or just make the responder simple enough that the entire thing can be replaced? Every point of customization adds a little bit of extra complexity in terms of implementation, but I'm convinced we can still use plain `fun`s and `struct`s to present a simple interface. 54 | - Do we need to be able to control whether a socket closes on a per-handler level? It seems like we might just need multiple handler types. Specifically, if we had something like `ChannelHandlers` that subscribed new sockets, `WSHandlers` that dealt with WebSockets, we could let everything else close (possibly based on incoming headers) 55 | 56 | ### Test Data 57 | 58 | Get request: 59 | 60 | "GET / HTTP/1.1\r\nHost: localhost:8184\r\nUser-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:35.0) Gecko/20100101 Firefox/35.0\r\nAccept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.5\r\nAccept-Encoding: gzip, deflate\r\nCookie: __utma=111872281.1074254706.1427666251.1427666251.1427666251.1; __utmz=111872281.1427666251.1.1.utmcsr=(direct)|utmccn=(direct)|utmcmd=(none); C6gbrqqYAK3a1rKin6QaTAZDD5Oe0xnRat0RKe06ntufdcKUN12VtUXc8rfLrgw4\r\nConnection: keep-alive\r\n\r\n" 61 | -------------------------------------------------------------------------------- /buffer.sml: -------------------------------------------------------------------------------- 1 | signature BUFFER = 2 | sig 3 | type Buffer 4 | datatype BufferStatus = Complete | Incomplete | Errored | Dead 5 | val new : unit -> Buffer 6 | val newStatic : int -> Buffer 7 | val readInto : Buffer -> ('a,Socket.active Socket.stream) Socket.sock -> BufferStatus 8 | val toSlice : Buffer -> Word8ArraySlice.slice 9 | end 10 | 11 | structure DefaultBuffer : BUFFER = 12 | struct 13 | type Buffer = { fill : int ref, buf : Word8Array.array ref, done_p: (int -> Word8Array.array -> bool) 14 | , started : Time.time, tries : int ref} 15 | datatype BufferStatus = Complete | Incomplete | Errored | Dead 16 | local 17 | val INIT_SIZE = 1000 18 | val MAX_SIZE = 50000 19 | val MAX_AGE = Time.fromSeconds 15 20 | val MAX_TRIES = 15 21 | 22 | fun done {fill, buf, done_p, started, tries} = done_p (!fill) (!buf) 23 | 24 | fun ageOf {fill, buf, done_p, started, tries} = Time.- (Time.now (), started) 25 | 26 | fun crlfx2 fill buf = 27 | let fun chk c i = (Word8.fromInt (Char.ord c)) = (Word8Array.sub (buf, fill - i)) 28 | in 29 | (fill >= 4) andalso (chk #"\r" 4) andalso (chk #"\n" 3) andalso (chk #"\r" 2) andalso (chk #"\n" 1) 30 | end 31 | 32 | fun isFull {fill, buf, done_p, started, tries} = (Word8Array.length (!buf)) = (!fill) 33 | 34 | fun grow {fill, buf, done_p, started, tries} = 35 | let val len = Word8Array.length (!buf) 36 | val newBuf = Word8Array.array (len * 2, Word8.fromInt 0) 37 | in 38 | Word8Array.copy {di=0, dst=newBuf, src=(!buf)}; 39 | buf := newBuf; 40 | () 41 | end 42 | in 43 | 44 | fun readInto buffer sock = 45 | let val {fill, buf, done_p, started, tries} = buffer 46 | fun recur () = 47 | let val i = Socket.recvArrNB (sock, Word8ArraySlice.slice (!buf, !fill, SOME 1)) 48 | in 49 | if i = NONE then () else (fill := (!fill + 1)); 50 | if done buffer 51 | then Complete 52 | else if (!fill > MAX_SIZE) orelse (Time.> (ageOf buffer, MAX_AGE)) orelse (!tries > MAX_TRIES) 53 | then Errored 54 | else case i of 55 | NONE => Incomplete 56 | | SOME n => (if isFull buffer then grow buffer else (); 57 | recur ()) 58 | end 59 | in 60 | tries := (!tries + 1); 61 | recur () 62 | end 63 | handle _ => Dead 64 | 65 | fun newStatic size = 66 | let fun done f b = f = (Word8Array.length b) 67 | in 68 | { fill= ref 0, buf= ref (Word8Array.array (size, Word8.fromInt 0)), done_p= done, 69 | started= Time.now (), tries = ref 0} 70 | end 71 | 72 | fun new () = 73 | { fill= ref 0, buf= ref (Word8Array.array (INIT_SIZE, Word8.fromInt 0)), done_p=crlfx2, 74 | started= Time.now (), tries = ref 0} 75 | 76 | fun toSlice {fill, buf, done_p, started, tries} = Word8ArraySlice.slice (!buf, 0, SOME (!fill)) 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /http.sml: -------------------------------------------------------------------------------- 1 | signature HTTP = 2 | sig 3 | type Response 4 | val response : string -> (string * string) list -> string -> Response 5 | val parseRes : Word8ArraySlice.slice -> Response 6 | val resHeader : Response -> string -> string option 7 | val body : Response -> string 8 | val sendRes : ('a,Socket.active Socket.stream) Socket.sock -> Response -> unit 9 | 10 | type Request 11 | val request : string -> string -> (string * string) list -> (string * string) list -> Request 12 | val parseParams : Word8ArraySlice.slice -> (string * string) list 13 | val parseReq : Word8ArraySlice.slice -> Request 14 | val sendReq : ('a,Socket.active Socket.stream) Socket.sock -> Request -> unit 15 | val header : Request -> string -> string option 16 | val param : Request -> string -> string option 17 | val mapParams : Request -> (string * string -> 'a) -> 'a list 18 | val addParam : Request -> string -> string -> Request 19 | val addParams : Request -> (string * string) list -> Request 20 | val method : Request -> string 21 | val resource : Request -> string 22 | end 23 | 24 | structure BasicHTTP : HTTP = 25 | struct 26 | type Response = { httpVersion : string, responseType : string, 27 | headers : (string * string) list, 28 | body : string } 29 | 30 | type Request = { method : string, resource : string, httpVersion : string, 31 | headers : (string * string) list, 32 | parameters : (string * string) list } 33 | type Headers = (string * string) list 34 | type Parameters = (string * string) list 35 | 36 | local 37 | fun matches str arr = 38 | ((String.size str) = (Word8ArraySlice.length arr)) 39 | andalso let val chr = Word8.fromInt o Char.ord 40 | fun recur (~1) = true 41 | | recur i = ((chr (String.sub (str, i))) = (Word8ArraySlice.sub (arr, i))) 42 | andalso recur (i - 1) 43 | in 44 | recur ((String.size str) - 1) 45 | end 46 | 47 | fun tokens sep arr = 48 | let val lst = map (Word8.fromInt o Char.ord) (String.explode sep) 49 | val sepLen = String.size sep 50 | val len = Word8ArraySlice.length arr 51 | fun collect mark i sepLen acc = 52 | if i > (mark + sepLen) 53 | then (Word8ArraySlice.subslice (arr, mark, SOME ((i-mark)-sepLen)))::acc 54 | else acc 55 | fun recur mark i [] acc = recur i i lst (collect mark i sepLen acc) 56 | | recur mark i (b::bs) acc = if i = len 57 | then List.rev (collect mark i 0 acc) 58 | else if b = (Word8ArraySlice.sub (arr, i)) 59 | then recur mark (i+1) bs acc 60 | else recur mark (i+1) lst acc 61 | in 62 | recur 0 0 lst [] 63 | end 64 | 65 | fun lookup _ [] = NONE 66 | | lookup key ((k : string, v :string)::rest) = 67 | if key = k 68 | then SOME v 69 | else lookup key rest 70 | 71 | val crlf = Word8VectorSlice.full (Byte.stringToBytes "\r\n") 72 | 73 | fun sendString sock str = 74 | Socket.sendVec (sock, Word8VectorSlice.full (Byte.stringToBytes str)) 75 | 76 | fun each f [] = () 77 | | each f (e::es) = (f e; each f es) 78 | 79 | fun sendLine sock ln = 80 | let val snd = sendString sock 81 | in 82 | each snd ln; 83 | Socket.sendVec (sock, crlf) 84 | end 85 | in 86 | 87 | fun response tp headers body = 88 | { 89 | httpVersion="HTTP/1.1", responseType=tp, 90 | headers=headers, body=body 91 | } 92 | fun resHeader (res : Response) key = lookup key (#headers res) 93 | fun body (res : Response) = #body res 94 | 95 | fun parseRes slc = 96 | let val (req, headers) = case tokens "\r\n" slc of 97 | (ln::lns) => (ln, lns) 98 | | _ => raise Fail "Invalid request" 99 | val (version, rType) = case tokens " " req of 100 | (v::rest) => (v, rest) 101 | | _ => raise Fail "Invalid request line" 102 | fun toHdr [k, v] = (sliceToStr k, sliceToStr v) 103 | | toHdr _ = raise Fail "Invalid header" 104 | in 105 | { 106 | httpVersion= sliceToStr version, responseType= String.concatWith " " (map sliceToStr rType), 107 | headers = map (fn h => toHdr (tokens ": " h)) headers, 108 | body="" 109 | } 110 | end 111 | 112 | fun sendRes sock {httpVersion, responseType, headers, body} = 113 | let fun ln lst = sendLine sock lst 114 | in 115 | ln [httpVersion, " ", responseType]; 116 | each (fn (k, v) => ln [k, ": ", v]) headers; 117 | ln []; 118 | ln [body]; 119 | () 120 | end 121 | 122 | fun request method res headers params = 123 | { 124 | httpVersion="HTTP/1.1", method=method, resource=res, 125 | headers=headers, parameters=params 126 | } 127 | 128 | fun header (req : Request) name = lookup name (#headers req) 129 | fun param (req : Request) name = lookup name (#parameters req) 130 | 131 | fun mapParams (req : Request) f = map f (#parameters req) 132 | fun addParam {method, resource, httpVersion, headers, parameters } k v = 133 | { method=method, resource=resource, httpVersion=httpVersion, 134 | headers=headers, parameters= (k, v)::parameters} 135 | fun addParams {method, resource, httpVersion, headers, parameters } ps = 136 | { method=method, resource=resource, httpVersion=httpVersion, 137 | headers=headers, parameters= ps @ parameters} 138 | 139 | fun method (req : Request) = #method req 140 | fun resource (req : Request) = #resource req 141 | 142 | fun parseParams slc = 143 | let val pairs = tokens "&" slc 144 | fun toPair [k, v] = (sliceToStr k, sliceToStr v) 145 | | toPair _ = raise Fail "Invalid parameter" 146 | in 147 | map (toPair o tokens "=") pairs 148 | end 149 | 150 | fun parseReq slc = 151 | let val (req, rest) = case tokens "\r\n" slc of 152 | (r :: rs) => (r, rs) 153 | | _ => raise Fail "Invalid request" 154 | fun toHdr [k, v] = (sliceToStr k, sliceToStr v) 155 | | toHdr _ = raise Fail "Invalid header" 156 | fun toReq [m, uri, ver] hdrs = 157 | let val (resource, args) = case tokens "?" uri of 158 | [rawUri, ps] => (rawUri, parseParams ps) 159 | | [rawUri] => (rawUri, []) 160 | | _ => raise Fail "Invalid resource specifier" 161 | in { method=sliceToStr m, resource=sliceToStr resource, httpVersion=sliceToStr ver, 162 | headers= map (fn h => toHdr (tokens ": " h)) hdrs, 163 | parameters=args } 164 | end 165 | | toReq _ _ = raise Fail "Invalid request line" 166 | in 167 | ((toReq (tokens " " req) rest) : Request) 168 | end 169 | 170 | fun sendReq sock {httpVersion, method, resource, headers, parameters} = 171 | let val ln = sendLine sock 172 | val str = sendString sock 173 | val paramStr = String.concatWith "&" (map (fn (k, v) => k ^ "=" ^ v) parameters) 174 | val (uri, hs, bdy) = case method of 175 | "POST" => (resource, 176 | ("Content-Length", Int.toString (String.size paramStr)) 177 | ::("Content-Type", "application/x-www-form-urlencoded") 178 | ::headers, 179 | paramStr) 180 | | _ => (resource ^ "?" ^ paramStr, headers, "") 181 | in 182 | ln [method, " ", uri, " HTTP/1.1"]; 183 | each (fn (k, v) => ln [k, ": ", v]) hs; 184 | ln []; 185 | if bdy = "" 186 | then () 187 | else (str bdy; ()) 188 | end 189 | 190 | end 191 | end 192 | -------------------------------------------------------------------------------- /main.sml: -------------------------------------------------------------------------------- 1 | structure Serv = SERVER (structure Buf = DefaultBuffer; structure Par = BasicHTTP); 2 | 3 | (* curl -d "a=foo&b=bar" localhost:8787/params *) 4 | 5 | fun httpRes responseType extraHeaders body action = 6 | ({ 7 | httpVersion = "HTTP/1.1", responseType = responseType, 8 | headers = ("Content-Length", Int.toString (String.size body))::extraHeaders, 9 | body = body 10 | }, action) 11 | 12 | fun route f (request : Serv.Request) socket = 13 | let val path = case String.fields (curry (op =) #"/") (Serv.resource request) of 14 | (""::rest) => rest 15 | | p => p 16 | val (res, act) = f (Serv.method request) path (Serv.param request) 17 | in 18 | Serv.sendRes socket res; 19 | act 20 | end 21 | 22 | fun simpleRes resCode body = 23 | httpRes resCode [] body Serv.CLOSE; 24 | 25 | fun ok body = httpRes "200 Ok" [] body Serv.CLOSE; 26 | fun err400 body = httpRes "404 Not Found" [] body Serv.CLOSE; 27 | fun err404 body = httpRes "404 Not Found" [] body Serv.CLOSE; 28 | 29 | fun hello "GET" ["hello", name] _ = 30 | ok ("Hello there, " ^ name ^ "! I'm a server!") 31 | | hello _ ("rest"::rest) _ = 32 | ok ("You asked for: '" ^ (String.concatWith " :: " rest) ^ "' ...") 33 | | hello _ ["params"] ps = 34 | let in 35 | case (ps "a", ps "b") of 36 | (SOME a, SOME b) => 37 | ok ("You sent me A: " ^ a ^ " and B: " ^ b ^ "...") 38 | | _ => err400 "Need both 'a' and 'b' parameters for this one." 39 | end 40 | | hello _ _ _ = 41 | err404 "Sorry; I don't know how to do that"; 42 | 43 | fun getPort (port::_) = 44 | let fun p (SOME n) = n 45 | | p NONE = 8181 46 | in 47 | p (Int.fromString port) 48 | end 49 | | getPort _ = 8181; 50 | 51 | Serv.serve (getPort (CommandLine.arguments ())) (route hello) ; 52 | -------------------------------------------------------------------------------- /server.mlb: -------------------------------------------------------------------------------- 1 | local 2 | $(SML_LIB)/basis/basis.mlb 3 | util.sml 4 | buffer.sml 5 | http.sml 6 | server.sml 7 | in 8 | main.sml 9 | end 10 | -------------------------------------------------------------------------------- /server.sml: -------------------------------------------------------------------------------- 1 | signature HTTPSERVER = 2 | sig 3 | datatype SockAction = CLOSE | LEAVE_OPEN | KEEP_LISTENING 4 | type Request 5 | val request : string -> string -> (string * string) list -> (string * string) list -> Request 6 | val header : Request -> string -> string option 7 | val param : Request -> string -> string option 8 | val mapParams : Request -> (string * string -> 'a) -> 'a list 9 | val addParam : Request -> string -> string -> Request 10 | val method : Request -> string 11 | val resource : Request -> string 12 | 13 | type Response 14 | val response : string -> (string * string) list -> string -> Response 15 | val resHeader : Response -> string -> string option 16 | val body : Response -> string 17 | 18 | val serve : int -> (Request -> (INetSock.inet,Socket.active Socket.stream) Socket.sock -> SockAction) -> 'u 19 | val sendRes : ('a,Socket.active Socket.stream) Socket.sock -> Response -> unit 20 | val sendReq : ('a,Socket.active Socket.stream) Socket.sock -> Request -> unit 21 | end 22 | 23 | functor SERVER (structure Buf : BUFFER; structure Par : HTTP) : HTTPSERVER = 24 | struct 25 | datatype SockAction = CLOSE | LEAVE_OPEN | KEEP_LISTENING 26 | type Request = Par.Request 27 | val request = Par.request 28 | val header = Par.header 29 | val param = Par.param 30 | val mapParams = Par.mapParams 31 | val addParam = Par.addParam 32 | val method = Par.method 33 | val resource = Par.resource 34 | 35 | type Response = Par.Response 36 | val response = Par.response 37 | val resHeader = Par.resHeader 38 | val body = Par.body 39 | 40 | val sendRes = Par.sendRes 41 | val sendReq = Par.sendReq 42 | 43 | local 44 | datatype ReqState = INITIAL_READ | BODY_READ of Request 45 | 46 | fun completeRequest c req cb= 47 | case cb req of 48 | CLOSE => NONE 49 | | _ => SOME (c, Buf.new (), INITIAL_READ, cb) 50 | 51 | fun processRequest (c, slc, INITIAL_READ, cb) = 52 | let val req = Par.parseReq slc 53 | fun complete () = completeRequest c req cb 54 | in 55 | case (Par.method req, Par.header req "Content-Type", Par.header req "Content-Length") of 56 | ("POST", SOME "application/x-www-form-urlencoded", SOME ct) => 57 | (case Int.fromString ct of 58 | NONE => complete () 59 | | SOME n => SOME (c, Buf.newStatic n, BODY_READ req, cb)) 60 | | _ => complete () 61 | end 62 | | processRequest (c, slc, BODY_READ saved, cb) = 63 | let val params = Par.parseParams slc 64 | in 65 | completeRequest c (Par.addParams saved params) cb 66 | end 67 | 68 | fun processClients descriptors sockTuples = 69 | let fun recur _ [] = [] 70 | | recur [] rest = rest 71 | | recur (d::ds) ((c,buffer,state,cb)::cs) = 72 | if Socket.sameDesc (d, Socket.sockDesc c) 73 | then (case Buf.readInto buffer c of 74 | Buf.Complete => (case processRequest (c, Buf.toSlice buffer, state, cb) of 75 | SOME tup => tup :: (recur ds cs) 76 | | NONE => (Socket.close c; recur ds cs) ) 77 | | Buf.Incomplete => (c, buffer, state, cb) :: (recur ds cs) 78 | | Buf.Errored => (Socket.close c; recur ds cs) (* Should send 400 here *) 79 | | Buf.Dead => (Socket.close c; recur ds cs)) 80 | handle Fail _ => (Socket.close c; recur ds cs) 81 | else (c, buffer, state, cb) :: (recur (d::ds) cs) 82 | in 83 | recur descriptors sockTuples 84 | end 85 | 86 | fun processServers f descs socks = 87 | let fun recur ds [] newClients = (ds, newClients) 88 | | recur [] _ newClients = ([], newClients) 89 | | recur (d::ds) (s::ss) newClients = 90 | if Socket.sameDesc (d, Socket.sockDesc s) 91 | then let val c = fst (Socket.accept s) 92 | val buf = Buf.new () 93 | fun cb req = f req c 94 | in 95 | recur ds ss ((c, buf, INITIAL_READ, cb)::newClients) 96 | end 97 | else recur (d::ds) ss newClients 98 | in 99 | recur descs socks [] 100 | end 101 | 102 | fun selecting server clients timeout = 103 | let val { rds, exs, wrs } = Socket.select { 104 | rds = (Socket.sockDesc server) :: (map Socket.sockDesc clients), 105 | wrs = [], exs = [], timeout = timeout 106 | } 107 | in 108 | rds 109 | end 110 | handle x => (Socket.close server; raise x) 111 | 112 | fun acceptLoop serv clients serverFn = 113 | let val ready = selecting serv (map (fn (a, _, _, _) => a) clients) NONE 114 | val (stillReady, newCs) = processServers serverFn ready [serv] 115 | val next = processClients stillReady clients 116 | in 117 | acceptLoop serv (newCs @ next) serverFn 118 | end 119 | handle x => (Socket.close serv; raise x) 120 | in 121 | 122 | fun serve port serverFn = 123 | let val s = INetSock.TCP.socket() 124 | in 125 | Socket.Ctl.setREUSEADDR (s, true); 126 | Socket.bind(s, INetSock.any port); 127 | Socket.listen(s, 5); 128 | print ("Listening on port " ^ (Int.toString port) ^ "...\n"); 129 | acceptLoop s [] serverFn 130 | end 131 | 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /util.sml: -------------------------------------------------------------------------------- 1 | (* ***** Basic Utility *) 2 | fun fst (a, _) = a 3 | fun snd (_, b) = b 4 | 5 | fun a_ (a, _, _) = a 6 | fun b_ (_, b, _) = b 7 | fun c_ (_, _, c) = c 8 | 9 | fun curry f = fn a => fn b => f(a,b) 10 | 11 | fun each f [] = () 12 | | each f (e::es) = (f e; each f es) 13 | 14 | (* ***** String to array conversions *) 15 | fun strToSlice str = (Word8ArraySlice.full (Word8Array.fromList (map (Word8.fromInt o Char.ord) (String.explode str)))) 16 | 17 | fun sliceToStr slice = 18 | let val len = Word8ArraySlice.length slice 19 | fun f i = Char.chr (Word8.toInt (Word8ArraySlice.sub (slice, i))) 20 | in 21 | String.implode (List.tabulate (len, f)) 22 | end 23 | --------------------------------------------------------------------------------