host: ring-core enablement part 2 — class tokens, vendored spork/http, reader-draining slurp

Class names evaluate to their canonical class-name STRINGS (the same values
class returns), so a (defmulti m (comp class :body)) matches (defmethod m
String ...) — ring.util.request dispatches exactly this way. Constructor
sugar and the new special form resolve the actual ctor from the registry
when given a token; dispatch-only names (InputStream, File, ISeq, ...) are
interned for defmethod position. nil is a legal multimethod dispatch value
now (sentinel-keyed: janet tables drop nil keys) — ring keys body-string's
no-body case on it.

spork/http is VENDORED (vendor/spork/http.janet, MIT) and baked into the
image, reaching the jolt layer as janet.spork.http/* through the janet.*
bridge — whose lookup is now a chain (runtime fiber env, module env, the
vendored registry), which also fixes janet/* resolution inside net/server
connection fibers (they carry a foreign env). jolt.http is rewritten over
the spork client (its old net/request never existed; also fixes its own
get shadowing clojure.core/get).

Also: slurp accepts opts and DRAINS reader shims (ring middleware slurps
request bodies), clojure.string/replace takes fn replacements with Clojure's
match-or-groups argument, .indexOf int needles are char codes, .getBytes on
the String surface, and ^bytes-style return hints on param vectors parse
(the fn macro unwraps the with-meta form).

Suite steady at 4715/5348; conformance x3 green; deps-conformance medley +
cuerdas green (the stuartsierra/dependency failure predates this change).
This commit is contained in:
Yogthos 2026-06-11 19:50:52 -04:00
parent 7c26a182e8
commit 8212bc76f7
7 changed files with 679 additions and 31 deletions

View file

@ -281,7 +281,11 @@
# non-ctx value, so check the shape, not just the throw.
(when (and (r 0) (ctx? (r 1)))
(r 1))))]
(or loaded
(or (when loaded
# per-PROCESS wiring an image restore skips: the renderer's
# print-method hook lives in module state, not the marshaled ctx
(install-print-method-cb! loaded)
loaded)
(let [ctx (init opts)
tmp (string path "." (os/getpid) ".tmp")]
# Atomic publish so concurrent cold starts never see a torn image.

View file

@ -1729,7 +1729,17 @@
(defn core-current-time-ms [] (* 1000 (os/clock :monotonic)))
# Host IO (host-classified in the spec): path-based slurp/spit, *out* flush.
(defn core-slurp [path] (string (slurp path)))
# Opts (:encoding ...) are accepted and ignored — everything is UTF-8 here.
# Reader shims (java.io.StringReader / PushbackReader / anything carrying
# :s + :pos) DRAIN instead of opening a file: Ring middleware slurps request
# bodies, and the jolt Ring adapter hands those over as StringReaders.
(defn core-slurp [src & opts]
(cond
(and (table? src) (string? (get src :s)) (number? (get src :pos)))
(let [s (src :s) p (src :pos)]
(put src :pos (length s))
(string/slice s p))
(string (slurp src))))
(defn core-spit [path content & opts]
(def append? (do (var a false) (var i 0)

View file

@ -13,17 +13,20 @@
# the janet/* interop bridge falls back to it inside env-less fibers.
(def- module-load-env (fiber/getenv (fiber/current)))
# spork/http (pure-janet, jpm-installed) reaches the jolt layer as
# janet.spork.http/* through the janet.* bridge below — the Ring adapter
# (examples/ring-app) builds its server on it. SOFT: jolt works without
# spork; only code touching janet.spork.http/* needs it installed.
(let [r (protect (require "spork/http"))]
(when (r 0)
(eachp [sym entry] (r 1)
(when (and (symbol? sym) (table? entry))
# root-env: the bridge resolves against the RUNTIME fiber env (which
# protos to the root env), not this module's env
(put root-env (symbol "spork.http/" sym) entry)))))
# spork/http, VENDORED (vendor/spork/http.janet, MIT) so the jolt binary
# carries its own HTTP support: a direct import marshals the fns into the
# baked image — no runtime require, no env-bridge marshal hazards. Reaches
# the jolt layer as janet.spork.http/* via janet-bridge-extras, the second
# lookup the janet.* bridge consults after the fiber env. The Ring adapter
# (examples/ring-app) and jolt.http build on it.
(import ../../vendor/spork/http :as vendored-spork-http)
(def- janet-bridge-extras
(let [t @{} pfx "vendored-spork-http/"]
(eachp [sym entry] (curenv)
(when (and (symbol? sym) (string/has-prefix? pfx sym) (table? entry))
(put t (string "spork.http/" (string/slice sym (length pfx)))
(get entry :value))))
t))
(defn- sym-name?
[sym-s name-str]
@ -513,14 +516,46 @@
# ctor fns are interned as clojure.core vars at init (install-stateful-fns!).
(def class-ctors @{})
(defn register-class-ctor! [nm f] (put class-ctors nm f))
# Class names evaluate to their CANONICAL NAME STRING — the same value
# core-class returns — so (defmethod m String ...) keys match a
# (defmulti m (comp class :body)) dispatch (ring.util.request does this).
# `new` resolves the actual constructor from class-ctors by short name.
(def- class-canonical-names
@{"String" "java.lang.String" "Number" "java.lang.Number"
"Boolean" "java.lang.Boolean" "Long" "java.lang.Long"
"Integer" "java.lang.Integer" "Double" "java.lang.Double"
"InputStream" "java.io.InputStream" "OutputStream" "java.io.OutputStream"
"File" "java.io.File" "Reader" "java.io.Reader" "Writer" "java.io.Writer"
"ISeq" "clojure.lang.ISeq" "Keyword" "clojure.lang.Keyword"
"Symbol" "clojure.lang.Symbol" "MapEntry" "clojure.lang.MapEntry"
"StringReader" "java.io.StringReader" "StringWriter" "java.io.StringWriter"
"StringBuilder" "java.lang.StringBuilder"
"StringTokenizer" "java.util.StringTokenizer"
"Charset" "java.nio.charset.Charset" "Base64" "java.util.Base64"})
(defn- class-value-for
"The value a class-name symbol evaluates to: its canonical name string."
[nm]
(or (get class-canonical-names nm)
# qualified already, or unknown: the name itself is the token
nm))
(defn- ctor-for-class-token
"Constructor fn for a class token (a canonical-name string): try the full
name, then the short name after the last dot."
[tok]
(or (in class-ctors tok)
(let [parts (string/split "." tok)]
(in class-ctors (last parts)))))
# java.lang.String method surface for clj-compat interop: (.toLowerCase s),
# (.indexOf s x), ... — the methods portable cljc libraries actually call.
# Case mapping is ASCII (the whole engine is byte-based); indexOf returns -1
# on miss, as on the JVM.
(defn- str-needle [x]
(if (and (struct? x) (= :jolt/char (get x :jolt/type)))
(string/from-bytes (x :ch))
(cond
(and (struct? x) (= :jolt/char (get x :jolt/type))) (string/from-bytes (x :ch))
# (.indexOf s 61): an int needle is a char CODE on the JVM, not its decimal
# text (ring-codec splits k=v pairs this way)
(number? x) (string/from-bytes (math/trunc x))
(string x)))
# java.lang.Number surface (ring-codec: (.byteValue (Integer/valueOf s 16))).
(def- number-methods
@ -609,16 +644,23 @@
(let [jname (if (= ns "janet") name (string (string/slice ns 6) "/" name))
# worker fibers may carry no env (fiber/new without :e inherit)
# — fall back to the env captured at module load
entry (in (or (fiber/getenv (fiber/current)) module-load-env)
(symbol jname))]
# three-step resolution: the runtime fiber's env (when it
# has one), the evaluator's module env (worker/connection
# fibers carry a foreign or empty env — net/server handler
# fibers resolve janet/struct through here), then the
# vendored-module registry (spork.http/*, marshaled fns)
entry (or (when-let [fe (fiber/getenv (fiber/current))]
(in fe (symbol jname)))
(in module-load-env (symbol jname))
(in janet-bridge-extras jname))]
(if (not (nil? entry))
(if (table? entry) (entry :value) entry)
(error (string "Unable to resolve Janet symbol: " jname))))
# syntax-quote ns-qualifies bare class names inside macros
# (selmer.util/StringBuilder); class names never belong to an ns —
# fall back to the constructor / statics shims before giving up.
(if-let [ctor (in class-ctors name)]
ctor
(if (or (in class-ctors name) (get class-canonical-names name))
(class-value-for name)
(error (string "Unable to resolve symbol: " ns "/" name))))))
# Use :jolt/not-found sentinel to distinguish nil binding from absent binding
(let [local (get bindings name :jolt/not-found-1)
@ -1090,7 +1132,8 @@
(def v-box @[nil])
(def mm-fn
(fn [& args]
(let [dv (apply dispatch-fn args)
(let [dv* (apply dispatch-fn args)
dv (if (nil? dv*) :jolt/nil-sentinel dv*)
method (get methods dv)]
(if method
(apply method args)
@ -1173,7 +1216,9 @@
(put v :jolt/methods @{})
v)))
(def methods (or (get mm-var :jolt/methods) (let [m @{}] (put mm-var :jolt/methods m) m)))
(put methods dispatch-val impl)
# nil is a legal dispatch value (ring's body-string keys a method on it);
# janet tables can't hold nil keys, so it rides the sentinel
(put methods (if (nil? dispatch-val) :jolt/nil-sentinel dispatch-val) impl)
(let [dc (get mm-var :jolt/dispatch-cache)]
(when dc (each k (keys dc) (put dc k nil))))
mm-var)
@ -1330,6 +1375,7 @@
mm-var))
(ns-intern core "remove-method-setup"
(fn [mm-sym dval]
(def dval (if (nil? dval) :jolt/nil-sentinel dval))
(def mm-var (mm-var-of mm-sym false))
(when mm-var
(let [methods (get mm-var :jolt/methods)]
@ -1352,6 +1398,7 @@
(or (and mm-var (get mm-var :jolt/prefers)) {})))
(ns-intern core "get-method-setup"
(fn [mm-sym dval]
(def dval (if (nil? dval) :jolt/nil-sentinel dval))
(def mm-var (mm-var-of mm-sym false))
(when mm-var
(let [methods (get mm-var :jolt/methods)]
@ -1444,8 +1491,13 @@
# read, with-in-str, and line-seq are Clojure over these (core/50-io.clj).
# The loader's registered source roots (the closest thing to a classpath) —
# io/resource searches these for relative resource paths.
# registered constructor shims (StringReader., StringBuilder., ...)
(eachp [nm f] class-ctors (ns-intern core nm f))
# registered constructor shims: the NAME evaluates to the canonical class
# string (so class-dispatch defmultis match); `new` finds the ctor fn.
(eachp [nm f] class-ctors (ns-intern core nm (class-value-for nm)))
# dispatch-only type names (no ctor): InputStream, File, ISeq, ...
(eachp [nm canon] class-canonical-names
(unless (or (in class-ctors nm) (ns-find core nm))
(ns-intern core nm canon)))
(ns-intern core "__source-roots"
(fn [] (tuple ;(get (ctx :env) :source-paths))))
(ns-intern core "__stdin-read-line"
@ -2011,7 +2063,8 @@
# compiles as a plain (do …); no special-form arm.
"new" (let [type-sym (in form 1)
args (map |(eval-form ctx bindings $) (tuple/slice form 2))
ctor (eval-form ctx bindings type-sym)]
ctor (eval-form ctx bindings type-sym)
ctor (if (string? ctor) (or (ctor-for-class-token ctor) ctor) ctor)]
(apply ctor args))
"." (let [target (eval-form ctx bindings (in form 1))
member-raw (in form 2)
@ -2126,6 +2179,9 @@
(let [type-name (string/slice sym-name 0 (- (length sym-name) 1))
type-sym {:jolt/type :symbol :ns (first-form :ns) :name type-name}
ctor (eval-form ctx bindings type-sym)
# class names evaluate to canonical-name STRINGS now; the
# constructor itself comes from the ctor registry
ctor (if (string? ctor) (or (ctor-for-class-token ctor) ctor) ctor)
args (map |(eval-form ctx bindings $) (tuple/slice form 1))]
(apply ctor args))
(let [v (resolve-var ctx bindings first-form)]

View file

@ -1,12 +1,25 @@
; Jolt Standard Library: jolt.http
; HTTP client using Janet's net/ module.
; HTTP client over spork/http (janet.spork.http/*; requires `jpm install spork`).
; Responses come back as {:status int :headers map :body string}.
(defn- response->map [r]
;; clojure.core/get explicitly: this ns defines an http `get` that shadows it
{:status (clojure.core/get r :status)
:body (str (or (janet.spork.http/read-body r) ""))
:headers (reduce (fn [m kv] (assoc m (str (nth kv 0)) (str (nth kv 1))))
{}
(janet/pairs (or (clojure.core/get r :headers) (janet/struct))))})
(defn- header-struct [headers]
(apply janet/struct
(mapcat (fn [kv] [(str (key kv)) (str (val kv))]) (seq (or headers {})))))
(defn get
[url & {:keys [headers]}]
(let [result (net/request url :get headers {})]
{:status (result :status) :body (result :body) :headers (result :headers)}))
(response->map
(janet.spork.http/request "GET" url :headers (header-struct headers))))
(defn post
[url body & {:keys [headers]}]
(let [result (net/request url :post headers body)]
{:status (result :status) :body (result :body) :headers (result :headers)}))
(response->map
(janet.spork.http/request "POST" url :body body :headers (header-struct headers))))

View file

@ -414,6 +414,17 @@
(do (buffer/push-string buf (string/from-bytes c)) (++ i)))))
(string buf))
(defn- replacement-for
"One match's replacement text. A string replacement gets $N expansion; a FN
replacement (Clojure: fn of the match — string, or [whole g1 ...] when the
pattern has groups) is called and its result used literally."
[replacement g ngroups]
(cond
(string? replacement) (expand-replacement replacement g)
(or (function? replacement) (cfunction? replacement))
(string (replacement (groups->result g ngroups)))
(string replacement)))
(defn re-replace-all [re s replacement]
(def re (re-pattern re))
(def buf @"") (var pos 0) (var last 0)
@ -421,7 +432,7 @@
(def g (match-at re s pos))
(if (and g (> (length (in g 0)) 0))
(do (buffer/push-string buf (string/slice s last pos))
(buffer/push-string buf (if (string? replacement) (expand-replacement replacement g) replacement))
(buffer/push-string buf (replacement-for replacement g (re :ngroups)))
(set pos (+ pos (length (in g 0))))
(set last pos))
(++ pos)))
@ -439,6 +450,6 @@
(if done
(let [[p g] done]
(string (string/slice s 0 p)
(if (string? replacement) (expand-replacement replacement g) replacement)
(replacement-for replacement g (re :ngroups))
(string/slice s (+ p (length (in g 0))))))
s))

View file

@ -163,3 +163,26 @@
"(do (defprotocol Ps (ps [x])) (extend-protocol Ps Map (ps [m] :map) Object (ps [o] :obj)) (ps (sorted-map 1 2)))"]
["reduce over reified IReduceInit" "42"
"(reduce + 0 (reify clojure.lang.IReduceInit (reduce [_ f init] (f (f init 40) 2))))"])
# ring-core enablement, part 2: class-name symbols evaluate to canonical
# class-name strings (so class-dispatch defmultis match), ctor sugar still
# constructs, type-hinted param vectors parse, slurp drains reader shims,
# str/replace takes fn replacements, and int needles are char codes.
(defspec "host-interop / class tokens & readers"
["class name evaluates to canonical string" "\"java.lang.String\"" "String"]
["dispatch-only class name" "\"java.io.InputStream\"" "InputStream"]
["(class x) matches the token" "true" "(= String (class \"abc\"))"]
["defmulti on class dispatches" ":str"
"(do (defmulti cm (fn [x] (class x))) (defmethod cm String [x] :str) (cm \"a\"))"]
["defmethod on nil dispatch value" ":nil"
"(do (defmulti cn (fn [x] (class x))) (defmethod cn nil [x] :nil) (defmethod cn String [x] :str) (cn nil))"]
["ctor sugar still constructs" "\"x\"" "(.toString (StringBuilder. \"x\"))"]
["return-hinted defn parses" "7" "(do (defn- hb ^bytes [b] b) (hb 7))"]
["hinted multi-arity parses" ":two" "((fn ([x] :one) (^String [x y] :two)) 1 2)"]
["slurp drains a StringReader" "\"a=1\"" "(slurp (StringReader. \"a=1\"))"]
["slurp accepts :encoding opts" "\"b\"" "(slurp (StringReader. \"b\") :encoding \"UTF-8\")"]
["replace with fn replacement is literal" "\"$0\""
"(do (require (quote [clojure.string :as s9])) (s9/replace \"x\" #\".\" (fn [m] \"$0\")))"]
["replace fn gets group vector" "\"v=k\""
"(do (require (quote [clojure.string :as s9])) (s9/replace \"k=v\" #\"(\\w+)=(\\w+)\" (fn [[_ k v]] (str v \"=\" k))))"]
["indexOf int needle is a char code" "1" "(.indexOf \"a=b\" 61)"])

531
vendor/spork/http.janet vendored Normal file
View file

@ -0,0 +1,531 @@
# Vendored from janet-lang/spork (MIT license) @ 3bdcf58
# for the janet.spork.http/* bridge — see evaluator.janet. Do not edit here.
###
### http.janet
###
### Pure Janet HTTP/1.1 parser, client, and server.
###
(def- chunk-size (* 16 4096))
(defn- pre-pop
"Remove n bytes from front of buffer"
[buf n]
(buffer/blit buf buf 0 n)
(buffer/popn buf n))
(def- http-grammar
~{:request-status (* :method :ws :path :ws "HTTP/1." :d :any-ws :rn)
:response-status (* "HTTP/1." :d :ws (/ ':d+ ,scan-number)
:ws '(some :printable) :rn)
:ws (some (set " \t"))
:any-ws (any (set " \t"))
:rn "\r\n"
:method '(some (range "AZ"))
:path-chr (range "az" "AZ" "09" "!!" "$9" ":;" "==" "?@" "~~" "__")
:path '(some :path-chr)
:printable (range "\x20~" "\t\t")
:headers (* (any :header) :rn)
# lower case header names since http headers are case-insensitive
:header-name (/ '(some (range "\x219" ";~")) ,string/ascii-lower)
:header-value '(any :printable)
:header (* :header-name ":" :any-ws :header-value :rn)})
(def request-peg
"PEG for parsing HTTP requests"
(peg/compile
(table/to-struct
(merge {:main ~(* :request-status :headers)}
http-grammar))))
(def response-peg
"PEG for parsing HTTP responses"
(peg/compile
(table/to-struct (merge
{:main ~(* :response-status :headers)}
http-grammar))))
(defn- accum-key-values
"Accumulate key-value pairs based on arg index (even = key, odd = value) into a table and combine
duplicate keys into arrays of values (rather than overwriting). Used for both query strings and headers."
[& args]
(def tab @{})
(loop [i :range [0 (length args) 2]
:let [k (get args i) v (get args (+ 1 i))]]
(if-let [item (in tab k)]
(if (array? item)
(array/push item v)
(put tab k @[item v]))
(put tab k v)))
tab)
(defn- read-header
"Read an HTTP header from a stream."
[conn buf peg key1 key2]
(var head nil)
(var last-index 0)
(forever
(when-let [end (string/find "\r\n\r\n" buf last-index)]
(set head
(if-let [matches (peg/match peg buf)]
(let [[a b] matches
headers (accum-key-values ;(array/remove matches 0 2))]
@{:headers headers
:connection conn
:buffer (pre-pop buf (+ 4 end))
:head-size (+ 4 end)
key1 a
key2 b})
:error))
(break))
(set last-index (max 0 (- (length buf) 4)))
(unless (:read conn chunk-size buf)
(set head :error)
(break)))
head)
(def query-string-grammar
"Grammar that parses a query string (sans url path and ? character) and returns a table."
(peg/compile
~{:qchar (+ (* "%" (/ (number (* :h :h) 16) ,string/from-bytes)) (* "+" (constant " ")))
:kchar (+ :qchar (* (not (set "&=;")) '1))
:vchar (+ :qchar (* (not (set "&;")) '1))
:key (accumulate (some :kchar))
:value (accumulate (any :vchar))
:entry (* :key (+ (* "=" :value) (constant true)) (+ (set ";&") -1))
:main (/ (any :entry) ,accum-key-values)}))
(defn read-request
``Read an HTTP request header from a connection. Returns a table with the following keys:
* `:headers` - table mapping header names to header values. Header names are lowercase.
* `:connection` - the connection stream for the header.
* `:buffer` - the buffer instance that may contain extra bytes.
* `:head-size` - the number of bytes used by the header.
* `:method` - the HTTP method used.
* `:path` - the path of the resource requested.
The following keys are also present, but omitted if the user passes a truthy parameter to `no-query`.
* `:route` - path of the resource requested without query string.
* `:query-string` - segment of HTTP path after first ? character.
* `:query` - the query string parsed into a table. Supports a single string value
for every string key, and any query parameters that aren't given a value are mapped to true.
Note that data is read in chunks and any data after the header terminator is
stored in `:buffer`.``
[conn buf &opt no-query]
(def head (read-header conn buf request-peg :method :path))
(if (= :error head) (break head))
# Parse query string separately
(unless no-query
(def fullpath (get head :path))
(def qloc (string/find "?" fullpath))
(def path (if qloc (string/slice fullpath 0 qloc) fullpath))
(def qs (if qloc (string/slice fullpath (inc qloc)) nil))
(put head :route path)
(put head :query-string qs)
(when qs
(when-let [m (peg/match query-string-grammar qs)]
(put head :query (first m)))))
head)
(defn read-response
``Read an HTTP response header from a connection. Returns a table with the following keys:
* `:headers` - table mapping header names to header values. Header names are lowercase.
* `:connection` - the connection stream for the header.
* `:buffer` - the buffer instance that may contain extra bytes.
* `:head-size` - the number of bytes used by the header.
* `:status` - the HTTP status code.
* `:message` - the HTTP status message.
Note that data is read in chunks and any data after the header terminator is
stored in `:buffer`.``
[conn buf]
(read-header conn buf response-peg :status :message))
(def status-messages
"Mapping of HTTP status codes to their status message."
{100 "Continue"
101 "Switching Protocols"
102 "Processing"
200 "OK"
201 "Created"
202 "Accepted"
203 "Non-Authoritative Information"
204 "No Content"
205 "Reset Content"
206 "Partial Content"
207 "Multi-Status"
208 "Already Reported"
226 "IM Used"
300 "Multiple Choices"
301 "Moved Permanently"
302 "Found"
303 "See Other"
304 "Not Modified"
305 "Use Proxy"
307 "Temporary Redirect"
308 "Permanent Redirect"
400 "Bad Request"
401 "Unauthorized"
402 "Payment Required"
403 "Forbidden"
404 "Not Found"
405 "Method Not Allowed"
406 "Not Acceptable"
407 "Proxy Authentication Required"
408 "Request Timeout"
409 "Conflict"
410 "Gone"
411 "Length Required"
412 "Precondition Failed"
413 "Payload Too Large"
414 "URI Too Long"
415 "Unsupported Media Type"
416 "Range Not Satisfiable"
417 "Expectation Failed"
421 "Misdirected Request"
422 "Unprocessable Entity"
423 "Locked"
424 "Failed Dependency"
426 "Upgrade Required"
428 "Precondition Required"
429 "Too Many Requests"
431 "Request Header Fields Too Large"
451 "Unavailable For Legal Reasons"
500 "Internal Server Error"
501 "Not Implemented"
502 "Bad Gateway"
503 "Service Unavailable"
504 "Gateway Timeout"
505 "HTTP Version Not Supported"
506 "Variant Also Negotiates"
507 "Insufficient Storage"
508 "Loop Detected"
510 "Not Extended"
511 "Network Authentication Required"})
(defn- write-body
"Write the body of an HTTP request, adding Content-Length header
or Transfer-Encoding: chunked"
[conn buf body]
(cond
(nil? body)
(do
(buffer/push buf "\r\n")
(:write conn buf))
(bytes? body)
(do
(buffer/format buf "Content-Length: %d\r\n\r\n%V" (length body) body)
(:write conn buf))
# default - iterate chunks
(do
(buffer/format buf "Transfer-Encoding: chunked\r\n\r\n")
(each chunk body
(assert (bytes? chunk) "expected byte chunk")
(buffer/format buf "%x\r\n%V\r\n" (length chunk) chunk)
(:write conn buf)
(buffer/clear buf))
(buffer/format buf "0\r\n\r\n")
(:write conn buf)))
(buffer/clear buf))
(defn- read-until
"Read single bytes from connection into buffer until the provided byte
sequence is found within it. The buffer need not be empty. Returns the number
of bytes from the start of the buffer until the substring."
[conn buf needle &opt start-index]
(default start-index 0)
(when-let [pos (peg/find needle buf start-index)]
(break pos))
(prompt :exit
(forever
(unless (:read conn 1 buf)
(error "end of stream"))
(when-let [pos (peg/find needle buf start-index)]
(return :exit pos)))))
(defn read-body
"Given a request, read the HTTP body from the connection. Returns the body as a buffer.
If the request has no body, returns nil."
[req]
(when-let [body (in req :body)] (break body))
(def headers (in req :headers))
# In place content
(when-let [cl (in headers "content-length")]
(def {:buffer buf
:connection conn} req)
(def content-length (scan-number cl))
(def remaining (- content-length (length buf)))
(when (pos? remaining)
(:chunk conn remaining buf))
(put req :body buf)
(break buf))
# event stream aka SSE
(when (-?>> (in headers "content-type")
(string/has-prefix? "text/event-stream"))
(def {:buffer buf
:connection conn} req)
(read-until conn buf "\n\n")
(put req :body buf)
(break buf))
# Chunked encoding
# TODO: The specification can have multiple transfer encodings so this
# precise string matching may not work for every case.
(when (= (in headers "transfer-encoding") "chunked")
(def {:buffer buf
:connection conn} req)
(def body (buffer/new chunk-size))
(var i 0)
(forever
(def chunk-length-end-pos (read-until conn buf "\r\n" i))
(var chunk-length (scan-number (slice buf i chunk-length-end-pos) 16))
(when (zero? chunk-length)
(read-until conn buf "\r\n" (+ 2 chunk-length-end-pos))
(break))
# If there's any data already read, blit that over first.
(let [leftover-start (+ chunk-length-end-pos 2)
leftover (- (length buf) leftover-start)
blit-amount (min leftover chunk-length)] # prevent overreading
(unless (zero? blit-amount)
(buffer/blit body buf -1 leftover-start (+ leftover-start blit-amount))
(-= chunk-length blit-amount))
(set i (+ leftover-start blit-amount)))
(if (= i (length buf))
# Basic case: the buffer has been exhausted and hereonout we can read
# from the socket directly.
(do
(:chunk conn chunk-length body)
(unless (:read conn 2 buf) # trailing CRLF (not included in chunk length proper)
(error "end of stream"))
# Clear buffer out. We ain't gonna need it no more.
(buffer/clear buf)
(set i 0))
# Alternatively, the pre-read data in the buffer was plentiful and we
# just managed to copy an entire chunk out of it. Just increment past
# the CRLF, if it's not already there, and proceed to loop again.
(set i (+ (read-until conn buf "\r\n" i) 2))))
(put req :body body)
(break body))
# no body, just return nil
nil)
(defn send-response
``Send an HTTP response over a connection. Will automatically use chunked
encoding if body is not a byte sequence. `response` should be a table
with the following keys:
* `:headers` - optional headers to write
* `:status` - integer status code to write
* `:body` - optional byte sequence or iterable (for chunked body)
for returning contents. The iterable can be lazy, i.e. for streaming
data.``
[conn response &opt buf]
(default buf @"")
(def status (get response :status 200))
(def message (in status-messages status))
(buffer/format buf "HTTP/1.1 %d %s\r\n" status message)
(def headers (get response :headers {}))
(eachp [k v] headers
# Values can be lists when representing duplicate headers (e.g.: multiple "Set-Cookie" entries)
(if (or (tuple? v) (array? v))
(each ve v (buffer/format buf "%V: %V\r\n" k ve))
(buffer/format buf "%V: %V\r\n" k v)))
(write-body conn buf (in response :body)))
###
### Server Middleware
###
(defn- bytes-to-mw
[b]
(fn mw [&] {:status 200 :body b}))
(defn middleware
"Coerce any type to http middleware"
[x]
(case (type x)
:function x
:number (let [msg (get status-messages x)]
(assert msg (string "unknown http status code when making middleware: " x))
(fn mw [&] {:status x :body msg}))
:string (bytes-to-mw x)
:buffer (bytes-to-mw x)
(fn mw [&] x)))
(defn router
"Creates a router middleware. A router will dispatch to different routes based on
the URL path."
[routes]
(fn router-mw [req]
(def r (or
(get routes (get req :route))
(get routes :default)))
(if r ((middleware r) req) {:status 404 :body "Not Found"})))
(defn logger
"Creates a logging middleware. The logger middleware prints URL route, return status, and elapsed request time."
[nextmw]
(fn logger-mw [req]
(def {:path path
:method method} req)
(def start-clock (os/clock))
(def ret (nextmw req))
(def end-clock (os/clock))
(def elapsed (string/format "%.3f" (* 1000 (- end-clock start-clock))))
(def status (or (get ret :status) 200))
(print method " " status " " path " elapsed " elapsed "ms")
(flush)
ret))
(def cookie-grammar
"Grammar to parse a cookie header to a series of keys and values."
(peg/compile
{:content '(some (if-not (set "=;") 1))
:eql "="
:sep '(between 1 2 (set "; "))
:main '(some (* (<- :content) :eql (<- :content) (? :sep)))}))
(defn cookies
"Parses cookies into the table under :cookies key"
[nextmw]
(fn cookie-mw [req]
(-> req
(put :cookies
(or (-?>> [:headers "cookie"]
(get-in req)
(peg/match cookie-grammar)
(apply table))
{}))
nextmw)))
###
### Server boilerplate
###
(defn server-handler
``A simple connection handler for an HTTP server.
When a connection is accepted. Call this with a handler
function to handle the connect. The handler will be called
with one argument, the request table, which will contain the
following keys:
* `:head-size` - number of bytes in the http header.
* `:headers` - table mapping header names to header values.
* `:connection` - the connection stream for the header.
* `:buffer` - the buffer instance that may contain extra bytes.
* `:path` - HTTP path.
* `:method` - HTTP method, as a string.``
[conn handler]
(def handler (middleware handler))
(defer (:close conn)
# Get request header
(def buf (buffer/new chunk-size))
(def req (read-request conn buf))
# Handle bad request
(when (= :error req)
(send-response conn {:status 400} (buffer/clear buf))
(break))
# Add some extra keys to the request
(put req :connection conn)
# Do something with request header
(def response (handler req))
# Now send back response
(send-response conn response @"")))
(defn server
"Makes a simple http server. By default it binds to 0.0.0.0:8000,
returns a new server stream.
Simply wraps http/server-handler with a net/server."
[handler &opt host port]
(default host "0.0.0.0")
(default port 8000)
(defn new-handler
[conn]
(server-handler conn handler))
(net/server host port new-handler))
###
### HTTP Client
###
(def- url-peg-source
~{:main (* (+ :https :http) :fqdn :port :path)
:https (* (constant "https") "https://")
:http (* (constant "http") "http://")
:fqdn '(some (range "az" "AZ" "09" ".." "--"))
:port (+ (* ":" ':d+) (constant nil))
:path-chr (range "az" "AZ" "09" "!!" "$9" ":;" "==" "?@" "~~" "__")
:path (+ '(some :path-chr) (constant "/"))})
(def url-grammar
"Grammar to parse a URL into scheme, domain, port, and path. Supports
both http:// and https:// protocols. Returns [scheme host port path]."
(peg/compile url-peg-source))
(defn request
``Make an HTTP request to a server.
Returns a table containing response information.
* `:head-size` - number of bytes in the http header
* `:headers` - table mapping header names to header values. Header names are lowercase.
* `:connection` - the connection stream for the header.
* `:buffer` - the buffer instance that may contain extra bytes.
* `:status` - HTTP status code as an integer.
* `:message` - HTTP status message.
* `:body` - Bytes of the response body.
Options:
* `:body` - Request body content
* `:headers` - Request headers table
* `:stream-factory` - Function to create connection stream. Defaults to net/connect.
Signature: (stream-factory host port stream-opts)
* `:stream-opts` - Options table passed to stream-factory``
[method url &keys
{:body body
:headers headers
:stream-factory stream-factory
:stream-opts stream-opts}]
(def x (peg/match url-grammar url))
(assert x (string "invalid url: " url))
(def [scheme host raw-port path] x)
# Default port based on scheme
(def port (or raw-port (if (= scheme "https") "443" "80")))
(def buf @"")
(buffer/format buf "%s %s HTTP/1.1\r\nHost: %s:%s\r\n" method path host port)
(when headers
(eachp [k v] headers
(buffer/format buf "%s: %s\r\n" k v)))
# Use custom stream-factory or default to net/connect
(let [make-conn (or stream-factory net/connect)
conn (if stream-opts
(make-conn host port stream-opts)
(make-conn host port))]
(with [conn conn]
# Make request
(write-body conn buf body)
# Parse response pure janet
(def res (read-response conn buf))
(when (= :error res) (error res))
# HEAD responses have no body per HTTP spec, skip read-body
(unless (= method "HEAD")
(read-body res))
# TODO - handle redirects with Location header
res)))