elm-ssr 0.2.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/README.md +48 -342
- package/elm-src/ElmSsr/Island/Sse.elm +151 -0
- package/package.json +53 -24
- package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
- package/src/sse.ts +162 -0
- package/AGENTS.md +0 -289
- package/CHANGELOG.md +0 -87
- package/LICENSE +0 -21
- package/bun.lock +0 -259
- package/docker-compose.yml +0 -33
- package/docs/README.md +0 -51
- package/docs/backends.md +0 -146
- package/docs/cli.md +0 -117
- package/docs/effects.md +0 -91
- package/docs/getting-started.md +0 -94
- package/docs/islands.md +0 -197
- package/docs/loaders-and-actions.md +0 -241
- package/docs/middleware.md +0 -93
- package/docs/migrations.md +0 -143
- package/docs/routing.md +0 -108
- package/docs/sessions.md +0 -218
- package/docs/tasks.md +0 -149
- package/docs/testing.md +0 -84
- package/elm-ssr.config.json +0 -14
- package/examples/basic/elm.json +0 -27
- package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
- package/examples/basic/migrations/0001_guestbook.sql +0 -10
- package/examples/basic/package.json +0 -10
- package/examples/basic/runtime.ts +0 -148
- package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
- package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
- package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
- package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
- package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
- package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
- package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
- package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
- package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
- package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
- package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
- package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
- package/examples/basic/styles.ts +0 -204
- package/examples/basic/worker.ts +0 -3
- package/examples/crypto-dashboard/elm.json +0 -30
- package/examples/crypto-dashboard/package.json +0 -10
- package/examples/crypto-dashboard/runtime.ts +0 -97
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
- package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
- package/examples/crypto-dashboard/styles.ts +0 -23
- package/examples/crypto-dashboard/worker.ts +0 -3
- package/llms.txt +0 -69
- package/packages/elm-ssr/README.md +0 -67
- package/packages/elm-ssr/package.json +0 -61
- package/scripts/benchmark.mjs +0 -60
- package/test/action.test.ts +0 -81
- package/test/adapters.test.ts +0 -173
- package/test/advanced-robustness.test.ts +0 -75
- package/test/app.test.ts +0 -209
- package/test/browser-island.test.ts +0 -184
- package/test/cli-migrate.test.ts +0 -97
- package/test/cli.test.ts +0 -94
- package/test/cookies.test.ts +0 -156
- package/test/crypto-dashboard.test.ts +0 -35
- package/test/effects.test.ts +0 -117
- package/test/http.test.ts +0 -50
- package/test/integration/redis-postgres.test.ts +0 -174
- package/test/island-runtime.test.ts +0 -214
- package/test/middleware.test.ts +0 -134
- package/test/migrations.test.ts +0 -244
- package/test/profile.test.ts +0 -159
- package/test/robustness.test.ts +0 -135
- package/test/serialize.test.ts +0 -92
- package/test/sessions.test.ts +0 -429
- package/test/svg.test.ts +0 -65
- package/tsconfig.json +0 -20
- package/wrangler.jsonc +0 -11
- /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
- /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
- /package/{packages/elm-ssr/src → src}/app.ts +0 -0
- /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
- /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/http.ts +0 -0
- /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
- /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
- /package/{packages/elm-ssr/src → src}/render.ts +0 -0
- /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
- /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
- /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
- /package/{packages/elm-ssr/src → src}/tasks.ts +0 -0
package/docs/sessions.md
DELETED
|
@@ -1,218 +0,0 @@
|
|
|
1
|
-
# Sessions and CSRF
|
|
2
|
-
|
|
3
|
-
Signed-cookie sessions backed by a pluggable store, plus CSRF protection for
|
|
4
|
-
form submissions. The whole stack is opt-in via two flags on
|
|
5
|
-
`createWorkerApp`. The Elm side picks the session up via four new effects:
|
|
6
|
-
`Loader.session`, `Loader.csrfToken`, `Loader.setSession`, and
|
|
7
|
-
`Loader.clearSession`.
|
|
8
|
-
|
|
9
|
-
## Quickstart
|
|
10
|
-
|
|
11
|
-
```ts
|
|
12
|
-
import { createWorkerApp } from "elm-ssr";
|
|
13
|
-
import { memorySessionStore, cacheStore } from "elm-ssr/sessions";
|
|
14
|
-
import { redisCache } from "elm-ssr/backends";
|
|
15
|
-
|
|
16
|
-
const worker = createWorkerApp({
|
|
17
|
-
// ... your usual options (elmModule, routes, createFlags, effects, ...)
|
|
18
|
-
|
|
19
|
-
// Enable signed-cookie sessions + auto-wrap your effect runner with
|
|
20
|
-
// sessionEffects so Loader.session / setSession work.
|
|
21
|
-
sessions: {
|
|
22
|
-
secret: env.SESSION_SECRET, // 32+ random bytes; do not leak
|
|
23
|
-
store: cacheStore(redisCache(myRedisClient)),
|
|
24
|
-
// Defaults: cookieName "session", maxAgeSeconds 7 days,
|
|
25
|
-
// cookiePath "/", secure true, sameSite "lax", HttpOnly.
|
|
26
|
-
},
|
|
27
|
-
|
|
28
|
-
// Enable CSRF (requires sessions). `true` uses defaults; pass an object to customize.
|
|
29
|
-
csrf: true
|
|
30
|
-
});
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
In dev / tests, swap in `memorySessionStore()` and `secure: false` so the
|
|
34
|
-
cookie works over plain HTTP.
|
|
35
|
-
|
|
36
|
-
## The flow
|
|
37
|
-
|
|
38
|
-
1. **Request arrives.** `sessionMiddleware` reads the configured cookie, HMAC-verifies it, looks up the record in the store. If anything's missing/tampered, it **mints a fresh session** (with a new CSRF token) so downstream code never has to nil-check.
|
|
39
|
-
2. **Effects run.** `sessionEffects` (auto-wired when `sessions:` is set) intercepts four Elm-facing effect kinds and reads/writes `context.session`:
|
|
40
|
-
- `session` → returns `session.data` or `null`.
|
|
41
|
-
- `csrfToken` → returns `session.csrf`.
|
|
42
|
-
- `setSession` → mutates `session.data`, marks `dirty`.
|
|
43
|
-
- `clearSession` → marks `destroyed`.
|
|
44
|
-
3. **Response comes back.** `sessionMiddleware`:
|
|
45
|
-
- If `destroyed`: deletes the record and emits a `Max-Age=0` Set-Cookie.
|
|
46
|
-
- If `dirty` or new: persists to the store and emits a fresh signed Set-Cookie.
|
|
47
|
-
- Else: leaves the response alone.
|
|
48
|
-
4. **`csrfMiddleware` runs in between.** For POST/PUT/PATCH/DELETE it requires a token matching `context.session.csrf`. Token comes from the `X-CSRF-Token` header or a `_csrf` form field. Mismatch → 403 (JSON for `/api/*`, plain text otherwise). Safe verbs pass through untouched.
|
|
49
|
-
|
|
50
|
-
## Stores
|
|
51
|
-
|
|
52
|
-
```ts
|
|
53
|
-
interface SessionStore {
|
|
54
|
-
get(id: string): Promise<SessionRecord | null>;
|
|
55
|
-
set(id: string, record: SessionRecord): Promise<void>;
|
|
56
|
-
delete(id: string): Promise<void>;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
interface SessionRecord {
|
|
60
|
-
data: unknown; // your app's payload (JSON-serialisable)
|
|
61
|
-
csrf: string;
|
|
62
|
-
expiresAt?: number; // epoch ms
|
|
63
|
-
}
|
|
64
|
-
```
|
|
65
|
-
|
|
66
|
-
Two built-ins:
|
|
67
|
-
|
|
68
|
-
- **`memorySessionStore()`** — a `Map`-backed store. Useful for tests and dev; sessions vanish when the process restarts.
|
|
69
|
-
- **`cacheStore(backend, options?)`** — wraps any [`CacheBackend`](backends.md) (so `redisCache(...)`, a KV-backed wrapper, etc.). Sessions are prefixed (default `"elm-ssr:session:"`) to avoid colliding with your other cache uses, and TTLs come from each record's `expiresAt`.
|
|
70
|
-
|
|
71
|
-
For SQL-backed sessions, implement `SessionStore` against your driver — that's three async functions.
|
|
72
|
-
|
|
73
|
-
## Authoring in Elm
|
|
74
|
-
|
|
75
|
-
### Read the session
|
|
76
|
-
|
|
77
|
-
```elm
|
|
78
|
-
import Json.Decode as Decode
|
|
79
|
-
import ElmSsr.Loader as Loader exposing (Loader)
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
type alias User =
|
|
83
|
-
{ id : String, email : String }
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
userDecoder : Decode.Decoder User
|
|
87
|
-
userDecoder =
|
|
88
|
-
Decode.map2 User
|
|
89
|
-
(Decode.field "id" Decode.string)
|
|
90
|
-
(Decode.field "email" Decode.string)
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
currentUser : Loader (Maybe User)
|
|
94
|
-
currentUser =
|
|
95
|
-
Loader.session userDecoder
|
|
96
|
-
```
|
|
97
|
-
|
|
98
|
-
`Loader.session` returns `Nothing` when no session payload has been set yet
|
|
99
|
-
(fresh anonymous visitor).
|
|
100
|
-
|
|
101
|
-
### Read the CSRF token (to embed in a form)
|
|
102
|
-
|
|
103
|
-
```elm
|
|
104
|
-
form
|
|
105
|
-
[ Attr.method "post", Attr.action "/profile" ]
|
|
106
|
-
(csrfHidden token ++ [ {- inputs, button -} ])
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
csrfHidden : Maybe String -> List (Node msg)
|
|
110
|
-
csrfHidden token =
|
|
111
|
-
case token of
|
|
112
|
-
Just value ->
|
|
113
|
-
[ input [ Attr.type_ "hidden", Attr.name "_csrf", Attr.value value ] ]
|
|
114
|
-
|
|
115
|
-
Nothing ->
|
|
116
|
-
[]
|
|
117
|
-
```
|
|
118
|
-
|
|
119
|
-
A page that needs both the session AND the token at once:
|
|
120
|
-
|
|
121
|
-
```elm
|
|
122
|
-
page _ =
|
|
123
|
-
Loader.map2 view
|
|
124
|
-
(Loader.session userDecoder)
|
|
125
|
-
Loader.csrfToken
|
|
126
|
-
```
|
|
127
|
-
|
|
128
|
-
### Write the session (login)
|
|
129
|
-
|
|
130
|
-
```elm
|
|
131
|
-
import Json.Encode as Encode
|
|
132
|
-
import ElmSsr.Action as Action exposing (Action)
|
|
133
|
-
import ElmSsr.Loader as Loader
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
action : Request -> Action (Document Never)
|
|
137
|
-
action request =
|
|
138
|
-
case Route.formValue "username" request of
|
|
139
|
-
Just username ->
|
|
140
|
-
Action.fromLoader
|
|
141
|
-
(Loader.setSession
|
|
142
|
-
(Encode.object [ ( "username", Encode.string username ) ]))
|
|
143
|
-
|> Action.andThen (\_ -> Action.redirect "/dashboard")
|
|
144
|
-
|
|
145
|
-
Nothing ->
|
|
146
|
-
Action.fail 422 "Username is required"
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
`Loader.setSession` takes a `Json.Encode.Value` — that's the only constraint
|
|
150
|
-
on payload shape. The middleware persists and rolls the signed cookie when
|
|
151
|
-
the response is built.
|
|
152
|
-
|
|
153
|
-
### Destroy the session (logout)
|
|
154
|
-
|
|
155
|
-
```elm
|
|
156
|
-
action _ =
|
|
157
|
-
Action.fromLoader Loader.clearSession
|
|
158
|
-
|> Action.andThen (\_ -> Action.redirect "/")
|
|
159
|
-
```
|
|
160
|
-
|
|
161
|
-
The middleware deletes the record and clears the cookie.
|
|
162
|
-
|
|
163
|
-
## CSRF in detail
|
|
164
|
-
|
|
165
|
-
`csrfMiddleware` enforces CSRF tokens for unsafe HTTP methods:
|
|
166
|
-
|
|
167
|
-
| Method | Token required? |
|
|
168
|
-
| ------ | --------------- |
|
|
169
|
-
| GET, HEAD, OPTIONS | No |
|
|
170
|
-
| POST, PUT, PATCH, DELETE, anything else | Yes |
|
|
171
|
-
|
|
172
|
-
The token is looked up in this order:
|
|
173
|
-
|
|
174
|
-
1. The `X-CSRF-Token` request header (configurable via `headerName`).
|
|
175
|
-
2. The `_csrf` field of a URL-encoded or multipart form body (configurable via `fieldName`).
|
|
176
|
-
|
|
177
|
-
Either matches the current request's `context.session.csrf`. Mismatch ⇒ 403.
|
|
178
|
-
|
|
179
|
-
```ts
|
|
180
|
-
csrf: {
|
|
181
|
-
headerName: "x-csrf-token", // default
|
|
182
|
-
fieldName: "_csrf", // default
|
|
183
|
-
skipPaths: ["/webhooks/"] // prefixes to bypass — useful for inbound webhooks
|
|
184
|
-
}
|
|
185
|
-
```
|
|
186
|
-
|
|
187
|
-
`csrfMiddleware` reads the form body via `request.clone().formData()` so the
|
|
188
|
-
downstream route handler can still read it.
|
|
189
|
-
|
|
190
|
-
## Security notes
|
|
191
|
-
|
|
192
|
-
- **Secret rotation.** The HMAC secret signs every cookie. Rotating it
|
|
193
|
-
invalidates all existing sessions (next request mints a new one). Treat as
|
|
194
|
-
a Cloudflare secret binding, never commit.
|
|
195
|
-
- **Cookie attributes.** Defaults are `HttpOnly` + `Secure` + `SameSite=Lax`
|
|
196
|
-
+ 7-day Max-Age + `Path=/`. Override `secure: false` only when developing
|
|
197
|
-
on plain HTTP (`http://localhost`).
|
|
198
|
-
- **CSRF tokens.** 32 bytes from `crypto.getRandomValues`, URL-safe Base64.
|
|
199
|
-
Rotated on session destroy (the new session minted afterward gets a fresh
|
|
200
|
-
token).
|
|
201
|
-
- **Constant-time signature check.** `verifyValue` uses constant-time byte
|
|
202
|
-
comparison.
|
|
203
|
-
- **Cookie size.** The signed cookie value is just the session id + signature
|
|
204
|
-
(~120 bytes). Payload lives in the store, never in the cookie — that
|
|
205
|
-
avoids the 4 KB cookie limit + lets you change session data without
|
|
206
|
-
re-signing on every response.
|
|
207
|
-
|
|
208
|
-
## End-to-end example
|
|
209
|
-
|
|
210
|
-
[examples/basic/src/Example/Basic/Routes/Profile.elm](../examples/basic/src/Example/Basic/Routes/Profile.elm)
|
|
211
|
-
is the full login/logout flow over the API above. The session-enabled worker
|
|
212
|
-
that runs it is exported as `createSessionExampleWorker` in
|
|
213
|
-
[examples/basic/runtime.ts](../examples/basic/runtime.ts).
|
|
214
|
-
|
|
215
|
-
## Source
|
|
216
|
-
|
|
217
|
-
- [packages/elm-ssr/src/sessions/](../packages/elm-ssr/src/sessions/) — `crypto.ts`, `types.ts`, `store.ts`, `middleware.ts`, `effects.ts`, `index.ts`.
|
|
218
|
-
- Tests: [test/sessions.test.ts](../test/sessions.test.ts), [test/profile.test.ts](../test/profile.test.ts).
|
package/docs/tasks.md
DELETED
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
# Tasks and queues
|
|
2
|
-
|
|
3
|
-
Background work that runs **after** the response goes out — emails, cache
|
|
4
|
-
warming, webhooks, anything you don't want blocking the user.
|
|
5
|
-
|
|
6
|
-
`Loader.enqueue { task, payload }` is the Elm-side entry point. What happens
|
|
7
|
-
to that payload depends on which adapter you wrap the runner with:
|
|
8
|
-
|
|
9
|
-
- **`withTasks(runner, handlers)`** — runs the task inline after the
|
|
10
|
-
response, kept alive by `ctx.waitUntil` on Cloudflare, fire-and-forget on
|
|
11
|
-
Bun. Best for fast, idempotent work.
|
|
12
|
-
- **`withQueueProducer(runner, { queueBinding })`** — sends the task to a
|
|
13
|
-
Cloudflare Queue (durable, retried on failure). A separate consumer Worker
|
|
14
|
-
picks it up with `createQueueConsumer(handlers)`.
|
|
15
|
-
|
|
16
|
-
Both wrap any existing runner, so they compose with `withCache`,
|
|
17
|
-
`cloudflareEffects`, `inMemoryEffects`, etc.
|
|
18
|
-
|
|
19
|
-
## Enqueueing from Elm
|
|
20
|
-
|
|
21
|
-
```elm
|
|
22
|
-
import Json.Encode as Encode
|
|
23
|
-
import ElmSsr.Action as Action
|
|
24
|
-
import ElmSsr.Loader as Loader
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
sendThankYou : String -> Loader ()
|
|
28
|
-
sendThankYou email =
|
|
29
|
-
Loader.enqueue
|
|
30
|
-
{ task = "sendEmail"
|
|
31
|
-
, payload =
|
|
32
|
-
Encode.object
|
|
33
|
-
[ ( "to", Encode.string email )
|
|
34
|
-
, ( "subject", Encode.string "Thanks!" )
|
|
35
|
-
]
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
action : Request -> Action (Document Never)
|
|
40
|
-
action request =
|
|
41
|
-
case Route.formValue "email" request of
|
|
42
|
-
Just email ->
|
|
43
|
-
Action.fromLoader (saveSubscriber email)
|
|
44
|
-
|> Action.andThen (\_ -> Action.fromLoader (sendThankYou email))
|
|
45
|
-
|> Action.andThen (\_ -> Action.redirect "/thanks")
|
|
46
|
-
|
|
47
|
-
Nothing ->
|
|
48
|
-
Action.fail 422 "Email is required"
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
The action returns its response right after `saveSubscriber`; `sendEmail` runs
|
|
52
|
-
afterward (with `waitUntil`), so the user isn't waiting for SMTP.
|
|
53
|
-
|
|
54
|
-
## `withTasks` (inline, fastest)
|
|
55
|
-
|
|
56
|
-
```ts
|
|
57
|
-
import { withTasks } from "elm-ssr/tasks";
|
|
58
|
-
import { cloudflareEffects } from "elm-ssr/effects";
|
|
59
|
-
|
|
60
|
-
const effects = withTasks(cloudflareEffects(), {
|
|
61
|
-
sendEmail: async (payload, ctx) => {
|
|
62
|
-
await fetch("https://api.sendgrid.com/v3/mail/send", {
|
|
63
|
-
method: "POST",
|
|
64
|
-
headers: { authorization: `Bearer ${ctx.env?.SENDGRID_KEY}` },
|
|
65
|
-
body: JSON.stringify(payload)
|
|
66
|
-
});
|
|
67
|
-
},
|
|
68
|
-
warmCache: async (_payload, ctx) => {
|
|
69
|
-
/* … */
|
|
70
|
-
}
|
|
71
|
-
});
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Each handler receives `(payload, effectContext)`. The context has `env`,
|
|
75
|
-
`request`, and `waitUntil` — same shape every effect sees.
|
|
76
|
-
|
|
77
|
-
**Behavior:**
|
|
78
|
-
- On Cloudflare: scheduled via `ctx.waitUntil(handler(...))`. The isolate
|
|
79
|
-
stays alive long enough to finish.
|
|
80
|
-
- On Bun (no `waitUntil`): runs detached (`void promise`). Survives only as
|
|
81
|
-
long as the process does.
|
|
82
|
-
- Handler throws → `console.error("elm-ssr: background task \"NAME\" failed",
|
|
83
|
-
error)`; the request is unaffected.
|
|
84
|
-
- Unknown task name → the **`enqueue`** effect fails (`No task handler
|
|
85
|
-
registered for "NAME"`), so the action surfaces the error to the caller.
|
|
86
|
-
|
|
87
|
-
## `withQueueProducer` + `createQueueConsumer` (durable)
|
|
88
|
-
|
|
89
|
-
For work that **must** survive a Worker restart or retry on failure, use a
|
|
90
|
-
Cloudflare Queue. The producer Worker enqueues; a separate consumer Worker
|
|
91
|
-
(or the same Worker with a `queue` handler) processes the messages.
|
|
92
|
-
|
|
93
|
-
### Producer
|
|
94
|
-
|
|
95
|
-
```ts
|
|
96
|
-
import { withQueueProducer } from "elm-ssr/tasks";
|
|
97
|
-
|
|
98
|
-
const effects = withQueueProducer(cloudflareEffects(), {
|
|
99
|
-
queueBinding: "JOBS" // default "QUEUE"
|
|
100
|
-
});
|
|
101
|
-
```
|
|
102
|
-
|
|
103
|
-
`Loader.enqueue { task: "warmCache", payload: ... }` sends
|
|
104
|
-
`{ task: "warmCache", payload: ... }` to `env.JOBS.send(...)`. If the binding
|
|
105
|
-
is missing, the effect fails with `Missing queue binding "JOBS"`.
|
|
106
|
-
|
|
107
|
-
### Consumer
|
|
108
|
-
|
|
109
|
-
```ts
|
|
110
|
-
import { createQueueConsumer } from "elm-ssr/tasks";
|
|
111
|
-
|
|
112
|
-
export default {
|
|
113
|
-
fetch: worker.fetch, // from createWorkerApp
|
|
114
|
-
queue: createQueueConsumer({
|
|
115
|
-
warmCache: async (payload, ctx) => { /* … */ },
|
|
116
|
-
sendEmail: async (payload, ctx) => { /* … */ }
|
|
117
|
-
})
|
|
118
|
-
};
|
|
119
|
-
```
|
|
120
|
-
|
|
121
|
-
The consumer reads each message's `{ task, payload }`, dispatches to the
|
|
122
|
-
named handler, and:
|
|
123
|
-
- **`message.ack()`** on success.
|
|
124
|
-
- **`message.retry()`** if the handler throws or the task name has no
|
|
125
|
-
handler.
|
|
126
|
-
|
|
127
|
-
Bind the queue in `wrangler.jsonc`:
|
|
128
|
-
|
|
129
|
-
```jsonc
|
|
130
|
-
{
|
|
131
|
-
"queues": {
|
|
132
|
-
"producers": [{ "queue": "elm-ssr-jobs", "binding": "JOBS" }],
|
|
133
|
-
"consumers": [{ "queue": "elm-ssr-jobs", "max_batch_size": 10 }]
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
```
|
|
137
|
-
|
|
138
|
-
## Choosing
|
|
139
|
-
|
|
140
|
-
| Need | Use |
|
|
141
|
-
| ---- | --- |
|
|
142
|
-
| Quick post-response work, fine to lose on crash | `withTasks` |
|
|
143
|
-
| Durable jobs, retry on failure, backpressure | `withQueueProducer` + `createQueueConsumer` |
|
|
144
|
-
| Both (some inline, some durable) | Wrap the runner with **both** — `enqueue` is intercepted by the outermost matching adapter, so order matters. Put the more-specific one outside, or split task names cleanly. |
|
|
145
|
-
|
|
146
|
-
## Source
|
|
147
|
-
|
|
148
|
-
- [packages/elm-ssr/src/tasks.ts](../packages/elm-ssr/src/tasks.ts)
|
|
149
|
-
- [packages/elm-ssr/elm-src/ElmSsr/Loader.elm](../packages/elm-ssr/elm-src/ElmSsr/Loader.elm) (`enqueue`)
|
package/docs/testing.md
DELETED
|
@@ -1,84 +0,0 @@
|
|
|
1
|
-
# Testing
|
|
2
|
-
|
|
3
|
-
The repo's test loops are split by what they need running. All run through
|
|
4
|
-
`bun test`.
|
|
5
|
-
|
|
6
|
-
## Scripts
|
|
7
|
-
|
|
8
|
-
| Script | Brings Docker up? | What runs |
|
|
9
|
-
| ------ | ----------------- | --------- |
|
|
10
|
-
| `bun run test:unit` | No | All `test/*.test.ts` except `test/integration/`. Fast. |
|
|
11
|
-
| `bun run test:integration` | Yes (PG + Redis) | Only `test/integration/`. Tears Docker back down on exit. |
|
|
12
|
-
| `bun run test` | Yes (PG + Redis) | Everything. Tears Docker back down on exit. |
|
|
13
|
-
|
|
14
|
-
Each Docker-managed script runs `docker compose up -d --wait`, executes the
|
|
15
|
-
suite, and then runs `docker compose down` — even on test failure (it
|
|
16
|
-
preserves the exit code).
|
|
17
|
-
|
|
18
|
-
## Local quick loop
|
|
19
|
-
|
|
20
|
-
While writing code, use `test:unit` — no Docker, ~400ms for ~108 tests:
|
|
21
|
-
|
|
22
|
-
```sh
|
|
23
|
-
bun run test:unit
|
|
24
|
-
```
|
|
25
|
-
|
|
26
|
-
## Full run
|
|
27
|
-
|
|
28
|
-
```sh
|
|
29
|
-
bun run test
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
This compiles the example apps (`bun run build`), brings Docker up, runs
|
|
33
|
-
everything (114 tests as of this writing), tears Docker down. Useful before a
|
|
34
|
-
PR or a release.
|
|
35
|
-
|
|
36
|
-
## Integration suite
|
|
37
|
-
|
|
38
|
-
`test/integration/redis-postgres.test.ts` exercises:
|
|
39
|
-
|
|
40
|
-
- `redisCache` (`withCache`) round-trips against a real Redis (`Bun.redis`).
|
|
41
|
-
- TTL expiration.
|
|
42
|
-
- `runMigrations` / `revertMigrations` / `listMigrations` against real
|
|
43
|
-
Postgres (`Bun.sql`).
|
|
44
|
-
- `postgresSql` adapter (`query`/`queryOne`/`execute`) against real Postgres.
|
|
45
|
-
|
|
46
|
-
It **throws** if `DATABASE_URL` / `REDIS_URL` aren't set. That's deliberate
|
|
47
|
-
— it points you at `bun run test:integration` instead of silently
|
|
48
|
-
green-skipping when you actually wanted to run it.
|
|
49
|
-
|
|
50
|
-
## Docker services
|
|
51
|
-
|
|
52
|
-
[docker-compose.yml](../docker-compose.yml) defines:
|
|
53
|
-
|
|
54
|
-
- `postgres:16-alpine` on `localhost:5432`, user/pass/db = `elmssr`.
|
|
55
|
-
- `redis:7-alpine` on `localhost:6379`.
|
|
56
|
-
|
|
57
|
-
Both have healthchecks; `--wait` blocks until they're ready.
|
|
58
|
-
|
|
59
|
-
## Writing tests
|
|
60
|
-
|
|
61
|
-
Each test suite lives in `test/<area>.test.ts`. Pattern:
|
|
62
|
-
|
|
63
|
-
```ts
|
|
64
|
-
import { describe, expect, it } from "bun:test";
|
|
65
|
-
import { createWorkerApp } from "elm-ssr";
|
|
66
|
-
import { inMemoryEffects } from "elm-ssr/effects";
|
|
67
|
-
|
|
68
|
-
describe("worker", () => {
|
|
69
|
-
it("renders a page", async () => {
|
|
70
|
-
const worker = createWorkerApp({ /* … */, effects: inMemoryEffects() });
|
|
71
|
-
const response = await worker.fetch(new Request("https://example.com/"));
|
|
72
|
-
expect(response.status).toBe(200);
|
|
73
|
-
});
|
|
74
|
-
});
|
|
75
|
-
```
|
|
76
|
-
|
|
77
|
-
Use `inMemoryEffects` (optionally with `withCache(..., redisCache(...))` /
|
|
78
|
-
`postgresSql(...)`) to test effect interactions without a real backend.
|
|
79
|
-
|
|
80
|
-
## Source
|
|
81
|
-
|
|
82
|
-
- Tests: [test/](../test/)
|
|
83
|
-
- Integration: [test/integration/](../test/integration/)
|
|
84
|
-
- Docker: [docker-compose.yml](../docker-compose.yml)
|
package/elm-ssr.config.json
DELETED
package/examples/basic/elm.json
DELETED
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"type": "application",
|
|
3
|
-
"source-directories": [
|
|
4
|
-
".elm-ssr",
|
|
5
|
-
"src",
|
|
6
|
-
".elm-ssr/src"
|
|
7
|
-
],
|
|
8
|
-
|
|
9
|
-
"elm-version": "0.19.1",
|
|
10
|
-
"dependencies": {
|
|
11
|
-
"direct": {
|
|
12
|
-
"elm/browser": "1.0.2",
|
|
13
|
-
"elm/core": "1.0.5",
|
|
14
|
-
"elm/html": "1.0.0",
|
|
15
|
-
"elm/json": "1.1.3",
|
|
16
|
-
"elm/url": "1.0.0"
|
|
17
|
-
},
|
|
18
|
-
"indirect": {
|
|
19
|
-
"elm/time": "1.0.0",
|
|
20
|
-
"elm/virtual-dom": "1.0.3"
|
|
21
|
-
}
|
|
22
|
-
},
|
|
23
|
-
"test-dependencies": {
|
|
24
|
-
"direct": {},
|
|
25
|
-
"indirect": {}
|
|
26
|
-
}
|
|
27
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
DROP TABLE entries;
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
-- The schema for the /guestbook route's `query`/`execute` effects.
|
|
2
|
-
-- Portable enough for SQLite and Postgres (`INTEGER PRIMARY KEY` auto-increments
|
|
3
|
-
-- in SQLite; in Postgres it's just a primary key — use `SERIAL`/`IDENTITY` if
|
|
4
|
-
-- you want server-side sequence generation).
|
|
5
|
-
|
|
6
|
-
CREATE TABLE entries (
|
|
7
|
-
id INTEGER PRIMARY KEY,
|
|
8
|
-
message TEXT NOT NULL,
|
|
9
|
-
created_at TEXT NOT NULL DEFAULT (CURRENT_TIMESTAMP)
|
|
10
|
-
);
|
|
@@ -1,10 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "@elm-ssr/example-basic",
|
|
3
|
-
"version": "0.0.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"description": "Reference example app: pages, islands, forms (PRG), server caching, SQL via migrations, background tasks.",
|
|
7
|
-
"dependencies": {
|
|
8
|
-
"elm-ssr": "workspace:*"
|
|
9
|
-
}
|
|
10
|
-
}
|
|
@@ -1,148 +0,0 @@
|
|
|
1
|
-
import { createWorkerApp } from "elm-ssr";
|
|
2
|
-
import { inMemoryEffects, type EffectRunner } from "elm-ssr/effects";
|
|
3
|
-
import { renderApp, type CompiledElmModule } from "elm-ssr/render";
|
|
4
|
-
import type { RouteCatalog } from "elm-ssr/http";
|
|
5
|
-
import { memorySessionStore } from "elm-ssr/sessions";
|
|
6
|
-
import { islands, bundleSource } from "../../generated/examples/basic/islands-manifest";
|
|
7
|
-
import { stylesheet } from "./styles";
|
|
8
|
-
// @ts-expect-error Generated at build time.
|
|
9
|
-
import ElmRuntime from "../../generated/examples/basic/app.mjs";
|
|
10
|
-
|
|
11
|
-
const elmModule = ElmRuntime as CompiledElmModule;
|
|
12
|
-
|
|
13
|
-
export const routes: RouteCatalog = {
|
|
14
|
-
pages: [
|
|
15
|
-
{
|
|
16
|
-
path: "/",
|
|
17
|
-
methods: ["GET", "HEAD"],
|
|
18
|
-
description: "Stateless landing page rendered from Elm (no client runtime)."
|
|
19
|
-
},
|
|
20
|
-
{
|
|
21
|
-
path: "/status",
|
|
22
|
-
methods: ["GET", "HEAD"],
|
|
23
|
-
description: "Stateless page whose Loader fetches data on the server."
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
path: "/counter",
|
|
27
|
-
methods: ["GET", "HEAD"],
|
|
28
|
-
description: "SSR page that embeds Browser.element islands."
|
|
29
|
-
},
|
|
30
|
-
{
|
|
31
|
-
path: "/greet/:name",
|
|
32
|
-
methods: ["GET", "HEAD"],
|
|
33
|
-
description: "Dynamic route; the name segment is captured from the URL."
|
|
34
|
-
}
|
|
35
|
-
],
|
|
36
|
-
assets: [
|
|
37
|
-
{
|
|
38
|
-
path: "/styles.css",
|
|
39
|
-
methods: ["GET", "HEAD"],
|
|
40
|
-
description: "Example stylesheet."
|
|
41
|
-
},
|
|
42
|
-
{
|
|
43
|
-
path: "/__elm-ssr/islands.js",
|
|
44
|
-
methods: ["GET", "HEAD"],
|
|
45
|
-
description: "Island loader runtime."
|
|
46
|
-
},
|
|
47
|
-
{
|
|
48
|
-
path: "/__elm-ssr/islands-bundle.js",
|
|
49
|
-
methods: ["GET", "HEAD"],
|
|
50
|
-
description: "Shared Browser.element island bundle."
|
|
51
|
-
}
|
|
52
|
-
],
|
|
53
|
-
utility: [
|
|
54
|
-
{
|
|
55
|
-
path: "/health",
|
|
56
|
-
methods: ["GET", "HEAD"],
|
|
57
|
-
description: "Plain text liveness endpoint."
|
|
58
|
-
}
|
|
59
|
-
],
|
|
60
|
-
api: [
|
|
61
|
-
{
|
|
62
|
-
path: "/api/health",
|
|
63
|
-
methods: ["GET", "HEAD"],
|
|
64
|
-
description: "JSON health payload."
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
path: "/api/routes",
|
|
68
|
-
methods: ["GET", "HEAD"],
|
|
69
|
-
description: "Route registry for the example app."
|
|
70
|
-
},
|
|
71
|
-
{
|
|
72
|
-
path: "/api/render",
|
|
73
|
-
methods: ["GET", "HEAD"],
|
|
74
|
-
description: "SSR preview endpoint."
|
|
75
|
-
}
|
|
76
|
-
]
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
export const createFlags = ({ request, path, formData }: { request?: Request; url?: URL; path: string; formData?: Record<string, string> }) => {
|
|
80
|
-
const [pathname, search = ""] = path.split("?");
|
|
81
|
-
|
|
82
|
-
return {
|
|
83
|
-
method: request?.method ?? "GET",
|
|
84
|
-
path: pathname,
|
|
85
|
-
query: Object.fromEntries(new URLSearchParams(search)),
|
|
86
|
-
formData: formData ?? {}
|
|
87
|
-
};
|
|
88
|
-
};
|
|
89
|
-
|
|
90
|
-
export const statusFixture = { uptime: "99.98%", region: "edge", builds: 128 };
|
|
91
|
-
|
|
92
|
-
// The Worker owns effect execution. Locally we use the in-memory adapter: a Map
|
|
93
|
-
// cache, env values, and a fixture for the `app://status` fetch its loader uses.
|
|
94
|
-
// On Cloudflare you'd swap in `cloudflareEffects()` (KV/D1/env) without touching
|
|
95
|
-
// any Elm.
|
|
96
|
-
export const exampleEffects: EffectRunner = inMemoryEffects({
|
|
97
|
-
env: { GREETING: "hello from the server env" },
|
|
98
|
-
fetchJson: (url) => {
|
|
99
|
-
if (url === "app://status") {
|
|
100
|
-
return statusFixture;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
throw new Error(`Unexpected fetchJson url in example: ${url}`);
|
|
104
|
-
}
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
export const renderPath = async (path: string) =>
|
|
108
|
-
renderApp(elmModule, createFlags({ path }), { effects: exampleEffects });
|
|
109
|
-
|
|
110
|
-
export const createExampleWorker = (options: { effects?: EffectRunner; log?: (entry: string) => void } = {}) =>
|
|
111
|
-
createWorkerApp({
|
|
112
|
-
elmModule,
|
|
113
|
-
islands,
|
|
114
|
-
islandsBundle: bundleSource,
|
|
115
|
-
stylesheet,
|
|
116
|
-
routes,
|
|
117
|
-
createFlags,
|
|
118
|
-
effects: options.effects ?? exampleEffects,
|
|
119
|
-
log: options.log
|
|
120
|
-
});
|
|
121
|
-
|
|
122
|
-
export const worker = createExampleWorker();
|
|
123
|
-
|
|
124
|
-
/**
|
|
125
|
-
* A second example worker that enables sessions + CSRF (against an in-memory
|
|
126
|
-
* store). Used by the /profile end-to-end tests. The hardened cookie defaults
|
|
127
|
-
* are turned down for the example because tests run over plain HTTP — in
|
|
128
|
-
* production `secure: true` is the default.
|
|
129
|
-
*/
|
|
130
|
-
export const createSessionExampleWorker = (
|
|
131
|
-
options: { effects?: EffectRunner; log?: (entry: string) => void; secret?: string } = {}
|
|
132
|
-
) =>
|
|
133
|
-
createWorkerApp({
|
|
134
|
-
elmModule,
|
|
135
|
-
islands,
|
|
136
|
-
islandsBundle: bundleSource,
|
|
137
|
-
stylesheet,
|
|
138
|
-
routes,
|
|
139
|
-
createFlags,
|
|
140
|
-
effects: options.effects ?? exampleEffects,
|
|
141
|
-
log: options.log,
|
|
142
|
-
sessions: {
|
|
143
|
-
secret: options.secret ?? "elm-ssr-example-dev-secret-do-not-use-in-prod",
|
|
144
|
-
store: memorySessionStore(),
|
|
145
|
-
secure: false
|
|
146
|
-
},
|
|
147
|
-
csrf: true
|
|
148
|
-
});
|