+3
-2
dune-project
+3
-2
dune-project
+139
lib/channel.ml
+139
lib/channel.ml
···
1
+
type event = {
2
+
topic : string;
3
+
event : string;
4
+
payload : string;
5
+
ref : int option;
6
+
}
7
+
8
+
type t = {
9
+
ws : Websocket.t;
10
+
pubsub : Pubsub.t;
11
+
subscriptions : (string, Pubsub.subscription) Kcas_data.Hashtbl.t;
12
+
handlers : (string, event -> unit) Kcas_data.Hashtbl.t;
13
+
mutable next_ref : int;
14
+
}
15
+
16
+
let parse_event s =
17
+
let len = String.length s in
18
+
if len < 2 || s.[0] <> '{' then None
19
+
else
20
+
let find_string_field key =
21
+
let pattern = "\"" ^ key ^ "\":\"" in
22
+
let plen = String.length pattern in
23
+
let rec search i =
24
+
if i + plen >= len then None
25
+
else if String.sub s i plen = pattern then
26
+
let start = i + plen in
27
+
let rec find_end j =
28
+
if j >= len then None
29
+
else if s.[j] = '"' && (j = start || s.[j - 1] <> '\\') then
30
+
Some (String.sub s start (j - start))
31
+
else find_end (j + 1)
32
+
in
33
+
find_end start
34
+
else search (i + 1)
35
+
in
36
+
search 0
37
+
in
38
+
let find_int_field key =
39
+
let pattern = "\"" ^ key ^ "\":" in
40
+
let plen = String.length pattern in
41
+
let rec search i =
42
+
if i + plen >= len then None
43
+
else if String.sub s i plen = pattern then
44
+
let start = i + plen in
45
+
let rec find_end j =
46
+
if j >= len then j
47
+
else match s.[j] with '0' .. '9' -> find_end (j + 1) | _ -> j
48
+
in
49
+
let end_pos = find_end start in
50
+
if end_pos > start then
51
+
int_of_string_opt (String.sub s start (end_pos - start))
52
+
else None
53
+
else search (i + 1)
54
+
in
55
+
search 0
56
+
in
57
+
match (find_string_field "t", find_string_field "e") with
58
+
| Some topic, Some event ->
59
+
let payload = Option.value ~default:"" (find_string_field "p") in
60
+
let ref = find_int_field "r" in
61
+
Some { topic; event; payload; ref }
62
+
| _ -> None
63
+
64
+
let serialize_event ev =
65
+
let ref_part =
66
+
match ev.ref with Some r -> Printf.sprintf ",\"r\":%d" r | None -> ""
67
+
in
68
+
Printf.sprintf "{\"t\":\"%s\",\"e\":\"%s\",\"p\":\"%s\"%s}" ev.topic ev.event
69
+
ev.payload ref_part
70
+
71
+
let create ~ws ~pubsub () =
72
+
{
73
+
ws;
74
+
pubsub;
75
+
subscriptions = Kcas_data.Hashtbl.create ();
76
+
handlers = Kcas_data.Hashtbl.create ();
77
+
next_ref = 0;
78
+
}
79
+
80
+
let join t ~topic ~params:_ =
81
+
if Kcas_data.Hashtbl.mem t.subscriptions topic then Ok ()
82
+
else
83
+
let sub =
84
+
Pubsub.subscribe t.pubsub topic (fun msg ->
85
+
let ev = { topic; event = "msg"; payload = msg; ref = None } in
86
+
ignore (Websocket.send_text t.ws (serialize_event ev)))
87
+
in
88
+
Kcas_data.Hashtbl.replace t.subscriptions topic sub;
89
+
Ok ()
90
+
91
+
let leave t ~topic =
92
+
match Kcas_data.Hashtbl.find_opt t.subscriptions topic with
93
+
| None -> ()
94
+
| Some sub ->
95
+
Pubsub.unsubscribe t.pubsub sub;
96
+
Kcas_data.Hashtbl.remove t.subscriptions topic
97
+
98
+
let push t ~topic ~event ~payload =
99
+
let ref = t.next_ref in
100
+
t.next_ref <- t.next_ref + 1;
101
+
let ev = { topic; event; payload; ref = Some ref } in
102
+
ignore (Websocket.send_text t.ws (serialize_event ev))
103
+
104
+
let broadcast t ~topic ~payload = Pubsub.broadcast t.pubsub topic payload
105
+
let on t ~event ~handler = Kcas_data.Hashtbl.replace t.handlers event handler
106
+
107
+
let handle_message t msg =
108
+
match parse_event msg with
109
+
| None -> ()
110
+
| Some ev -> (
111
+
match ev.event with
112
+
| "join" -> ignore (join t ~topic:ev.topic ~params:ev.payload)
113
+
| "leave" -> leave t ~topic:ev.topic
114
+
| _ -> (
115
+
match Kcas_data.Hashtbl.find_opt t.handlers ev.event with
116
+
| Some handler -> handler ev
117
+
| None -> ()))
118
+
119
+
let close t =
120
+
Kcas_data.Hashtbl.iter
121
+
(fun _ sub -> Pubsub.unsubscribe t.pubsub sub)
122
+
t.subscriptions;
123
+
Kcas_data.Hashtbl.clear t.subscriptions;
124
+
Websocket.close t.ws
125
+
126
+
let run t =
127
+
let rec loop () =
128
+
match Websocket.recv_message t.ws with
129
+
| Ok (Websocket.Opcode.Text, msg) ->
130
+
handle_message t msg;
131
+
loop ()
132
+
| Ok (Websocket.Opcode.Binary, msg) ->
133
+
handle_message t msg;
134
+
loop ()
135
+
| Ok _ -> loop ()
136
+
| Error Websocket.Connection_closed -> close t
137
+
| Error _ -> close t
138
+
in
139
+
loop ()
+1
-1
lib/dune
+1
-1
lib/dune
···
3
3
(library
4
4
(name hcs)
5
5
(public_name hcs)
6
-
(libraries eio eio.unix h1 h2 tls-eio tls ca-certs x509 ptime ptime.clock.os cstruct uri digestif base64 bigstringaf faraday bytesrw bytesrw.zlib bytesrw.zstd))
6
+
(libraries eio eio.unix h1 h2 tls-eio tls ca-certs x509 ptime ptime.clock.os cstruct uri digestif base64 bigstringaf faraday bytesrw bytesrw.zlib bytesrw.zstd mirage-crypto mirage-crypto-rng kcas kcas_data unix))
+84
lib/endpoint.ml
+84
lib/endpoint.ml
···
1
+
type config = {
2
+
port : int;
3
+
bind : string;
4
+
domains : int;
5
+
secret_key_base : string;
6
+
protocol : Server.protocol;
7
+
tls : Tls_config.Server.t option;
8
+
health_check : bool;
9
+
}
10
+
11
+
let default_config =
12
+
{
13
+
port = 8080;
14
+
bind = "0.0.0.0";
15
+
domains = 1;
16
+
secret_key_base = "";
17
+
protocol = Server.Http1_only;
18
+
tls = None;
19
+
health_check = true;
20
+
}
21
+
22
+
type params_handler = Router.params -> Server.request -> Server.response
23
+
24
+
type t = {
25
+
config : config;
26
+
plugs : Plug.t list;
27
+
router : params_handler Router.t option;
28
+
ws_handler : Server.ws_handler option;
29
+
}
30
+
31
+
let create config = { config; plugs = []; router = None; ws_handler = None }
32
+
let plug t p = { t with plugs = p :: t.plugs }
33
+
let router t r = { t with router = Some r }
34
+
let websocket t handler = { t with ws_handler = Some handler }
35
+
let not_found_handler _req = Server.respond ~status:`Not_found "Not Found"
36
+
37
+
let health_handler _req =
38
+
Server.respond ~status:`OK ~headers:[ ("Content-Type", "text/plain") ] "ok"
39
+
40
+
let build_handler t =
41
+
let base_handler =
42
+
match t.router with
43
+
| None -> not_found_handler
44
+
| Some r -> (
45
+
fun req ->
46
+
let path =
47
+
match String.index_opt req.Server.target '?' with
48
+
| Some i -> String.sub req.target 0 i
49
+
| None -> req.target
50
+
in
51
+
match Router.lookup r ~method_:req.meth ~path with
52
+
| Some (handler, params) -> handler params req
53
+
| None -> not_found_handler req)
54
+
in
55
+
let with_health =
56
+
if t.config.health_check then fun req ->
57
+
if req.Server.target = "/_health" || req.Server.target = "/health" then
58
+
health_handler req
59
+
else base_handler req
60
+
else base_handler
61
+
in
62
+
let pipeline = Plug.compose_all (List.rev t.plugs) in
63
+
Plug.apply pipeline with_health
64
+
65
+
let start t ~env =
66
+
let handler = build_handler t in
67
+
let server_config =
68
+
{
69
+
Server.default_config with
70
+
port = t.config.port;
71
+
host = t.config.bind;
72
+
domain_count = t.config.domains;
73
+
protocol = t.config.protocol;
74
+
tls = t.config.tls;
75
+
}
76
+
in
77
+
Eio.Switch.run @@ fun sw ->
78
+
let net = Eio.Stdenv.net env in
79
+
if t.config.domains > 1 then
80
+
let domain_mgr = Eio.Stdenv.domain_mgr env in
81
+
Server.run_parallel ~sw ~net ~domain_mgr ~config:server_config
82
+
?ws_handler:t.ws_handler handler
83
+
else
84
+
Server.run ~sw ~net ~config:server_config ?ws_handler:t.ws_handler handler
+9
lib/hcs.ml
+9
lib/hcs.ml
···
18
18
module Plug = Plug
19
19
(** Plug-based middleware system (Phoenix-style) *)
20
20
21
+
module Endpoint = Endpoint
22
+
(** Application bootstrap tying router, plugs, and server *)
23
+
21
24
module Middleware = Middleware
22
25
(** Generic middleware composition (for non-HTTP use cases) *)
23
26
···
50
53
51
54
module Websocket = Websocket
52
55
(** WebSocket support *)
56
+
57
+
module Pubsub = Pubsub
58
+
(** Lock-free topic-based pub/sub messaging *)
59
+
60
+
module Channel = Channel
61
+
(** WebSocket channel abstraction with topic subscriptions *)
53
62
54
63
module Codec = Codec
55
64
(** Codec system for serialization/deserialization *)
+3
lib/plug.ml
+3
lib/plug.ml
+401
lib/plug/negotiate.ml
+401
lib/plug/negotiate.ml
···
1
+
(** Content negotiation plug.
2
+
3
+
Parses Accept headers and selects response format based on client
4
+
preferences and server capabilities. Follows RFC 7231 content negotiation
5
+
semantics.
6
+
7
+
{1 Usage}
8
+
9
+
{[
10
+
(* In router pipeline *)
11
+
let pipeline =
12
+
Plug.Negotiate.create ~formats:[ Json; Html ] () @> Plug.identity
13
+
14
+
(* In handler *)
15
+
let handler req =
16
+
match get_format req with
17
+
| Some Json -> Response.json {|{"message": "hello"}|}
18
+
| Some Html -> Response.html "<h1>Hello</h1>"
19
+
| _ -> Response.text "hello"
20
+
21
+
(* Or use respond helper *)
22
+
let handler req =
23
+
respond req
24
+
~json:(fun () -> {|{"message": "hello"}|})
25
+
~html:(fun () -> "<h1>Hello</h1>")
26
+
]} *)
27
+
28
+
(** {1 Types} *)
29
+
30
+
type media_type = {
31
+
type_ : string; (** e.g., "application" *)
32
+
subtype : string; (** e.g., "json" *)
33
+
quality : float; (** 0.0 - 1.0, default 1.0 *)
34
+
params : (string * string) list; (** Additional parameters *)
35
+
}
36
+
(** Parsed media type from Accept header *)
37
+
38
+
(** Response format - common formats plus custom *)
39
+
type format =
40
+
| Json
41
+
| Html
42
+
| Text
43
+
| Xml
44
+
| Csv
45
+
| Custom of string (** Custom MIME type *)
46
+
47
+
(** Content negotiation errors *)
48
+
type error = Not_acceptable
49
+
50
+
exception Not_acceptable_exn
51
+
52
+
(** {1 Format Utilities} *)
53
+
54
+
(** Get MIME type string for a format *)
55
+
let mime_type_of_format = function
56
+
| Json -> "application/json"
57
+
| Html -> "text/html"
58
+
| Text -> "text/plain"
59
+
| Xml -> "application/xml"
60
+
| Csv -> "text/csv"
61
+
| Custom s -> s
62
+
63
+
(** Get format from MIME type string *)
64
+
let format_of_mime_type s =
65
+
match String.lowercase_ascii s with
66
+
| "application/json" -> Some Json
67
+
| "text/json" -> Some Json
68
+
| "text/html" -> Some Html
69
+
| "text/plain" -> Some Text
70
+
| "application/xml" -> Some Xml
71
+
| "text/xml" -> Some Xml
72
+
| "text/csv" -> Some Csv
73
+
| _ -> Some (Custom s)
74
+
75
+
(** Get short name for format (for internal use) *)
76
+
let format_name = function
77
+
| Json -> "json"
78
+
| Html -> "html"
79
+
| Text -> "text"
80
+
| Xml -> "xml"
81
+
| Csv -> "csv"
82
+
| Custom s -> s
83
+
84
+
(** {1 Accept Header Parsing} *)
85
+
86
+
(** Skip whitespace in string starting at position i *)
87
+
let skip_ws s i =
88
+
let len = String.length s in
89
+
let rec loop i =
90
+
if i < len && (s.[i] = ' ' || s.[i] = '\t') then loop (i + 1) else i
91
+
in
92
+
loop i
93
+
94
+
(** Parse a token (sequence of non-separator chars) *)
95
+
let parse_token s i =
96
+
let len = String.length s in
97
+
let rec loop j =
98
+
if j >= len then j
99
+
else
100
+
match s.[j] with
101
+
| ' ' | '\t' | ',' | ';' | '=' | '/' -> j
102
+
| _ -> loop (j + 1)
103
+
in
104
+
let j = loop i in
105
+
(String.sub s i (j - i), j)
106
+
107
+
(** Parse a quoted string *)
108
+
let parse_quoted s i =
109
+
let len = String.length s in
110
+
if i >= len || s.[i] <> '"' then ("", i)
111
+
else
112
+
let buf = Buffer.create 32 in
113
+
let rec loop j =
114
+
if j >= len then j
115
+
else if s.[j] = '"' then j + 1
116
+
else if s.[j] = '\\' && j + 1 < len then begin
117
+
Buffer.add_char buf s.[j + 1];
118
+
loop (j + 2)
119
+
end
120
+
else begin
121
+
Buffer.add_char buf s.[j];
122
+
loop (j + 1)
123
+
end
124
+
in
125
+
let j = loop (i + 1) in
126
+
(Buffer.contents buf, j)
127
+
128
+
(** Parse a parameter value (token or quoted-string) *)
129
+
let parse_value s i =
130
+
let i = skip_ws s i in
131
+
if i < String.length s && s.[i] = '"' then parse_quoted s i
132
+
else parse_token s i
133
+
134
+
(** Parse parameters after media type: ;name=value pairs *)
135
+
let parse_params s i =
136
+
let len = String.length s in
137
+
let rec loop i acc quality =
138
+
let i = skip_ws s i in
139
+
if i >= len || s.[i] = ',' then (List.rev acc, quality, i)
140
+
else if s.[i] = ';' then begin
141
+
let i = skip_ws s (i + 1) in
142
+
let name, i = parse_token s i in
143
+
let name = String.lowercase_ascii name in
144
+
let i = skip_ws s i in
145
+
if i < len && s.[i] = '=' then begin
146
+
let value, i = parse_value s (i + 1) in
147
+
if name = "q" then
148
+
let q = try Float.of_string value with _ -> 1.0 in
149
+
let q = Float.max 0.0 (Float.min 1.0 q) in
150
+
loop i acc q
151
+
else loop i ((name, value) :: acc) quality
152
+
end
153
+
else loop i acc quality
154
+
end
155
+
else (List.rev acc, quality, i)
156
+
in
157
+
loop i [] 1.0
158
+
159
+
(** Parse a single media type entry *)
160
+
let parse_media_type s i =
161
+
let i = skip_ws s i in
162
+
let type_, i = parse_token s i in
163
+
let type_ = String.lowercase_ascii type_ in
164
+
let i = skip_ws s i in
165
+
if i >= String.length s || s.[i] <> '/' then None
166
+
else begin
167
+
let subtype, i = parse_token s (i + 1) in
168
+
let subtype = String.lowercase_ascii subtype in
169
+
let params, quality, i = parse_params s i in
170
+
Some ({ type_; subtype; quality; params }, i)
171
+
end
172
+
173
+
(** Parse Accept header into list of media types, sorted by quality (highest
174
+
first).
175
+
176
+
Handles:
177
+
- Multiple types separated by commas
178
+
- Quality values (q=0.8)
179
+
- Parameters (charset=utf-8)
180
+
- Wildcards (* / * and type/ *)
181
+
182
+
@param header Accept header value
183
+
@return List of media types sorted by quality descending *)
184
+
let parse_accept (header : string) : media_type list =
185
+
let len = String.length header in
186
+
let rec loop i acc =
187
+
if i >= len then acc
188
+
else
189
+
match parse_media_type header i with
190
+
| None ->
191
+
(* Skip to next comma *)
192
+
let rec skip j =
193
+
if j >= len || header.[j] = ',' then j + 1 else skip (j + 1)
194
+
in
195
+
loop (skip i) acc
196
+
| Some (mt, i) ->
197
+
let i = skip_ws header i in
198
+
let i = if i < len && header.[i] = ',' then i + 1 else i in
199
+
loop i (mt :: acc)
200
+
in
201
+
let types = loop 0 [] in
202
+
(* Sort by quality descending, then by specificity (more specific first) *)
203
+
List.sort
204
+
(fun a b ->
205
+
let cmp = Float.compare b.quality a.quality in
206
+
if cmp <> 0 then cmp
207
+
else
208
+
(* More specific types have higher priority *)
209
+
let specificity mt =
210
+
if mt.type_ = "*" then 0
211
+
else if mt.subtype = "*" then 1
212
+
else 2 + List.length mt.params
213
+
in
214
+
Int.compare (specificity b) (specificity a))
215
+
types
216
+
217
+
(** {1 Negotiation} *)
218
+
219
+
(** Check if a media type matches a format *)
220
+
let media_type_matches (mt : media_type) (format : format) : bool =
221
+
if mt.type_ = "*" && mt.subtype = "*" then true
222
+
else
223
+
let mime = mime_type_of_format format in
224
+
match String.split_on_char '/' mime with
225
+
| [ t; s ] ->
226
+
(mt.type_ = "*" || mt.type_ = t) && (mt.subtype = "*" || mt.subtype = s)
227
+
| _ -> false
228
+
229
+
(** Negotiate best format from Accept header.
230
+
231
+
@param accept Accept header value
232
+
@param available List of formats the server can produce
233
+
@return Best matching format or None if no match *)
234
+
let negotiate ~accept ~available : format option =
235
+
if available = [] then None
236
+
else if accept = "" || accept = "*/*" then Some (List.hd available)
237
+
else
238
+
let types = parse_accept accept in
239
+
(* Find first accepted type that matches an available format *)
240
+
let rec find_match types =
241
+
match types with
242
+
| [] -> None
243
+
| mt :: rest -> (
244
+
if mt.quality <= 0.0 then find_match rest
245
+
else
246
+
(* Find first available format matching this media type *)
247
+
let matching =
248
+
List.find_opt (fun fmt -> media_type_matches mt fmt) available
249
+
in
250
+
match matching with
251
+
| Some _ as result -> result
252
+
| None -> find_match rest)
253
+
in
254
+
find_match types
255
+
256
+
(** Negotiate best format, raising exception if no match.
257
+
258
+
@param accept Accept header value
259
+
@param available List of formats the server can produce
260
+
@return Best matching format
261
+
@raise Not_acceptable_exn if no format matches *)
262
+
let negotiate_exn ~accept ~available : format =
263
+
match negotiate ~accept ~available with
264
+
| Some fmt -> fmt
265
+
| None -> raise Not_acceptable_exn
266
+
267
+
(** {1 Request Format Storage} *)
268
+
269
+
(** Key for storing negotiated format in request. We store format as a header
270
+
since requests are immutable records. *)
271
+
let format_header = "x-hcs-negotiated-format"
272
+
273
+
(** Get negotiated format from request (set by Negotiate plug) *)
274
+
let get_format (req : Server.request) : format option =
275
+
match
276
+
List.find_opt
277
+
(fun (n, _) -> String.lowercase_ascii n = format_header)
278
+
req.headers
279
+
with
280
+
| None -> None
281
+
| Some (_, v) -> (
282
+
match v with
283
+
| "json" -> Some Json
284
+
| "html" -> Some Html
285
+
| "text" -> Some Text
286
+
| "xml" -> Some Xml
287
+
| "csv" -> Some Csv
288
+
| s -> Some (Custom s))
289
+
290
+
(** Set negotiated format on request *)
291
+
let set_format (req : Server.request) (fmt : format) : Server.request =
292
+
let headers =
293
+
(format_header, format_name fmt)
294
+
:: List.filter
295
+
(fun (n, _) -> String.lowercase_ascii n <> format_header)
296
+
req.headers
297
+
in
298
+
{ req with headers }
299
+
300
+
(** {1 Response Helpers} *)
301
+
302
+
(** Create response with correct Content-Type for format *)
303
+
let respond_format (fmt : format) ~(body : string) : Server.response =
304
+
let content_type =
305
+
match fmt with
306
+
| Json -> "application/json; charset=utf-8"
307
+
| Html -> "text/html; charset=utf-8"
308
+
| Text -> "text/plain; charset=utf-8"
309
+
| Xml -> "application/xml; charset=utf-8"
310
+
| Csv -> "text/csv; charset=utf-8"
311
+
| Custom s -> s
312
+
in
313
+
{
314
+
status = `OK;
315
+
headers =
316
+
[
317
+
("Content-Type", content_type);
318
+
("Content-Length", string_of_int (String.length body));
319
+
];
320
+
body = Server.Body_string body;
321
+
}
322
+
323
+
(** Respond based on negotiated format with lazy body generation. Only evaluates
324
+
the body function for the selected format.
325
+
326
+
@param req Request with negotiated format
327
+
@param json Function to generate JSON body
328
+
@param html Function to generate HTML body
329
+
@param text Optional function for plain text (defaults to json)
330
+
@param xml Optional function for XML
331
+
@return Response with appropriate body and Content-Type *)
332
+
let respond (req : Server.request) ?json ?html ?text ?xml () : Server.response =
333
+
let fmt = get_format req in
334
+
match fmt with
335
+
| Some Json -> (
336
+
match json with
337
+
| Some f -> respond_format Json ~body:(f ())
338
+
| None -> respond_format Text ~body:"")
339
+
| Some Html -> (
340
+
match html with
341
+
| Some f -> respond_format Html ~body:(f ())
342
+
| None -> (
343
+
match json with
344
+
| Some f -> respond_format Json ~body:(f ())
345
+
| None -> respond_format Text ~body:""))
346
+
| Some Text -> (
347
+
match text with
348
+
| Some f -> respond_format Text ~body:(f ())
349
+
| None -> (
350
+
match json with
351
+
| Some f -> respond_format Json ~body:(f ())
352
+
| None -> respond_format Text ~body:""))
353
+
| Some Xml -> (
354
+
match xml with
355
+
| Some f -> respond_format Xml ~body:(f ())
356
+
| None -> respond_format Text ~body:"")
357
+
| Some Csv -> respond_format Csv ~body:""
358
+
| Some (Custom _) | None -> (
359
+
(* Default to first available *)
360
+
match json with
361
+
| Some f -> respond_format Json ~body:(f ())
362
+
| None -> (
363
+
match html with
364
+
| Some f -> respond_format Html ~body:(f ())
365
+
| None -> respond_format Text ~body:""))
366
+
367
+
(** {1 Plug} *)
368
+
369
+
(** Create content negotiation plug.
370
+
371
+
Parses the Accept header and determines the best response format. If no
372
+
acceptable format is found, returns 406 Not Acceptable.
373
+
374
+
@param formats List of formats this endpoint can produce
375
+
@return Plug that sets negotiated format on request *)
376
+
let create ~(formats : format list) () : Core.t =
377
+
fun handler req ->
378
+
let accept =
379
+
List.find_opt
380
+
(fun (n, _) -> String.lowercase_ascii n = "accept")
381
+
req.headers
382
+
|> Option.map snd
383
+
|> Option.value ~default:"*/*"
384
+
in
385
+
match negotiate ~accept ~available:formats with
386
+
| None ->
387
+
(* 406 Not Acceptable *)
388
+
let acceptable =
389
+
String.concat ", " (List.map mime_type_of_format formats)
390
+
in
391
+
{
392
+
status = `Not_acceptable;
393
+
headers =
394
+
[
395
+
("Content-Type", "text/plain; charset=utf-8"); ("Accept", acceptable);
396
+
];
397
+
body = Server.Body_string "Not Acceptable";
398
+
}
399
+
| Some fmt ->
400
+
let req = set_format req fmt in
401
+
handler req
+223
lib/plug/session.ml
+223
lib/plug/session.ml
···
1
+
(** Cookie-based session management with fiber-local isolation.
2
+
3
+
{[
4
+
let store = Session.Memory_store.create () in
5
+
let session_plug = Session.create ~store () in
6
+
7
+
let handler req =
8
+
match Session.get "user_id" with
9
+
| Some uid -> Session.put "last_seen" (string_of_float now); ...
10
+
| None -> ...
11
+
]} *)
12
+
13
+
type session = {
14
+
id : string;
15
+
data : (string * string) list;
16
+
created_at : float;
17
+
}
18
+
19
+
type store =
20
+
| Memory of (string, session) Kcas_data.Hashtbl.t
21
+
| Cookie of { secret : string; max_age : float }
22
+
23
+
type session_ctx = {
24
+
mutable data : (string * string) list;
25
+
mutable modified : bool;
26
+
}
27
+
28
+
let session_key : session_ctx Eio.Fiber.key = Eio.Fiber.create_key ()
29
+
30
+
let get_ctx () =
31
+
match Eio.Fiber.get session_key with
32
+
| Some ctx -> ctx
33
+
| None -> failwith "Session: not in session scope (missing Session plug)"
34
+
35
+
module Memory_store = struct
36
+
let create () = Memory (Kcas_data.Hashtbl.create ())
37
+
38
+
let cleanup ?(max_age = 86400.0) store =
39
+
match store with
40
+
| Memory tbl ->
41
+
let now = Unix.gettimeofday () in
42
+
Kcas_data.Hashtbl.filter_map_inplace
43
+
(fun _ sess ->
44
+
if now -. sess.created_at > max_age then None else Some sess)
45
+
tbl
46
+
| Cookie _ -> ()
47
+
end
48
+
49
+
module Cookie_store = struct
50
+
let create ~secret ?(max_age = 86400.0) () = Cookie { secret; max_age }
51
+
52
+
let serialize data =
53
+
List.map (fun (k, v) -> Token.b64_encode k ^ "=" ^ Token.b64_encode v) data
54
+
|> String.concat "&"
55
+
56
+
let deserialize s =
57
+
if s = "" then []
58
+
else
59
+
String.split_on_char '&' s
60
+
|> List.filter_map (fun pair ->
61
+
match String.index_opt pair '=' with
62
+
| Some i -> (
63
+
let k_b64 = String.sub pair 0 i in
64
+
let v_b64 =
65
+
String.sub pair (i + 1) (String.length pair - i - 1)
66
+
in
67
+
match (Token.b64_decode k_b64, Token.b64_decode v_b64) with
68
+
| Ok k, Ok v -> Some (k, v)
69
+
| _ -> None)
70
+
| None -> None)
71
+
72
+
let encrypt ~secret ~max_age data =
73
+
Token.encrypt ~secret ~data:(serialize data) ~max_age
74
+
75
+
let decrypt ~secret ~max_age token =
76
+
match Token.decrypt ~secret ~token ~max_age with
77
+
| Ok data -> Some (deserialize data)
78
+
| Error _ -> None
79
+
end
80
+
81
+
let get key =
82
+
let ctx = get_ctx () in
83
+
List.assoc_opt key ctx.data
84
+
85
+
let get_with_req _req key = get key
86
+
87
+
let put key value =
88
+
let ctx = get_ctx () in
89
+
ctx.modified <- true;
90
+
ctx.data <- (key, value) :: List.filter (fun (k, _) -> k <> key) ctx.data
91
+
92
+
let put_with_req _req key value = put key value
93
+
94
+
let delete key =
95
+
let ctx = get_ctx () in
96
+
ctx.modified <- true;
97
+
ctx.data <- List.filter (fun (k, _) -> k <> key) ctx.data
98
+
99
+
let delete_with_req _req key = delete key
100
+
101
+
let clear () =
102
+
let ctx = get_ctx () in
103
+
ctx.modified <- true;
104
+
ctx.data <- []
105
+
106
+
let clear_with_req _req = clear ()
107
+
108
+
let get_all () =
109
+
let ctx = get_ctx () in
110
+
ctx.data
111
+
112
+
let get_all_with_req _req = get_all ()
113
+
114
+
type config = {
115
+
cookie_name : string;
116
+
store : store;
117
+
secure : bool;
118
+
same_site : [ `Strict | `Lax | `None ];
119
+
http_only : bool;
120
+
path : string;
121
+
max_age : float;
122
+
}
123
+
124
+
let parse_cookie ~cookie_name headers =
125
+
match
126
+
List.find_opt (fun (n, _) -> String.lowercase_ascii n = "cookie") headers
127
+
with
128
+
| None -> None
129
+
| Some (_, cookies) ->
130
+
String.split_on_char ';' cookies
131
+
|> List.find_map (fun pair ->
132
+
let pair = String.trim pair in
133
+
match String.index_opt pair '=' with
134
+
| None -> None
135
+
| Some i ->
136
+
let name = String.sub pair 0 i in
137
+
if name = cookie_name then
138
+
Some (String.sub pair (i + 1) (String.length pair - i - 1))
139
+
else None)
140
+
141
+
let make_cookie ~config value =
142
+
let same_site =
143
+
match config.same_site with
144
+
| `Strict -> "; SameSite=Strict"
145
+
| `Lax -> "; SameSite=Lax"
146
+
| `None -> "; SameSite=None"
147
+
in
148
+
Printf.sprintf "%s=%s; Path=%s; Max-Age=%.0f%s%s%s" config.cookie_name value
149
+
config.path config.max_age
150
+
(if config.secure then "; Secure" else "")
151
+
(if config.http_only then "; HttpOnly" else "")
152
+
same_site
153
+
154
+
let generate_id () = Token.b64_encode (Mirage_crypto_rng.generate 16)
155
+
156
+
(** Create session plug with configurable storage and cookie options. *)
157
+
let create ~store ?(cookie_name = "_session") ?(secure = true)
158
+
?(same_site = `Lax) ?(http_only = true) ?(path = "/") ?(max_age = 86400.0)
159
+
() : Core.t =
160
+
let config =
161
+
{ cookie_name; store; secure; same_site; http_only; path; max_age }
162
+
in
163
+
fun handler req ->
164
+
let existing_id =
165
+
parse_cookie ~cookie_name:config.cookie_name req.Server.headers
166
+
in
167
+
let initial_data =
168
+
match config.store with
169
+
| Memory tbl -> (
170
+
match existing_id with
171
+
| Some id -> (
172
+
match Kcas_data.Hashtbl.find_opt tbl id with
173
+
| Some sess -> sess.data
174
+
| None -> [])
175
+
| None -> [])
176
+
| Cookie { secret; max_age } -> (
177
+
match existing_id with
178
+
| Some token -> (
179
+
match Cookie_store.decrypt ~secret ~max_age token with
180
+
| Some data -> data
181
+
| None -> [])
182
+
| None -> [])
183
+
in
184
+
let ctx = { data = initial_data; modified = false } in
185
+
let resp = Eio.Fiber.with_binding session_key ctx (fun () -> handler req) in
186
+
if not ctx.modified then resp
187
+
else
188
+
match config.store with
189
+
| Memory tbl ->
190
+
let session_id =
191
+
match existing_id with
192
+
| Some id when Kcas_data.Hashtbl.mem tbl id -> id
193
+
| _ -> generate_id ()
194
+
in
195
+
if ctx.data <> [] then begin
196
+
Kcas_data.Hashtbl.replace tbl session_id
197
+
{
198
+
id = session_id;
199
+
data = ctx.data;
200
+
created_at = Unix.gettimeofday ();
201
+
};
202
+
let cookie = make_cookie ~config session_id in
203
+
{
204
+
resp with
205
+
Server.headers = ("Set-Cookie", cookie) :: resp.headers;
206
+
}
207
+
end
208
+
else begin
209
+
(match existing_id with
210
+
| Some id -> Kcas_data.Hashtbl.remove tbl id
211
+
| None -> ());
212
+
resp
213
+
end
214
+
| Cookie { secret; max_age } ->
215
+
if ctx.data <> [] then begin
216
+
let token = Cookie_store.encrypt ~secret ~max_age ctx.data in
217
+
let cookie = make_cookie ~config token in
218
+
{
219
+
resp with
220
+
Server.headers = ("Set-Cookie", cookie) :: resp.headers;
221
+
}
222
+
end
223
+
else resp
+217
lib/plug/token.ml
+217
lib/plug/token.ml
···
1
+
(** Signed and encrypted tokens for authentication.
2
+
3
+
Provides secure token generation and verification for auth tokens, password
4
+
reset, email verification, etc.
5
+
6
+
Token format:
7
+
- signed: base64(data) . timestamp . base64(hmac)
8
+
- encrypted: base64(nonce + ciphertext + tag) . timestamp . base64(hmac) *)
9
+
10
+
(** Error types for token operations *)
11
+
type error =
12
+
| Expired (** Token has exceeded max_age *)
13
+
| Invalid (** Token format invalid or signature mismatch *)
14
+
| Tampered (** Token data has been modified *)
15
+
16
+
let error_to_string = function
17
+
| Expired -> "token expired"
18
+
| Invalid -> "invalid token"
19
+
| Tampered -> "token tampered"
20
+
21
+
(** {1 Internal helpers} *)
22
+
23
+
(** Get current Unix timestamp *)
24
+
let now () = Unix.gettimeofday ()
25
+
26
+
(** Encode timestamp as compact varint-like format (8 bytes max) *)
27
+
let encode_timestamp ts =
28
+
let ts_int = Int64.of_float ts in
29
+
let buf = Buffer.create 8 in
30
+
let rec encode v =
31
+
if Int64.compare v 0L = 0 then ()
32
+
else begin
33
+
Buffer.add_char buf (Char.chr (Int64.to_int (Int64.logand v 0xFFL)));
34
+
encode (Int64.shift_right_logical v 8)
35
+
end
36
+
in
37
+
if Int64.compare ts_int 0L = 0 then Buffer.add_char buf '\000'
38
+
else encode ts_int;
39
+
Buffer.contents buf
40
+
41
+
(** Decode timestamp from bytes *)
42
+
let decode_timestamp s =
43
+
let len = String.length s in
44
+
let rec decode acc shift i =
45
+
if i >= len then acc
46
+
else
47
+
let byte = Int64.of_int (Char.code s.[i]) in
48
+
decode (Int64.logor acc (Int64.shift_left byte shift)) (shift + 8) (i + 1)
49
+
in
50
+
Int64.to_float (decode 0L 0 0)
51
+
52
+
(** Constant-time string comparison to prevent timing attacks *)
53
+
let secure_compare a b =
54
+
let len_a = String.length a in
55
+
let len_b = String.length b in
56
+
if len_a <> len_b then false
57
+
else
58
+
let result = ref 0 in
59
+
for i = 0 to len_a - 1 do
60
+
result := !result lor (Char.code a.[i] lxor Char.code b.[i])
61
+
done;
62
+
!result = 0
63
+
64
+
(** Compute HMAC-SHA256 *)
65
+
let hmac ~key data =
66
+
Digestif.SHA256.hmac_string ~key data |> Digestif.SHA256.to_raw_string
67
+
68
+
(** Base64 URL-safe encoding (no padding) *)
69
+
let b64_encode s =
70
+
Base64.encode_string ~pad:false ~alphabet:Base64.uri_safe_alphabet s
71
+
72
+
(** Base64 URL-safe decoding *)
73
+
let b64_decode s =
74
+
(* Add padding if needed *)
75
+
let len = String.length s in
76
+
let padded = match len mod 4 with 2 -> s ^ "==" | 3 -> s ^ "=" | _ -> s in
77
+
Base64.decode ~alphabet:Base64.uri_safe_alphabet padded
78
+
79
+
(** {1 Signed tokens} *)
80
+
81
+
(** Sign data with HMAC-SHA256.
82
+
83
+
Format: base64(data).base64(timestamp).base64(hmac)
84
+
85
+
The HMAC covers both data and timestamp to prevent replay attacks with
86
+
modified timestamps.
87
+
88
+
@param secret Shared secret key (should be at least 32 bytes)
89
+
@param data Payload to sign (arbitrary string)
90
+
@param max_age Maximum token age in seconds
91
+
@return URL-safe signed token string *)
92
+
let sign ~secret ~data ~max_age:_ =
93
+
let ts = now () in
94
+
let ts_bytes = encode_timestamp ts in
95
+
let data_b64 = b64_encode data in
96
+
let ts_b64 = b64_encode ts_bytes in
97
+
(* HMAC covers data and timestamp *)
98
+
let to_sign = data_b64 ^ "." ^ ts_b64 in
99
+
let signature = hmac ~key:secret to_sign in
100
+
let sig_b64 = b64_encode signature in
101
+
data_b64 ^ "." ^ ts_b64 ^ "." ^ sig_b64
102
+
103
+
(** Verify a signed token.
104
+
105
+
@param secret Shared secret key (must match signing key)
106
+
@param token The signed token to verify
107
+
@param max_age Maximum allowed age in seconds
108
+
@return Ok(data) if valid, Error(reason) otherwise *)
109
+
let verify ~secret ~token ~max_age =
110
+
match String.split_on_char '.' token with
111
+
| [ data_b64; ts_b64; sig_b64 ] -> (
112
+
(* Decode signature and verify first (fail fast on tampering) *)
113
+
match (b64_decode sig_b64, b64_decode data_b64, b64_decode ts_b64) with
114
+
| Ok signature, Ok data, Ok ts_bytes ->
115
+
let to_sign = data_b64 ^ "." ^ ts_b64 in
116
+
let expected = hmac ~key:secret to_sign in
117
+
if not (secure_compare expected signature) then Error Tampered
118
+
else
119
+
let ts = decode_timestamp ts_bytes in
120
+
let age = now () -. ts in
121
+
if age > max_age then Error Expired
122
+
else if age < -60.0 then
123
+
(* Allow 60s clock skew, but reject future tokens *)
124
+
Error Invalid
125
+
else Ok data
126
+
| _ -> Error Invalid)
127
+
| _ -> Error Invalid
128
+
129
+
(** {1 Encrypted tokens} *)
130
+
131
+
(** Encrypt and sign data using AES-256-GCM.
132
+
133
+
Format: base64(nonce + ciphertext + tag).base64(timestamp).base64(hmac)
134
+
135
+
Encryption uses AES-256-GCM for authenticated encryption. The HMAC provides
136
+
an additional layer of authentication over the encrypted blob and timestamp.
137
+
138
+
@param secret Shared secret key (first 32 bytes for AES, full key for HMAC)
139
+
@param data Payload to encrypt
140
+
@param max_age Maximum token age in seconds
141
+
@return URL-safe encrypted token string *)
142
+
let encrypt ~secret ~data ~max_age:_ =
143
+
let ts = now () in
144
+
let ts_bytes = encode_timestamp ts in
145
+
let aes_key =
146
+
if String.length secret >= 32 then String.sub secret 0 32
147
+
else Digestif.SHA256.(digest_string secret |> to_raw_string)
148
+
in
149
+
let nonce = Mirage_crypto_rng.generate 12 in
150
+
let key = Mirage_crypto.AES.GCM.of_secret aes_key in
151
+
let ciphertext =
152
+
Mirage_crypto.AES.GCM.authenticate_encrypt ~key ~nonce data
153
+
in
154
+
let encrypted = nonce ^ ciphertext in
155
+
let encrypted_b64 = b64_encode encrypted in
156
+
let ts_b64 = b64_encode ts_bytes in
157
+
let to_sign = encrypted_b64 ^ "." ^ ts_b64 in
158
+
let signature = hmac ~key:secret to_sign in
159
+
let sig_b64 = b64_encode signature in
160
+
encrypted_b64 ^ "." ^ ts_b64 ^ "." ^ sig_b64
161
+
162
+
(** Decrypt and verify an encrypted token.
163
+
164
+
@param secret Shared secret key (must match encryption key)
165
+
@param token The encrypted token to decrypt
166
+
@param max_age Maximum allowed age in seconds
167
+
@return Ok(data) if valid, Error(reason) otherwise *)
168
+
let decrypt ~secret ~token ~max_age =
169
+
match String.split_on_char '.' token with
170
+
| [ encrypted_b64; ts_b64; sig_b64 ] -> (
171
+
match
172
+
(b64_decode sig_b64, b64_decode encrypted_b64, b64_decode ts_b64)
173
+
with
174
+
| Ok signature, Ok encrypted, Ok ts_bytes -> (
175
+
let to_sign = encrypted_b64 ^ "." ^ ts_b64 in
176
+
let expected = hmac ~key:secret to_sign in
177
+
if not (secure_compare expected signature) then Error Tampered
178
+
else
179
+
let ts = decode_timestamp ts_bytes in
180
+
let age = now () -. ts in
181
+
if age > max_age then Error Expired
182
+
else if age < -60.0 then Error Invalid
183
+
else if String.length encrypted < 28 then Error Invalid
184
+
else
185
+
let nonce = String.sub encrypted 0 12 in
186
+
let ciphertext =
187
+
String.sub encrypted 12 (String.length encrypted - 12)
188
+
in
189
+
let aes_key =
190
+
if String.length secret >= 32 then String.sub secret 0 32
191
+
else Digestif.SHA256.(digest_string secret |> to_raw_string)
192
+
in
193
+
let key = Mirage_crypto.AES.GCM.of_secret aes_key in
194
+
match
195
+
Mirage_crypto.AES.GCM.authenticate_decrypt ~key ~nonce
196
+
ciphertext
197
+
with
198
+
| Some plaintext -> Ok plaintext
199
+
| None -> Error Tampered)
200
+
| _ -> Error Invalid)
201
+
| _ -> Error Invalid
202
+
203
+
(** {1 Convenience functions} *)
204
+
205
+
(** Create a signed auth token with user data. Default max_age is 24 hours
206
+
(86400 seconds). *)
207
+
let auth_token ~secret ~user_id = sign ~secret ~data:user_id ~max_age:86400.0
208
+
209
+
(** Create an encrypted password reset token. Default max_age is 1 hour (3600
210
+
seconds). Encrypted so user cannot see/modify the payload. *)
211
+
let reset_token ~secret ~user_id = encrypt ~secret ~data:user_id ~max_age:3600.0
212
+
213
+
(** Verify an auth token, returning the user_id if valid. *)
214
+
let verify_auth ~secret ~token ~max_age = verify ~secret ~token ~max_age
215
+
216
+
(** Verify a reset token, returning the user_id if valid. *)
217
+
let verify_reset ~secret ~token ~max_age = decrypt ~secret ~token ~max_age
+42
lib/pubsub.ml
+42
lib/pubsub.ml
···
1
+
type topic = string
2
+
type message = string
3
+
type subscription = { id : int; topic : topic; callback : message -> unit }
4
+
5
+
type t = {
6
+
topics : (topic, subscription list) Kcas_data.Hashtbl.t;
7
+
next_id : int Atomic.t;
8
+
}
9
+
10
+
let create () =
11
+
{ topics = Kcas_data.Hashtbl.create (); next_id = Atomic.make 0 }
12
+
13
+
let subscribe t topic callback =
14
+
let id = Atomic.fetch_and_add t.next_id 1 in
15
+
let sub = { id; topic; callback } in
16
+
let subs =
17
+
match Kcas_data.Hashtbl.find_opt t.topics topic with
18
+
| Some existing -> sub :: existing
19
+
| None -> [ sub ]
20
+
in
21
+
Kcas_data.Hashtbl.replace t.topics topic subs;
22
+
sub
23
+
24
+
let unsubscribe t sub =
25
+
match Kcas_data.Hashtbl.find_opt t.topics sub.topic with
26
+
| None -> ()
27
+
| Some subs ->
28
+
let remaining = List.filter (fun s -> s.id <> sub.id) subs in
29
+
if remaining = [] then Kcas_data.Hashtbl.remove t.topics sub.topic
30
+
else Kcas_data.Hashtbl.replace t.topics sub.topic remaining
31
+
32
+
let broadcast t topic msg =
33
+
match Kcas_data.Hashtbl.find_opt t.topics topic with
34
+
| None -> ()
35
+
| Some subs -> List.iter (fun sub -> sub.callback msg) subs
36
+
37
+
let subscriber_count t topic =
38
+
match Kcas_data.Hashtbl.find_opt t.topics topic with
39
+
| None -> 0
40
+
| Some subs -> List.length subs
41
+
42
+
let topics t = Kcas_data.Hashtbl.to_seq_keys t.topics |> List.of_seq