elm-ssr 0.1.0 → 0.3.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/bin/elm-ssr.mjs +9 -4
- package/elm-src/ElmSsr/Action.elm +232 -7
- package/elm-src/ElmSsr/Island/Sse.elm +151 -0
- package/elm-src/ElmSsr/Loader.elm +89 -0
- package/elm-src/ElmSsr/Runtime.elm +19 -9
- package/lib/scaffold.mjs +44 -16
- package/package.json +3 -1
- package/src/app.ts +41 -7
- package/src/client-runtime/islands.ts +113 -15
- package/src/effects.ts +2 -0
- package/src/http.ts +2 -0
- package/src/render.ts +28 -4
- package/src/request-handler.ts +65 -20
- package/src/sessions/crypto.ts +93 -0
- package/src/sessions/effects.ts +52 -0
- package/src/sessions/index.ts +13 -0
- package/src/sessions/middleware.ts +201 -0
- package/src/sessions/store.ts +60 -0
- package/src/sessions/types.ts +32 -0
- package/src/sse.ts +162 -0
package/bin/elm-ssr.mjs
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { readFile } from "node:fs/promises";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
|
-
import {
|
|
5
|
+
import { createAppScaffold } from "../lib/scaffold.mjs";
|
|
6
6
|
import { readWorkspaceConfig } from "../lib/workspace.mjs";
|
|
7
7
|
import { build } from "../lib/build.mjs";
|
|
8
8
|
import { migrate } from "../lib/migrate.mjs";
|
|
@@ -48,7 +48,8 @@ const printHelp = () => {
|
|
|
48
48
|
build Generate wrapper modules and compile configured Elm SSR apps
|
|
49
49
|
compress Pre-compress island and app bundles using Gzip for faster edge delivery
|
|
50
50
|
dev Build and start wrangler dev using the current workspace config
|
|
51
|
-
new <name> Create a new
|
|
51
|
+
new <name> Create a new app at <workspace>/<name>/ (or <workspace>/<subdir>/<name>/
|
|
52
|
+
with --in <subdir>) and register it in elm-ssr.config.json
|
|
52
53
|
routes Print configured apps and their public modules
|
|
53
54
|
info Print current workspace package and configured app names
|
|
54
55
|
migrate ... Apply / revert / inspect SQL migrations (see: elm-ssr migrate --help)
|
|
@@ -72,11 +73,15 @@ switch (command) {
|
|
|
72
73
|
const name = args[1];
|
|
73
74
|
|
|
74
75
|
if (!name) {
|
|
75
|
-
console.error("Usage: elm-ssr new <name>");
|
|
76
|
+
console.error("Usage: elm-ssr new <name> [--in <subdir>]");
|
|
77
|
+
console.error(" Default location: <workspace>/<name>/");
|
|
78
|
+
console.error(" Use --in apps to place it under <workspace>/apps/<name>/, etc.");
|
|
76
79
|
process.exit(1);
|
|
77
80
|
}
|
|
78
81
|
|
|
79
|
-
const
|
|
82
|
+
const subdir = findFlagValue("--in");
|
|
83
|
+
const appRoot = subdir ? `${subdir.replace(/\/+$/, "")}/${name}` : name;
|
|
84
|
+
const created = await createAppScaffold(rootPath, name, { root: appRoot });
|
|
80
85
|
console.log(`Created ${created.name} at ${created.root}`);
|
|
81
86
|
break;
|
|
82
87
|
}
|
|
@@ -2,7 +2,8 @@ module ElmSsr.Action exposing
|
|
|
2
2
|
( Action
|
|
3
3
|
, succeed, fail, redirect, json
|
|
4
4
|
, map, andThen, fromLoader
|
|
5
|
-
,
|
|
5
|
+
, Cookie, SameSite(..), setCookie, clearCookie, defaultCookie, sessionCookie
|
|
6
|
+
, Effect, Step(..), step, collectCookies, encodeStep, encodeCookies
|
|
6
7
|
)
|
|
7
8
|
|
|
8
9
|
{-| An `Action` describes what should happen in response to a non-GET request
|
|
@@ -32,12 +33,20 @@ redirects (the Post/Redirect/Get pattern):
|
|
|
32
33
|
@docs map, andThen, fromLoader
|
|
33
34
|
|
|
34
35
|
|
|
36
|
+
# Cookies
|
|
37
|
+
|
|
38
|
+
Attach `Set-Cookie` headers to any action's response (including redirects and
|
|
39
|
+
JSON responses). The Worker serializes the attributes for you.
|
|
40
|
+
|
|
41
|
+
@docs Cookie, SameSite, setCookie, clearCookie, defaultCookie, sessionCookie
|
|
42
|
+
|
|
43
|
+
|
|
35
44
|
# Runtime interpretation
|
|
36
45
|
|
|
37
46
|
Used by the elm-ssr runtime to drive an action. Application authors do not need
|
|
38
47
|
these.
|
|
39
48
|
|
|
40
|
-
@docs Effect, Step, step, encodeStep
|
|
49
|
+
@docs Effect, Step, step, collectCookies, encodeStep, encodeCookies
|
|
41
50
|
|
|
42
51
|
-}
|
|
43
52
|
|
|
@@ -53,6 +62,7 @@ type Action a
|
|
|
53
62
|
| Redirect String
|
|
54
63
|
| JsonResult Encode.Value
|
|
55
64
|
| Pending Effect (Decode.Value -> Action a)
|
|
65
|
+
| WithCookies (List Cookie) (Action a)
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
{-| A single side effect the Worker runs, addressed by `kind`. Shared with
|
|
@@ -61,6 +71,136 @@ type alias Effect =
|
|
|
61
71
|
Loader.Effect
|
|
62
72
|
|
|
63
73
|
|
|
74
|
+
{-| `SameSite` policy for a cookie. Maps to the `SameSite=` attribute. -}
|
|
75
|
+
type SameSite
|
|
76
|
+
= Lax
|
|
77
|
+
| Strict
|
|
78
|
+
| None
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
{-| A cookie to attach to the response. Build one with
|
|
82
|
+
[`defaultCookie`](#defaultCookie) and override the attributes you care about.
|
|
83
|
+
-}
|
|
84
|
+
type alias Cookie =
|
|
85
|
+
{ name : String
|
|
86
|
+
, value : String
|
|
87
|
+
, maxAge : Maybe Int
|
|
88
|
+
, expires : Maybe String
|
|
89
|
+
, domain : Maybe String
|
|
90
|
+
, path : Maybe String
|
|
91
|
+
, secure : Bool
|
|
92
|
+
, httpOnly : Bool
|
|
93
|
+
, sameSite : Maybe SameSite
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
{-| A cookie with sensible defaults: `path=/`, no other attributes set. Override
|
|
98
|
+
fields with record update syntax.
|
|
99
|
+
|
|
100
|
+
Action.setCookie
|
|
101
|
+
{ defaultCookie "session" sessionId | httpOnly = True, secure = True, maxAge = Just (60 * 60 * 24 * 7) }
|
|
102
|
+
(Action.redirect "/dashboard")
|
|
103
|
+
|
|
104
|
+
-}
|
|
105
|
+
defaultCookie : String -> String -> Cookie
|
|
106
|
+
defaultCookie name value =
|
|
107
|
+
{ name = name
|
|
108
|
+
, value = value
|
|
109
|
+
, maxAge = Nothing
|
|
110
|
+
, expires = Nothing
|
|
111
|
+
, domain = Nothing
|
|
112
|
+
, path = Just "/"
|
|
113
|
+
, secure = False
|
|
114
|
+
, httpOnly = False
|
|
115
|
+
, sameSite = Nothing
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
{-| A cookie hardened for session use: `HttpOnly` (JS can't read it),
|
|
120
|
+
`Secure` (HTTPS-only), `SameSite=Lax` (sent on top-level navigations,
|
|
121
|
+
blocked on cross-site sub-requests), `Path=/`, and a 7-day `Max-Age`.
|
|
122
|
+
|
|
123
|
+
Use this for anything that grants authority — session IDs, auth tokens —
|
|
124
|
+
and override only what you need.
|
|
125
|
+
|
|
126
|
+
Action.redirect "/dashboard"
|
|
127
|
+
|> Action.setCookie (Action.sessionCookie "session" sessionId)
|
|
128
|
+
|
|
129
|
+
To use a stricter `SameSite=Strict`, or shorter/longer lifetime:
|
|
130
|
+
|
|
131
|
+
Action.sessionCookie "session" sid
|
|
132
|
+
|> (\c -> { c | sameSite = Just Action.Strict, maxAge = Just (60 * 30) })
|
|
133
|
+
|> (\c -> Action.setCookie c (Action.redirect "/dashboard"))
|
|
134
|
+
|
|
135
|
+
If you are testing locally over plain HTTP, you'll need to set `secure = False`
|
|
136
|
+
on the cookie — modern browsers ignore `Secure` cookies on `http://`.
|
|
137
|
+
|
|
138
|
+
-}
|
|
139
|
+
sessionCookie : String -> String -> Cookie
|
|
140
|
+
sessionCookie name value =
|
|
141
|
+
{ name = name
|
|
142
|
+
, value = value
|
|
143
|
+
, maxAge = Just (60 * 60 * 24 * 7)
|
|
144
|
+
, expires = Nothing
|
|
145
|
+
, domain = Nothing
|
|
146
|
+
, path = Just "/"
|
|
147
|
+
, secure = True
|
|
148
|
+
, httpOnly = True
|
|
149
|
+
, sameSite = Just Lax
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
{-| Attach a `Set-Cookie` header to the action's response. Stackable — call
|
|
154
|
+
multiple times to set multiple cookies. Works with any terminal step (success,
|
|
155
|
+
fail, redirect, JSON) and is preserved through `map`/`andThen`.
|
|
156
|
+
|
|
157
|
+
Action.fromLoader (createSession email)
|
|
158
|
+
|> Action.andThen
|
|
159
|
+
(\sessionId ->
|
|
160
|
+
Action.redirect "/dashboard"
|
|
161
|
+
|> Action.setCookie
|
|
162
|
+
({ defaultCookie "session" sessionId
|
|
163
|
+
| httpOnly = True
|
|
164
|
+
, secure = True
|
|
165
|
+
, sameSite = Just Lax
|
|
166
|
+
, maxAge = Just (60 * 60 * 24 * 7)
|
|
167
|
+
})
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
-}
|
|
171
|
+
setCookie : Cookie -> Action a -> Action a
|
|
172
|
+
setCookie cookie action =
|
|
173
|
+
case action of
|
|
174
|
+
WithCookies cookies inner ->
|
|
175
|
+
WithCookies (cookies ++ [ cookie ]) inner
|
|
176
|
+
|
|
177
|
+
_ ->
|
|
178
|
+
WithCookies [ cookie ] action
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
{-| Clear a cookie by setting `Max-Age=0`. Honors the same `path`/`domain` you
|
|
182
|
+
used to set it — pass them in if you scoped the original cookie that way.
|
|
183
|
+
|
|
184
|
+
Action.redirect "/login"
|
|
185
|
+
|> Action.clearCookie { name = "session", path = Just "/", domain = Nothing }
|
|
186
|
+
|
|
187
|
+
-}
|
|
188
|
+
clearCookie : { name : String, path : Maybe String, domain : Maybe String } -> Action a -> Action a
|
|
189
|
+
clearCookie config action =
|
|
190
|
+
setCookie
|
|
191
|
+
{ name = config.name
|
|
192
|
+
, value = ""
|
|
193
|
+
, maxAge = Just 0
|
|
194
|
+
, expires = Nothing
|
|
195
|
+
, domain = config.domain
|
|
196
|
+
, path = config.path
|
|
197
|
+
, secure = False
|
|
198
|
+
, httpOnly = False
|
|
199
|
+
, sameSite = Nothing
|
|
200
|
+
}
|
|
201
|
+
action
|
|
202
|
+
|
|
203
|
+
|
|
64
204
|
{-| An action that resolves to a value with no further work. -}
|
|
65
205
|
succeed : a -> Action a
|
|
66
206
|
succeed =
|
|
@@ -104,6 +244,9 @@ map fn action =
|
|
|
104
244
|
Pending effect continue ->
|
|
105
245
|
Pending effect (\value -> map fn (continue value))
|
|
106
246
|
|
|
247
|
+
WithCookies cookies inner ->
|
|
248
|
+
WithCookies cookies (map fn inner)
|
|
249
|
+
|
|
107
250
|
|
|
108
251
|
{-| Sequence actions: run a second step that depends on the first's result.
|
|
109
252
|
Effects run one after the other, each completing before the next begins. -}
|
|
@@ -125,6 +268,9 @@ andThen fn action =
|
|
|
125
268
|
Pending effect continue ->
|
|
126
269
|
Pending effect (\value -> andThen fn (continue value))
|
|
127
270
|
|
|
271
|
+
WithCookies cookies inner ->
|
|
272
|
+
WithCookies cookies (andThen fn inner)
|
|
273
|
+
|
|
128
274
|
|
|
129
275
|
{-| Lift a [`Loader`](./Loader.elm) into an action so its effects run as part of
|
|
130
276
|
the action. This is how an action does server work (fetch, KV, D1, …) before
|
|
@@ -152,7 +298,9 @@ type Step a
|
|
|
152
298
|
| Await Effect (Decode.Value -> Action a)
|
|
153
299
|
|
|
154
300
|
|
|
155
|
-
{-| Inspect the next step of an action.
|
|
301
|
+
{-| Inspect the next step of an action. `WithCookies` wrappers are skipped —
|
|
302
|
+
use [`collectCookies`](#collectCookies) first if you need the attached cookies.
|
|
303
|
+
-}
|
|
156
304
|
step : Action a -> Step a
|
|
157
305
|
step action =
|
|
158
306
|
case action of
|
|
@@ -171,16 +319,51 @@ step action =
|
|
|
171
319
|
Pending effect continue ->
|
|
172
320
|
Await effect continue
|
|
173
321
|
|
|
322
|
+
WithCookies _ inner ->
|
|
323
|
+
step inner
|
|
324
|
+
|
|
174
325
|
|
|
175
|
-
{-|
|
|
176
|
-
the
|
|
177
|
-
|
|
178
|
-
|
|
326
|
+
{-| Strip the top-level `WithCookies` wrappers and return the accumulated
|
|
327
|
+
cookies plus the unwrapped action. Used by the runtime so terminal steps can
|
|
328
|
+
carry `Set-Cookie` headers.
|
|
329
|
+
-}
|
|
330
|
+
collectCookies : Action a -> ( List Cookie, Action a )
|
|
331
|
+
collectCookies action =
|
|
332
|
+
case action of
|
|
333
|
+
WithCookies cookies inner ->
|
|
334
|
+
let
|
|
335
|
+
( more, base ) =
|
|
336
|
+
collectCookies inner
|
|
337
|
+
in
|
|
338
|
+
( cookies ++ more, base )
|
|
339
|
+
|
|
340
|
+
_ ->
|
|
341
|
+
( [], action )
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
{-| JSON-encode a list of cookies in the shape the Worker runtime expects.
|
|
345
|
+
Used internally; exposed for cases that build a terminal response by hand
|
|
346
|
+
(e.g. error responses from the runtime). -}
|
|
347
|
+
encodeCookies : List Cookie -> Encode.Value
|
|
348
|
+
encodeCookies cookies =
|
|
349
|
+
Encode.list encodeCookie cookies
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
{-| Encode a terminal step plus any attached `Set-Cookie`s for the Worker
|
|
353
|
+
runtime. `Await` is never encoded — the runtime runs its effect first — so it
|
|
354
|
+
is reported defensively as an error. -}
|
|
355
|
+
encodeStep : List Cookie -> (a -> Encode.Value) -> Step a -> Encode.Value
|
|
356
|
+
encodeStep cookies encoder step_ =
|
|
357
|
+
let
|
|
358
|
+
cookieField =
|
|
359
|
+
( "cookies", encodeCookies cookies )
|
|
360
|
+
in
|
|
179
361
|
case step_ of
|
|
180
362
|
Resolved value ->
|
|
181
363
|
Encode.object
|
|
182
364
|
[ ( "kind", Encode.string "resolved" )
|
|
183
365
|
, ( "value", encoder value )
|
|
366
|
+
, cookieField
|
|
184
367
|
]
|
|
185
368
|
|
|
186
369
|
Errored status message ->
|
|
@@ -188,18 +371,21 @@ encodeStep encoder step_ =
|
|
|
188
371
|
[ ( "kind", Encode.string "errored" )
|
|
189
372
|
, ( "status", Encode.int status )
|
|
190
373
|
, ( "message", Encode.string message )
|
|
374
|
+
, cookieField
|
|
191
375
|
]
|
|
192
376
|
|
|
193
377
|
Moved url ->
|
|
194
378
|
Encode.object
|
|
195
379
|
[ ( "kind", Encode.string "redirect" )
|
|
196
380
|
, ( "url", Encode.string url )
|
|
381
|
+
, cookieField
|
|
197
382
|
]
|
|
198
383
|
|
|
199
384
|
SentJson value ->
|
|
200
385
|
Encode.object
|
|
201
386
|
[ ( "kind", Encode.string "json" )
|
|
202
387
|
, ( "value", value )
|
|
388
|
+
, cookieField
|
|
203
389
|
]
|
|
204
390
|
|
|
205
391
|
Await _ _ ->
|
|
@@ -207,4 +393,43 @@ encodeStep encoder step_ =
|
|
|
207
393
|
[ ( "kind", Encode.string "errored" )
|
|
208
394
|
, ( "status", Encode.int 500 )
|
|
209
395
|
, ( "message", Encode.string "Action step was not resolved before encoding." )
|
|
396
|
+
, cookieField
|
|
210
397
|
]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
encodeCookie : Cookie -> Encode.Value
|
|
401
|
+
encodeCookie cookie =
|
|
402
|
+
Encode.object
|
|
403
|
+
[ ( "name", Encode.string cookie.name )
|
|
404
|
+
, ( "value", Encode.string cookie.value )
|
|
405
|
+
, ( "maxAge", encodeMaybe Encode.int cookie.maxAge )
|
|
406
|
+
, ( "expires", encodeMaybe Encode.string cookie.expires )
|
|
407
|
+
, ( "domain", encodeMaybe Encode.string cookie.domain )
|
|
408
|
+
, ( "path", encodeMaybe Encode.string cookie.path )
|
|
409
|
+
, ( "secure", Encode.bool cookie.secure )
|
|
410
|
+
, ( "httpOnly", Encode.bool cookie.httpOnly )
|
|
411
|
+
, ( "sameSite", encodeMaybe encodeSameSite cookie.sameSite )
|
|
412
|
+
]
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
encodeMaybe : (a -> Encode.Value) -> Maybe a -> Encode.Value
|
|
416
|
+
encodeMaybe encoder value =
|
|
417
|
+
case value of
|
|
418
|
+
Just inner ->
|
|
419
|
+
encoder inner
|
|
420
|
+
|
|
421
|
+
Nothing ->
|
|
422
|
+
Encode.null
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
encodeSameSite : SameSite -> Encode.Value
|
|
426
|
+
encodeSameSite sameSite =
|
|
427
|
+
case sameSite of
|
|
428
|
+
Lax ->
|
|
429
|
+
Encode.string "lax"
|
|
430
|
+
|
|
431
|
+
Strict ->
|
|
432
|
+
Encode.string "strict"
|
|
433
|
+
|
|
434
|
+
None ->
|
|
435
|
+
Encode.string "none"
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
port module ElmSsr.Island.Sse exposing
|
|
2
|
+
( Event, Error(..)
|
|
3
|
+
, open, close
|
|
4
|
+
, events, errors, match
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
{-| Server-Sent Events subscription for islands.
|
|
8
|
+
|
|
9
|
+
The browser opens an `EventSource` per URL; the Worker streams
|
|
10
|
+
`text/event-stream` from a route built with `createSseStream` (see the
|
|
11
|
+
TS-side `elm-ssr/sse` subpath). Multiple subscriptions to the same URL share
|
|
12
|
+
a single underlying connection.
|
|
13
|
+
|
|
14
|
+
Lifecycle:
|
|
15
|
+
- Open the stream in `init` (or any `update` branch) with [`open url`](#open).
|
|
16
|
+
- Receive raw frames via [`events`](#events) in `subscriptions`; route them
|
|
17
|
+
by URL with [`match`](#match) plus your decoder.
|
|
18
|
+
- Optionally observe connection errors via [`errors`](#errors).
|
|
19
|
+
- Close with [`close url`](#close) when you no longer need the stream;
|
|
20
|
+
SPA navigation closes them automatically for non-persistent islands.
|
|
21
|
+
|
|
22
|
+
A minimal island:
|
|
23
|
+
|
|
24
|
+
type Msg
|
|
25
|
+
= GotEvent Event
|
|
26
|
+
| NoOp
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
init flags =
|
|
30
|
+
( { time = "" }, Sse.open "/__elm-ssr/live" )
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
subscriptions _ =
|
|
34
|
+
Sse.events GotEvent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
update msg model =
|
|
38
|
+
case msg of
|
|
39
|
+
GotEvent event ->
|
|
40
|
+
case Sse.match "/__elm-ssr/live" tickDecoder event of
|
|
41
|
+
Just (Ok tick) ->
|
|
42
|
+
( { model | time = tick.time }, Cmd.none )
|
|
43
|
+
|
|
44
|
+
_ ->
|
|
45
|
+
( model, Cmd.none )
|
|
46
|
+
|
|
47
|
+
NoOp ->
|
|
48
|
+
( model, Cmd.none )
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Types
|
|
52
|
+
|
|
53
|
+
@docs Event, Error
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Opening and closing
|
|
57
|
+
|
|
58
|
+
@docs open, close
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Receiving
|
|
62
|
+
|
|
63
|
+
@docs events, errors, match
|
|
64
|
+
|
|
65
|
+
-}
|
|
66
|
+
|
|
67
|
+
import Json.Decode as Decode exposing (Decoder)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
{-| A raw SSE message. `data` is the unparsed string payload — use
|
|
71
|
+
[`match`](#match) plus a decoder to turn it into a typed value.
|
|
72
|
+
-}
|
|
73
|
+
type alias Event =
|
|
74
|
+
{ url : String, data : String }
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
{-| Why an SSE callback might fail. -}
|
|
78
|
+
type Error
|
|
79
|
+
= DecodeError String
|
|
80
|
+
| NetworkError String
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
port sseOpen : String -> Cmd msg
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
port sseClose : String -> Cmd msg
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
port sseEventIn : (Event -> msg) -> Sub msg
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
port sseErrorIn : ({ url : String, message : String } -> msg) -> Sub msg
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
{-| Open (or reuse) an SSE connection to a URL. Idempotent — opening the same
|
|
96
|
+
URL twice does not open a second connection. -}
|
|
97
|
+
open : String -> Cmd msg
|
|
98
|
+
open url =
|
|
99
|
+
sseOpen url
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
{-| Close the SSE connection for a URL. Idempotent. -}
|
|
103
|
+
close : String -> Cmd msg
|
|
104
|
+
close url =
|
|
105
|
+
sseClose url
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
{-| Subscribe to every SSE message across every open stream. Filter by URL in
|
|
109
|
+
your `update` (the [`match`](#match) helper is the convenient way). -}
|
|
110
|
+
events : (Event -> msg) -> Sub msg
|
|
111
|
+
events toMsg =
|
|
112
|
+
sseEventIn toMsg
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
{-| Subscribe to connection errors (`onerror` events from `EventSource`).
|
|
116
|
+
The browser auto-reconnects after these — this is informational. -}
|
|
117
|
+
errors : ({ url : String, message : String } -> msg) -> Sub msg
|
|
118
|
+
errors toMsg =
|
|
119
|
+
sseErrorIn toMsg
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
{-| If this event is for `url`, decode its `data` with `decoder`. Returns
|
|
123
|
+
`Nothing` for a different URL so the caller can `Maybe`-pattern-match in
|
|
124
|
+
`update` without nested `case`s.
|
|
125
|
+
|
|
126
|
+
update msg model =
|
|
127
|
+
case msg of
|
|
128
|
+
GotEvent event ->
|
|
129
|
+
case Sse.match "/__elm-ssr/live" tickDecoder event of
|
|
130
|
+
Just (Ok tick) ->
|
|
131
|
+
( { model | tick = tick }, Cmd.none )
|
|
132
|
+
|
|
133
|
+
Just (Err _) ->
|
|
134
|
+
( model, Cmd.none )
|
|
135
|
+
|
|
136
|
+
Nothing ->
|
|
137
|
+
( model, Cmd.none )
|
|
138
|
+
|
|
139
|
+
-}
|
|
140
|
+
match : String -> Decoder a -> Event -> Maybe (Result Error a)
|
|
141
|
+
match url decoder event =
|
|
142
|
+
if event.url == url then
|
|
143
|
+
case Decode.decodeString decoder event.data of
|
|
144
|
+
Ok value ->
|
|
145
|
+
Just (Ok value)
|
|
146
|
+
|
|
147
|
+
Err err ->
|
|
148
|
+
Just (Err (DecodeError (Decode.errorToString err)))
|
|
149
|
+
|
|
150
|
+
else
|
|
151
|
+
Nothing
|
|
@@ -6,7 +6,9 @@ module ElmSsr.Loader exposing
|
|
|
6
6
|
, cacheGet, cachePut
|
|
7
7
|
, query, queryOne, execute
|
|
8
8
|
, env
|
|
9
|
+
, getCookie
|
|
9
10
|
, enqueue
|
|
11
|
+
, session, csrfToken, setSession, clearSession
|
|
10
12
|
, Effect, Step(..), step, encodeEffect
|
|
11
13
|
)
|
|
12
14
|
|
|
@@ -30,9 +32,19 @@ fully typed end to end.
|
|
|
30
32
|
@docs cacheGet, cachePut
|
|
31
33
|
@docs query, queryOne, execute
|
|
32
34
|
@docs env
|
|
35
|
+
@docs getCookie
|
|
33
36
|
@docs enqueue
|
|
34
37
|
|
|
35
38
|
|
|
39
|
+
# Sessions
|
|
40
|
+
|
|
41
|
+
These require the TS-side `sessionMiddleware` + `sessionEffects(runner)` to be
|
|
42
|
+
wired in the Worker. Without them the effects fail with a clear message at
|
|
43
|
+
request time.
|
|
44
|
+
|
|
45
|
+
@docs session, csrfToken, setSession, clearSession
|
|
46
|
+
|
|
47
|
+
|
|
36
48
|
# Runtime interpretation
|
|
37
49
|
|
|
38
50
|
These are used by the elm-ssr runtime to drive a loader. Application authors do
|
|
@@ -204,6 +216,24 @@ env name =
|
|
|
204
216
|
(\result -> resumeFetchJson (Decode.nullable Decode.string) result)
|
|
205
217
|
|
|
206
218
|
|
|
219
|
+
{-| Read a cookie from the incoming request by name. Returns `Nothing` if the
|
|
220
|
+
cookie is not present (or no `Cookie` header was sent).
|
|
221
|
+
|
|
222
|
+
sessionToken : Loader (Maybe String)
|
|
223
|
+
sessionToken =
|
|
224
|
+
Loader.getCookie "session"
|
|
225
|
+
|
|
226
|
+
The Worker parses the `Cookie` header for you. To *set* a cookie on the
|
|
227
|
+
response, use `ElmSsr.Action.setCookie` from inside an `Action`.
|
|
228
|
+
-}
|
|
229
|
+
getCookie : String -> Loader (Maybe String)
|
|
230
|
+
getCookie name =
|
|
231
|
+
Pending
|
|
232
|
+
{ kind = "cookie", payload = Encode.object [ ( "name", Encode.string name ) ]
|
|
233
|
+
}
|
|
234
|
+
(\result -> resumeFetchJson (Decode.nullable Decode.string) result)
|
|
235
|
+
|
|
236
|
+
|
|
207
237
|
{-| Enqueue a background task to run after the response (fire-and-forget). The
|
|
208
238
|
named handler lives in the Worker's task adapter; the request does not wait for
|
|
209
239
|
it. Typically used from an `Action` via `Action.fromLoader`. -}
|
|
@@ -220,6 +250,65 @@ enqueue config =
|
|
|
220
250
|
(\result -> resumeFetchJson (Decode.succeed ()) result)
|
|
221
251
|
|
|
222
252
|
|
|
253
|
+
{-| Read the current session payload (whatever was last `setSession`-ed) and
|
|
254
|
+
decode it. Returns `Nothing` when the session is empty.
|
|
255
|
+
|
|
256
|
+
type alias User = { id : String, email : String }
|
|
257
|
+
|
|
258
|
+
currentUser : Loader (Maybe User)
|
|
259
|
+
currentUser =
|
|
260
|
+
Loader.session userDecoder
|
|
261
|
+
|
|
262
|
+
-}
|
|
263
|
+
session : Decoder a -> Loader (Maybe a)
|
|
264
|
+
session decoder =
|
|
265
|
+
Pending
|
|
266
|
+
{ kind = "session", payload = Encode.object [] }
|
|
267
|
+
(\result -> resumeFetchJson (Decode.nullable decoder) result)
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
{-| Read the current request's CSRF token. Embed it in form submissions
|
|
271
|
+
(hidden input named `_csrf`) or in an `X-CSRF-Token` header on `fetch`. The
|
|
272
|
+
TS-side `csrfMiddleware` checks the match on POST/PUT/PATCH/DELETE.
|
|
273
|
+
-}
|
|
274
|
+
csrfToken : Loader (Maybe String)
|
|
275
|
+
csrfToken =
|
|
276
|
+
Pending
|
|
277
|
+
{ kind = "csrfToken", payload = Encode.object [] }
|
|
278
|
+
(\result -> resumeFetchJson (Decode.nullable Decode.string) result)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
{-| Replace the current session payload. Typically used from an `Action` via
|
|
282
|
+
`Action.fromLoader` after authenticating. The middleware persists and rolls
|
|
283
|
+
the signed cookie on the response.
|
|
284
|
+
|
|
285
|
+
Action.fromLoader (Loader.setSession (encodeUser user))
|
|
286
|
+
|> Action.andThen (\_ -> Action.redirect "/dashboard")
|
|
287
|
+
|
|
288
|
+
-}
|
|
289
|
+
setSession : Encode.Value -> Loader ()
|
|
290
|
+
setSession value =
|
|
291
|
+
Pending
|
|
292
|
+
{ kind = "setSession"
|
|
293
|
+
, payload = Encode.object [ ( "value", value ) ]
|
|
294
|
+
}
|
|
295
|
+
(\_ -> Done ())
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
{-| Destroy the current session. The middleware deletes the record from the
|
|
299
|
+
store and clears the signed cookie on the response.
|
|
300
|
+
|
|
301
|
+
Action.fromLoader Loader.clearSession
|
|
302
|
+
|> Action.andThen (\_ -> Action.redirect "/")
|
|
303
|
+
|
|
304
|
+
-}
|
|
305
|
+
clearSession : Loader ()
|
|
306
|
+
clearSession =
|
|
307
|
+
Pending
|
|
308
|
+
{ kind = "clearSession", payload = Encode.object [] }
|
|
309
|
+
(\_ -> Done ())
|
|
310
|
+
|
|
311
|
+
|
|
223
312
|
sqlPayload : String -> List Encode.Value -> Encode.Value
|
|
224
313
|
sqlPayload sql params =
|
|
225
314
|
Encode.object
|
|
@@ -117,7 +117,7 @@ advance : Config -> Loader (Document Never) -> ( State, Cmd Msg )
|
|
|
117
117
|
advance config loader =
|
|
118
118
|
case Loader.step loader of
|
|
119
119
|
Loader.Resolved document ->
|
|
120
|
-
( Rendered, render config document )
|
|
120
|
+
( Rendered, render config [] document )
|
|
121
121
|
|
|
122
122
|
Loader.Errored status message ->
|
|
123
123
|
( Aborted status message, renderError config status message )
|
|
@@ -128,36 +128,46 @@ advance config loader =
|
|
|
128
128
|
|
|
129
129
|
advanceAction : Config -> Action (Document Never) -> ( State, Cmd Msg )
|
|
130
130
|
advanceAction config action =
|
|
131
|
-
|
|
131
|
+
let
|
|
132
|
+
( cookies, base ) =
|
|
133
|
+
Action.collectCookies action
|
|
134
|
+
in
|
|
135
|
+
case Action.step base of
|
|
132
136
|
Action.Resolved document ->
|
|
133
|
-
( Rendered, render config document )
|
|
137
|
+
( Rendered, render config cookies document )
|
|
134
138
|
|
|
135
139
|
Action.Errored status message ->
|
|
136
|
-
( Aborted status message,
|
|
140
|
+
( Aborted status message, renderActionError config cookies status message )
|
|
137
141
|
|
|
138
142
|
Action.Moved url ->
|
|
139
|
-
( Rendered, config.ports.rendered (Action.encodeStep (always Json.null) (Action.Moved url)) )
|
|
143
|
+
( Rendered, config.ports.rendered (Action.encodeStep cookies (always Json.null) (Action.Moved url)) )
|
|
140
144
|
|
|
141
145
|
Action.SentJson value ->
|
|
142
|
-
( Rendered, config.ports.rendered (Action.encodeStep (always Json.null) (Action.SentJson value)) )
|
|
146
|
+
( Rendered, config.ports.rendered (Action.encodeStep cookies (always Json.null) (Action.SentJson value)) )
|
|
143
147
|
|
|
144
148
|
Action.Await effect continue ->
|
|
145
149
|
( PerformingAction continue, config.ports.effectRequest (Loader.encodeEffect effect) )
|
|
146
150
|
|
|
147
151
|
|
|
148
|
-
render : Config -> Document Never -> Cmd Msg
|
|
149
|
-
render config document =
|
|
150
|
-
config.ports.rendered (Action.encodeStep Encode.encode (Action.Resolved (Document.map never document)))
|
|
152
|
+
render : Config -> List Action.Cookie -> Document Never -> Cmd Msg
|
|
153
|
+
render config cookies document =
|
|
154
|
+
config.ports.rendered (Action.encodeStep cookies Encode.encode (Action.Resolved (Document.map never document)))
|
|
151
155
|
|
|
152
156
|
|
|
153
157
|
renderError : Config -> Int -> String -> Cmd Msg
|
|
154
158
|
renderError config status message =
|
|
159
|
+
renderActionError config [] status message
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
renderActionError : Config -> List Action.Cookie -> Int -> String -> Cmd Msg
|
|
163
|
+
renderActionError config cookies status message =
|
|
155
164
|
config.ports.rendered
|
|
156
165
|
(Json.object
|
|
157
166
|
[ ( "kind", Json.string "errored" )
|
|
158
167
|
, ( "status", Json.int status )
|
|
159
168
|
, ( "message", Json.string message )
|
|
160
169
|
, ( "value", Encode.encode (Document.map never (Page.error status message)) )
|
|
170
|
+
, ( "cookies", Action.encodeCookies cookies )
|
|
161
171
|
]
|
|
162
172
|
)
|
|
163
173
|
|