Source file html5_history.ml
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
open Core
module Dom = Js_of_ocaml.Dom
module Dom_html = Js_of_ocaml.Dom_html
module Js = Js_of_ocaml.Js
let uri_to_html5_history_string uri =
Uri.make
~scheme:"https"
~path:(Uri.path uri)
~query:(Uri.query uri)
?fragment:(Uri.fragment uri)
()
|> Uri.canonicalize
|> (fun uri -> Uri.with_scheme uri None)
|> Uri.to_string
;;
module T = struct
module type Payload = sig
type t [@@deriving bin_io]
end
module Entry = struct
type 'p t =
{ payload : 'p option
; uri : Uri.t
}
end
type 'p t =
{ payload_module : (module Payload with type t = 'p)
; payload_bin_shape : string
; popstate_bus : ('p Entry.t -> unit, read_write) Bus.t
; log_s : Sexp.t -> unit
}
let log_s t sexp : unit = t.log_s sexp
let initialised = ref false
let convert_state (type p) (t : p t) (state : Js.Unsafe.top Js.t Js.opt) =
let result =
match Js.Opt.to_option state with
| None ->
error_s
[%message
"Html5_history" "history state was null, presumably due to initial page load"]
| Some state ->
let (module Payload : Payload with type t = p) = t.payload_module in
let get_string (x : _ Js.t) key =
match Js.Optdef.to_option (Js.Unsafe.get x (Js.string key)) with
| None -> None
| Some value ->
(match Js.to_string (Js.typeof value) with
| "string" -> Some (Js.to_string value)
| _ -> None)
in
(match get_string state "bin_shape", get_string state "payload_v1" with
| None, _ | _, None ->
let state =
match Js_of_ocaml.Json.output state with
| exception exn -> [%sexp "failed to turn state into JSON", (exn : exn)]
| string -> [%sexp (Js.to_string string : string)]
in
error_s
[%message
"Html5_history"
"history state non-null, but bin_shape or payload missing"
(state : Sexp.t)]
| Some saved_shape, Some payload ->
(match String.equal saved_shape t.payload_bin_shape with
| false ->
error_s
[%message
"Html5_history"
"history event bin shape mismatch"
~saved_shape
~expected:t.payload_bin_shape]
| true ->
Or_error.try_with (fun () ->
let payload = Base64.decode_exn payload in
Binable.of_string (module Payload) payload)))
in
match result with
| Ok state -> Some state
| Error error ->
log_s t [%sexp (error : Error.t)];
None
;;
let current_uri () =
let string = Js.to_string Dom_html.window##.location##.href in
match Uri.of_string string with
| uri -> uri
| exception exn ->
raise_s
[%message
"Html5_history" "BUG: browser gave us a URI we can't parse" string (exn : exn)]
;;
let init_exn (type p) ?(log_s = ignore) payload_module =
(match !initialised with
| true -> failwith "You called Html5_history.init_exn twice"
| false -> initialised := true);
let (module Payload : Payload with type t = p) = payload_module in
let payload_bin_shape =
Bin_prot.Shape.eval_to_digest_string [%bin_shape: Payload.t]
in
let popstate_bus =
Bus.create_exn
[%here]
Arity1
~on_subscription_after_first_write:Allow
~on_callback_raise:Error.raise
in
let t = { payload_module; payload_bin_shape; popstate_bus; log_s } in
let (_ : Dom_html.event_listener_id) =
let handler event =
let payload =
let state : Js.Unsafe.top Js.t Js.opt =
Js.Unsafe.get event (Js.string "state")
in
convert_state t state
in
let uri = current_uri () in
Bus.write popstate_bus { payload; uri };
Js._true
in
Dom.addEventListener
Dom_html.window
Dom_html.Event.popstate
(Dom_html.handler handler)
Js._true
in
t
;;
let popstate_bus t = Bus.read_only t.popstate_bus
let current t =
let payload =
let state : Js.Unsafe.top Js.t Js.opt =
Js.Unsafe.get Dom_html.window##.history (Js.string "state")
in
convert_state t state
in
let uri = current_uri () in
{ Entry.payload; uri }
;;
let push_or_replace (type p) t action ?uri state : unit =
let (module Payload : Payload with type t = p) = t.payload_module in
let payload = Binable.to_string (module Payload) state in
let payload = Base64.encode_exn payload in
let state =
Js.Unsafe.obj
[| "bin_shape", Js.Unsafe.inject (Js.string t.payload_bin_shape)
; "payload_v1", Js.Unsafe.inject (Js.string payload)
|]
in
let title =
Js.string ""
in
let uri =
match uri with
| None -> Js.null
| Some uri -> uri |> uri_to_html5_history_string |> Js.string |> Js.some
in
match action with
| `Replace -> Dom_html.window##.history##replaceState state title uri
| `Push -> Dom_html.window##.history##pushState state title uri
;;
let replace t ?uri p : unit = push_or_replace t `Replace ?uri p
let push t ?uri p : unit = push_or_replace t `Push ?uri p
end
module Opinionated = struct
module Html5_history = T
module type Uri_routing = sig
type t [@@deriving equal, sexp_of]
val parse : Uri.t -> (t, [ `Not_found ]) Result.t
val to_path_and_query : t -> Uri.t
end
module type History_state = sig
type uri_routing
type t [@@deriving bin_io, equal, sexp_of]
val to_uri_routing : t -> uri_routing
val of_uri_routing : uri_routing -> t
end
module type Arg_modules = sig
module Uri_routing : Uri_routing
module History_state : History_state with type uri_routing := Uri_routing.t
end
type 's t =
{ html5_history : 's Html5_history.t
; arg_modules : (module Arg_modules with type History_state.t = 's)
; mutable current_state : 's
; changes_bus : ('s -> unit, read_write) Bus.t
}
let log_s t sexp : unit = Html5_history.log_s t.html5_history sexp
let push_or_replace
(type s)
(module Arg_modules : Arg_modules with type History_state.t = s)
html5_history
action
state
=
let open Arg_modules in
let uri = Uri_routing.to_path_and_query (History_state.to_uri_routing state) in
Html5_history.push_or_replace html5_history action ~uri state
;;
let init_exn
?log_s:log_s_arg
(type u s)
history_state_module
uri_routing_module
~on_bad_uri
=
let module Arg_modules = struct
module History_state =
(val history_state_module
: History_state with type t = s and type uri_routing = u)
module Uri_routing = (val uri_routing_module : Uri_routing with type t = u)
end
in
let open Arg_modules in
let html5_history = Html5_history.init_exn ?log_s:log_s_arg (module History_state) in
let current_state =
let { Html5_history.Entry.payload; uri } = Html5_history.current html5_history in
match payload with
| Some payload ->
Html5_history.log_s
html5_history
[%message
"Html5_history"
"initial history state from state payload"
(payload : History_state.t)];
payload
| None ->
(match Uri_routing.parse uri with
| Ok routing -> History_state.of_uri_routing routing
| Error `Not_found ->
let message =
[%message
"Html5_history"
"The server should not have served up the main HTML file on this uri, \
as it does not route"
~uri:(Uri.to_string uri)]
in
(match on_bad_uri with
| `Raise -> raise_s message
| `Default_state s ->
Html5_history.log_s html5_history message;
s))
in
push_or_replace (module Arg_modules) html5_history `Replace current_state;
let t =
{ html5_history
; arg_modules = (module Arg_modules)
; current_state
; changes_bus =
Bus.create_exn
[%here]
Arity1
~on_subscription_after_first_write:Allow
~on_callback_raise:Error.raise
}
in
let (_ : _ Bus.Subscriber.t) =
let bus = Html5_history.popstate_bus html5_history in
Bus.subscribe_exn bus [%here] ~f:(fun state ->
match state.payload with
| None -> log_s t [%message "Html5_history" "ignored popstate due to no payload"]
| Some payload ->
log_s t [%message "Html5_history" "popstate" ~_:(payload : History_state.t)];
t.current_state <- payload;
Bus.write t.changes_bus payload)
in
t
;;
let current t = t.current_state
let changes_bus t = Bus.read_only t.changes_bus
let update (type s) t next_state : unit =
let (module Arg_modules : Arg_modules with type History_state.t = s) =
t.arg_modules
in
let open Arg_modules in
let prev_state = t.current_state in
let what_do =
match History_state.equal prev_state next_state with
| true ->
`Nothing
| false ->
(match
Uri_routing.equal
(History_state.to_uri_routing prev_state)
(History_state.to_uri_routing next_state)
with
| false -> `Push
| true -> `Replace)
in
t.current_state <- next_state;
match what_do with
| `Nothing -> ()
| (`Replace | `Push) as action ->
log_s
t
[%message
"Html5_history"
"updating history state"
(action : [ `Push | `Replace ])
(prev_state : History_state.t)
(next_state : History_state.t)];
push_or_replace t.arg_modules t.html5_history action next_state
;;
let replace t next_state : unit =
t.current_state <- next_state;
push_or_replace t.arg_modules t.html5_history `Replace next_state
;;
let sync_to_bonsai t ~ ~get_state ~schedule_navigate_to =
let (_ : _ Bus.Subscriber.t) =
Bus.subscribe_exn (changes_bus t) [%here] ~f:schedule_navigate_to
in
let (_ : _ Bus.Subscriber.t) =
Bus.subscribe_exn extra_bus [%here] ~f:(fun next ->
match get_state next with
| Error `Uninitialised -> ()
| Ok next -> update t next)
in
()
;;
end
include T