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 CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { readFile } from "node:fs/promises";
4
4
  import { resolve } from "node:path";
5
- import { createExampleScaffold } from "../lib/scaffold.mjs";
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 example app and register it in elm-ssr.config.json
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 created = await createExampleScaffold(rootPath, name);
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
- , Effect, Step(..), step, encodeStep
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
- {-| Encode a terminal step for the Worker runtime. `Await` is never encoded
176
- the runtime runs its effect first so it is reported defensively as an error. -}
177
- encodeStep : (a -> Encode.Value) -> Step a -> Encode.Value
178
- encodeStep encoder step_ =
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
- case Action.step action of
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, renderError config 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