elm-ssr 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,38 @@
1
+ port module ElmSsr.Island.Shared exposing (GlobalEvent, broadcast, listen)
2
+
3
+ {-| Standardized ports for cross-island communication.
4
+
5
+ Islands are isolated by default. Use these functions to communicate across island
6
+ boundaries using the browser's global event bus.
7
+
8
+ @docs GlobalEvent, broadcast, listen
9
+ -}
10
+
11
+ import Json.Encode exposing (Value)
12
+
13
+
14
+ {-| A message captured from the global event bus. -}
15
+ type alias GlobalEvent =
16
+ { tag : String
17
+ , payload : Value
18
+ }
19
+
20
+
21
+ port broadcastOut : GlobalEvent -> Cmd msg
22
+
23
+
24
+ port broadcastIn : (GlobalEvent -> msg) -> Sub msg
25
+
26
+
27
+ {-| Send a message to all other islands. -}
28
+ broadcast : String -> Value -> Cmd msg
29
+ broadcast tag payload =
30
+ broadcastOut { tag = tag, payload = payload }
31
+
32
+
33
+ {-| Subscribe to the global event bus. You should filter by `tag` in your `update`
34
+ function to ignore messages intended for other components.
35
+ -}
36
+ listen : (GlobalEvent -> msg) -> Sub msg
37
+ listen =
38
+ broadcastIn
@@ -0,0 +1,49 @@
1
+ module ElmSsr.Island exposing
2
+ ( Config
3
+ , embed
4
+ )
5
+
6
+ {-| Embed a standard `Browser.element` island into an SSR page.
7
+
8
+ An island is mounted client-side by plain Elm, not by a custom DOM patcher. The
9
+ server emits a marker element with encoded flags plus optional fallback markup.
10
+ The browser runtime later finds that marker and runs the island's own
11
+ `main : Program flags model msg`.
12
+
13
+ This keeps authoring compatible with normal `elm/html`, `Html.Keyed`, `elm/http`
14
+ and the broader Elm ecosystem.
15
+
16
+
17
+ # Embedding
18
+
19
+ @docs Config, embed
20
+
21
+ -}
22
+
23
+ import ElmSsr.Html as Html exposing (Node)
24
+ import Json.Encode as Encode
25
+
26
+
27
+ {-| SSR-side config for embedding an island. -}
28
+ type alias Config flags pageMsg =
29
+ { encodeFlags : flags -> Encode.Value
30
+ , fallback : flags -> List (Node pageMsg)
31
+ , id : Maybe String
32
+ }
33
+
34
+
35
+ {-| Render the island marker into a page.
36
+
37
+ The `name` must match the generated browser bundle key for the island module.
38
+ `fallback` is optional SSR-only markup shown before the client bundle mounts.
39
+ -}
40
+ embed : String -> Config flags pageMsg -> flags -> Node pageMsg
41
+ embed name config flags =
42
+ Html.element "elm-ssr-island"
43
+ (List.filterMap identity
44
+ [ Just (Html.Property "data-elmssr-island" name)
45
+ , Just (Html.Property "data-elmssr-props" (Encode.encode 0 (config.encodeFlags flags)))
46
+ , config.id |> Maybe.map (Html.Property "data-elmssr-id")
47
+ ]
48
+ )
49
+ (config.fallback flags)
@@ -0,0 +1,297 @@
1
+ module ElmSsr.Loader exposing
2
+ ( Loader
3
+ , succeed, fail
4
+ , map, map2, andThen
5
+ , fetchJson
6
+ , cacheGet, cachePut
7
+ , query, queryOne, execute
8
+ , env
9
+ , enqueue
10
+ , Effect, Step(..), step, encodeEffect
11
+ )
12
+
13
+ {-| A `Loader` describes the data a route needs before it can render.
14
+
15
+ It is a plain description, not a side effect. The author composes loaders with
16
+ `succeed`, `map`, `andThen`, and effect helpers like `fetchJson`; the elm-ssr
17
+ runtime interprets the description, asking the Worker to run the real IO and
18
+ feeding results back until the loader resolves.
19
+
20
+ This means loaders run on the server only, never touch ports by hand, and stay
21
+ fully typed end to end.
22
+
23
+
24
+ # Building loaders
25
+
26
+ @docs Loader
27
+ @docs succeed, fail
28
+ @docs map, map2, andThen
29
+ @docs fetchJson
30
+ @docs cacheGet, cachePut
31
+ @docs query, queryOne, execute
32
+ @docs env
33
+ @docs enqueue
34
+
35
+
36
+ # Runtime interpretation
37
+
38
+ These are used by the elm-ssr runtime to drive a loader. Application authors do
39
+ not need them.
40
+
41
+ @docs Effect, Step, step, encodeEffect
42
+
43
+ -}
44
+
45
+ import Json.Decode as Decode exposing (Decoder)
46
+ import Json.Encode as Encode
47
+
48
+
49
+ {-| A description of how to produce a value of type `a` on the server. -}
50
+ type Loader a
51
+ = Done a
52
+ | Failed Int String
53
+ | Pending Effect (Decode.Value -> Loader a)
54
+
55
+
56
+ {-| A single side effect the Worker knows how to run, addressed by `kind`. -}
57
+ type alias Effect =
58
+ { kind : String
59
+ , payload : Encode.Value
60
+ }
61
+
62
+
63
+ {-| A loader that needs no work and resolves immediately. -}
64
+ succeed : a -> Loader a
65
+ succeed value =
66
+ Done value
67
+
68
+
69
+ {-| Abort a load with an HTTP status and message. The runtime renders an error
70
+ response instead of the page.
71
+ -}
72
+ fail : Int -> String -> Loader a
73
+ fail status message =
74
+ Failed status message
75
+
76
+
77
+ {-| Transform the value a loader resolves to. -}
78
+ map : (a -> b) -> Loader a -> Loader b
79
+ map fn loader =
80
+ case loader of
81
+ Done value ->
82
+ Done (fn value)
83
+
84
+ Failed status message ->
85
+ Failed status message
86
+
87
+ Pending effect continue ->
88
+ Pending effect (\value -> map fn (continue value))
89
+
90
+
91
+ {-| Run a second loader that depends on the first loader's result. Loaders run
92
+ sequentially, so each effect completes before the next begins.
93
+ -}
94
+ andThen : (a -> Loader b) -> Loader a -> Loader b
95
+ andThen fn loader =
96
+ case loader of
97
+ Done value ->
98
+ fn value
99
+
100
+ Failed status message ->
101
+ Failed status message
102
+
103
+ Pending effect continue ->
104
+ Pending effect (\value -> andThen fn (continue value))
105
+
106
+
107
+ {-| Combine two loaders. They run one after the other. -}
108
+ map2 : (a -> b -> c) -> Loader a -> Loader b -> Loader c
109
+ map2 fn first second =
110
+ first |> andThen (\a -> map (fn a) second)
111
+
112
+
113
+ {-| Fetch a URL and decode its JSON body into a value.
114
+
115
+ type alias Status =
116
+ { uptime : String }
117
+
118
+ statusLoader : Loader Status
119
+ statusLoader =
120
+ fetchJson
121
+ { url = "https://api.example.com/status"
122
+ , decoder =
123
+ Decode.map Status (Decode.field "uptime" Decode.string)
124
+ }
125
+
126
+ The Worker performs the actual `fetch`. A non-2xx response or a decode mismatch
127
+ fails the loader with a `502`.
128
+ -}
129
+ fetchJson : { url : String, decoder : Decoder a } -> Loader a
130
+ fetchJson config =
131
+ Pending
132
+ { kind = "fetchJson"
133
+ , payload = Encode.object [ ( "url", Encode.string config.url ) ]
134
+ }
135
+ (\result -> resumeFetchJson config.decoder result)
136
+
137
+
138
+ {-| Read a value from the cache, or `Nothing` on a miss. Backend-neutral: the
139
+ runner maps it to Cloudflare KV, Redis locally, etc. -}
140
+ cacheGet : { key : String, decoder : Decoder a } -> Loader (Maybe a)
141
+ cacheGet config =
142
+ Pending
143
+ { kind = "cacheGet"
144
+ , payload = Encode.object [ ( "key", Encode.string config.key ) ]
145
+ }
146
+ (\result -> resumeFetchJson (Decode.nullable config.decoder) result)
147
+
148
+
149
+ {-| Write a value to the cache, with an optional TTL in seconds. -}
150
+ cachePut : { key : String, value : Encode.Value, ttlSeconds : Maybe Int } -> Loader ()
151
+ cachePut config =
152
+ let
153
+ fields =
154
+ [ ( "key", Encode.string config.key ), ( "value", config.value ) ]
155
+ ++ (case config.ttlSeconds of
156
+ Just ttl ->
157
+ [ ( "ttlSeconds", Encode.int ttl ) ]
158
+
159
+ Nothing ->
160
+ []
161
+ )
162
+ in
163
+ Pending
164
+ { kind = "cachePut", payload = Encode.object fields }
165
+ (\_ -> Done ())
166
+
167
+
168
+ {-| Run a SQL query and decode every row. Backend-neutral: the runner maps it to
169
+ Cloudflare D1, Postgres/SQLite locally, etc. Use `?` placeholders with `params`. -}
170
+ query : { sql : String, params : List Encode.Value, decoder : Decoder a } -> Loader (List a)
171
+ query config =
172
+ Pending
173
+ { kind = "query", payload = sqlPayload config.sql config.params }
174
+ (\result -> resumeFetchJson (Decode.list config.decoder) result)
175
+
176
+
177
+ {-| Run a SQL query and decode only the first row, if any. -}
178
+ queryOne : { sql : String, params : List Encode.Value, decoder : Decoder a } -> Loader (Maybe a)
179
+ queryOne config =
180
+ Pending
181
+ { kind = "queryOne", payload = sqlPayload config.sql config.params }
182
+ (\result -> resumeFetchJson (Decode.nullable config.decoder) result)
183
+
184
+
185
+ {-| Execute a statement (INSERT/UPDATE/DELETE) and return the number of rows
186
+ affected. Typically used by an `Action` via `Action.fromLoader`. -}
187
+ execute : { sql : String, params : List Encode.Value } -> Loader { rowsAffected : Int }
188
+ execute config =
189
+ Pending
190
+ { kind = "execute", payload = sqlPayload config.sql config.params }
191
+ (\result ->
192
+ resumeFetchJson
193
+ (Decode.map (\rows -> { rowsAffected = rows }) (Decode.field "rowsAffected" Decode.int))
194
+ result
195
+ )
196
+
197
+
198
+ {-| Read an environment variable / secret / binding name. -}
199
+ env : String -> Loader (Maybe String)
200
+ env name =
201
+ Pending
202
+ { kind = "env", payload = Encode.object [ ( "name", Encode.string name ) ]
203
+ }
204
+ (\result -> resumeFetchJson (Decode.nullable Decode.string) result)
205
+
206
+
207
+ {-| Enqueue a background task to run after the response (fire-and-forget). The
208
+ named handler lives in the Worker's task adapter; the request does not wait for
209
+ it. Typically used from an `Action` via `Action.fromLoader`. -}
210
+ enqueue : { task : String, payload : Encode.Value } -> Loader ()
211
+ enqueue config =
212
+ Pending
213
+ { kind = "enqueue"
214
+ , payload =
215
+ Encode.object
216
+ [ ( "task", Encode.string config.task )
217
+ , ( "payload", config.payload )
218
+ ]
219
+ }
220
+ (\result -> resumeFetchJson (Decode.succeed ()) result)
221
+
222
+
223
+ sqlPayload : String -> List Encode.Value -> Encode.Value
224
+ sqlPayload sql params =
225
+ Encode.object
226
+ [ ( "sql", Encode.string sql )
227
+ , ( "params", Encode.list identity params )
228
+ ]
229
+
230
+
231
+ resumeFetchJson : Decoder a -> Decode.Value -> Loader a
232
+ resumeFetchJson decoder result =
233
+ case Decode.decodeValue effectOutcomeDecoder result of
234
+ Ok (Ok body) ->
235
+ case Decode.decodeValue decoder body of
236
+ Ok value ->
237
+ Done value
238
+
239
+ Err decodeError ->
240
+ Failed 502 ("Loader response did not match decoder: " ++ Decode.errorToString decodeError)
241
+
242
+ Ok (Err message) ->
243
+ Failed 502 message
244
+
245
+ Err decodeError ->
246
+ Failed 500 ("Malformed loader effect result: " ++ Decode.errorToString decodeError)
247
+
248
+
249
+ effectOutcomeDecoder : Decoder (Result String Decode.Value)
250
+ effectOutcomeDecoder =
251
+ Decode.field "ok" Decode.bool
252
+ |> Decode.andThen
253
+ (\ok ->
254
+ if ok then
255
+ Decode.map Ok (Decode.field "value" Decode.value)
256
+
257
+ else
258
+ Decode.map Err
259
+ (Decode.oneOf
260
+ [ Decode.field "error" Decode.string
261
+ , Decode.succeed "Loader effect failed"
262
+ ]
263
+ )
264
+ )
265
+
266
+
267
+ {-| One observable step of a loader: it is done, it failed, or it is waiting on
268
+ an effect. The runtime resumes a pending loader by calling the continuation with
269
+ the effect result.
270
+ -}
271
+ type Step a
272
+ = Resolved a
273
+ | Errored Int String
274
+ | Await Effect (Decode.Value -> Loader a)
275
+
276
+
277
+ {-| Inspect the next step of a loader. -}
278
+ step : Loader a -> Step a
279
+ step loader =
280
+ case loader of
281
+ Done value ->
282
+ Resolved value
283
+
284
+ Failed status message ->
285
+ Errored status message
286
+
287
+ Pending effect continue ->
288
+ Await effect continue
289
+
290
+
291
+ {-| Encode an effect as the JSON request the Worker receives. -}
292
+ encodeEffect : Effect -> Encode.Value
293
+ encodeEffect effect =
294
+ Encode.object
295
+ [ ( "kind", Encode.string effect.kind )
296
+ , ( "payload", effect.payload )
297
+ ]
@@ -0,0 +1,102 @@
1
+ module ElmSsr.Page exposing
2
+ ( document
3
+ , error
4
+ , metaCharset
5
+ , metaName
6
+ , metaViewport
7
+ , notFound
8
+ , page
9
+ , stylesheet
10
+ , title
11
+ )
12
+
13
+ import ElmSsr.Document exposing (Document)
14
+ import ElmSsr.Html as Html exposing (Node, link, meta, text)
15
+ import ElmSsr.Html.Attributes exposing (attr, href, rel)
16
+
17
+
18
+ document :
19
+ { status : Int
20
+ , lang : String
21
+ , head : List (Node msg)
22
+ , body : List (Node msg)
23
+ }
24
+ -> Document msg
25
+ document config =
26
+ { status = config.status
27
+ , lang = config.lang
28
+ , hasIslands = Html.anyIsland config.body
29
+ , head = config.head
30
+ , body = config.body
31
+ }
32
+
33
+
34
+ page :
35
+ { title : String
36
+ , head : List (Node msg)
37
+ , body : List (Node msg)
38
+ }
39
+ -> Document msg
40
+ page config =
41
+ document
42
+ { status = 200
43
+ , lang = "en"
44
+ , head = title config.title :: config.head
45
+ , body = config.body
46
+ }
47
+
48
+
49
+ notFound :
50
+ { title : String
51
+ , head : List (Node msg)
52
+ , body : List (Node msg)
53
+ }
54
+ -> Document msg
55
+ notFound config =
56
+ document
57
+ { status = 404
58
+ , lang = "en"
59
+ , head = title config.title :: config.head
60
+ , body = config.body
61
+ }
62
+
63
+
64
+ title : String -> Node msg
65
+ title value =
66
+ Html.Element "title" [] [ text value ]
67
+
68
+
69
+ error : Int -> String -> Document Never
70
+ error status message =
71
+ { status = status
72
+ , lang = "en"
73
+ , hasIslands = False
74
+ , head = [ title ("Error " ++ String.fromInt status) ]
75
+ , body =
76
+ [ Html.Element "main"
77
+ []
78
+ [ Html.Element "h1" [] [ text (String.fromInt status) ]
79
+ , Html.Element "p" [] [ text message ]
80
+ ]
81
+ ]
82
+ }
83
+
84
+
85
+ stylesheet : String -> Node msg
86
+ stylesheet path =
87
+ link [ rel "stylesheet", href path ]
88
+
89
+
90
+ metaCharset : String -> Node msg
91
+ metaCharset charset =
92
+ meta [ attr "charset" charset ]
93
+
94
+
95
+ metaViewport : String -> Node msg
96
+ metaViewport content =
97
+ meta [ attr "name" "viewport", attr "content" content ]
98
+
99
+
100
+ metaName : String -> String -> Node msg
101
+ metaName name content =
102
+ meta [ attr "name" name, attr "content" content ]
@@ -0,0 +1,136 @@
1
+ module ElmSsr.Route exposing
2
+ ( Request
3
+ , segments, method, query
4
+ , param, params
5
+ , formValue
6
+ , decoder
7
+ )
8
+
9
+ {-| The incoming request a route is matched against.
10
+
11
+ Routing is file-based, so you rarely match paths by hand. Instead, dynamic
12
+ segments captured from a file name (e.g. `Routes/Posts/Slug_.elm` →
13
+ `/posts/:slug`) are available through [`param`](#param).
14
+
15
+
16
+ # Request
17
+
18
+ @docs Request
19
+ @docs segments, method, query
20
+
21
+
22
+ # Dynamic route params
23
+
24
+ @docs param, params
25
+
26
+
27
+ # Form data
28
+
29
+ @docs formValue
30
+
31
+
32
+ # Decoding
33
+
34
+ @docs decoder
35
+
36
+ -}
37
+
38
+ import Json.Decode as Decode exposing (Decoder)
39
+
40
+
41
+ {-| Everything the runtime knows about an incoming request.
42
+
43
+ `params` holds the dynamic segments captured by the generated router; it is
44
+ empty for static routes.
45
+ -}
46
+ type alias Request =
47
+ { method : String
48
+ , path : String
49
+ , query : List ( String, String )
50
+ , params : List ( String, String )
51
+ , formData : List ( String, String )
52
+ }
53
+
54
+
55
+ {-| The path split into non-empty segments.
56
+
57
+ -- request.path == "/posts/hello"
58
+ segments request --> [ "posts", "hello" ]
59
+
60
+ -}
61
+ segments : Request -> List String
62
+ segments request =
63
+ request.path
64
+ |> String.split "/"
65
+ |> List.filter (not << String.isEmpty)
66
+
67
+
68
+ {-| The HTTP method, uppercased by the runtime (e.g. `"GET"`). -}
69
+ method : Request -> String
70
+ method request =
71
+ request.method
72
+
73
+
74
+ {-| Look up a query string parameter by name.
75
+
76
+ -- request.path == "/search?q=elm"
77
+ query "q" request --> Just "elm"
78
+
79
+ -}
80
+ query : String -> Request -> Maybe String
81
+ query key request =
82
+ lookup key request.query
83
+
84
+
85
+ {-| Look up a dynamic route segment by name.
86
+
87
+ -- Routes/Posts/Slug_.elm matched against /posts/hello
88
+ param "slug" request --> Just "hello"
89
+
90
+ -}
91
+ param : String -> Request -> Maybe String
92
+ param key request =
93
+ lookup key request.params
94
+
95
+
96
+ {-| All captured dynamic segments, as name/value pairs. -}
97
+ params : Request -> List ( String, String )
98
+ params request =
99
+ request.params
100
+
101
+
102
+ {-| Look up a form value by name. -}
103
+ formValue : String -> Request -> Maybe String
104
+ formValue key request =
105
+ lookup key request.formData
106
+
107
+
108
+ lookup : String -> List ( String, String ) -> Maybe String
109
+ lookup key pairs =
110
+ pairs
111
+ |> List.filter (\( name, _ ) -> name == key)
112
+ |> List.head
113
+ |> Maybe.map Tuple.second
114
+
115
+
116
+ {-| Decode the request the Worker passes in as flags. Dynamic params are filled
117
+ in by the generated router, not by the Worker, so they start empty.
118
+ -}
119
+ decoder : Decoder Request
120
+ decoder =
121
+ Decode.map4
122
+ (\requestMethod path queryPairs formDataPairs ->
123
+ { method = requestMethod, path = path, query = queryPairs, params = [], formData = formDataPairs }
124
+ )
125
+ (Decode.oneOf [ Decode.field "method" Decode.string, Decode.succeed "GET" ])
126
+ (Decode.field "path" Decode.string)
127
+ (Decode.oneOf
128
+ [ Decode.field "query" (Decode.keyValuePairs Decode.string)
129
+ , Decode.succeed []
130
+ ]
131
+ )
132
+ (Decode.oneOf
133
+ [ Decode.field "formData" (Decode.keyValuePairs Decode.string)
134
+ , Decode.succeed []
135
+ ]
136
+ )