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.
- package/README.md +67 -0
- package/bin/elm-ssr.mjs +102 -0
- package/elm-src/ElmSsr/Action.elm +210 -0
- package/elm-src/ElmSsr/Document/Encode.elm +83 -0
- package/elm-src/ElmSsr/Document/Events.elm +125 -0
- package/elm-src/ElmSsr/Document.elm +26 -0
- package/elm-src/ElmSsr/Html/Attributes.elm +344 -0
- package/elm-src/ElmSsr/Html/Events.elm +95 -0
- package/elm-src/ElmSsr/Html.elm +706 -0
- package/elm-src/ElmSsr/Island/Shared.elm +38 -0
- package/elm-src/ElmSsr/Island.elm +49 -0
- package/elm-src/ElmSsr/Loader.elm +297 -0
- package/elm-src/ElmSsr/Page.elm +102 -0
- package/elm-src/ElmSsr/Route.elm +136 -0
- package/elm-src/ElmSsr/Runtime.elm +170 -0
- package/elm-src/ElmSsr/Svg/Attributes.elm +1208 -0
- package/elm-src/ElmSsr/Svg.elm +309 -0
- package/lib/build.mjs +256 -0
- package/lib/migrate.mjs +146 -0
- package/lib/scaffold.mjs +472 -0
- package/lib/workspace.mjs +21 -0
- package/package.json +60 -0
- package/src/app.ts +74 -0
- package/src/backends.ts +116 -0
- package/src/client-runtime/islands.ts +247 -0
- package/src/effects.ts +267 -0
- package/src/http.ts +86 -0
- package/src/middleware.ts +104 -0
- package/src/migrations.ts +225 -0
- package/src/protocol.ts +119 -0
- package/src/render.ts +111 -0
- package/src/request-handler.ts +208 -0
- package/src/response-headers.ts +18 -0
- package/src/serialize.ts +47 -0
- package/src/tasks.ts +139 -0
|
@@ -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
|
+
)
|