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.
Files changed (119) hide show
  1. package/README.md +48 -342
  2. package/elm-src/ElmSsr/Island/Sse.elm +151 -0
  3. package/package.json +53 -24
  4. package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
  5. package/src/sse.ts +162 -0
  6. package/AGENTS.md +0 -289
  7. package/CHANGELOG.md +0 -87
  8. package/LICENSE +0 -21
  9. package/bun.lock +0 -259
  10. package/docker-compose.yml +0 -33
  11. package/docs/README.md +0 -51
  12. package/docs/backends.md +0 -146
  13. package/docs/cli.md +0 -117
  14. package/docs/effects.md +0 -91
  15. package/docs/getting-started.md +0 -94
  16. package/docs/islands.md +0 -197
  17. package/docs/loaders-and-actions.md +0 -241
  18. package/docs/middleware.md +0 -93
  19. package/docs/migrations.md +0 -143
  20. package/docs/routing.md +0 -108
  21. package/docs/sessions.md +0 -218
  22. package/docs/tasks.md +0 -149
  23. package/docs/testing.md +0 -84
  24. package/elm-ssr.config.json +0 -14
  25. package/examples/basic/elm.json +0 -27
  26. package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
  27. package/examples/basic/migrations/0001_guestbook.sql +0 -10
  28. package/examples/basic/package.json +0 -10
  29. package/examples/basic/runtime.ts +0 -148
  30. package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
  31. package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
  32. package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
  33. package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
  34. package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
  35. package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
  36. package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
  37. package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
  38. package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
  39. package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
  40. package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
  41. package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
  42. package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
  43. package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
  44. package/examples/basic/styles.ts +0 -204
  45. package/examples/basic/worker.ts +0 -3
  46. package/examples/crypto-dashboard/elm.json +0 -30
  47. package/examples/crypto-dashboard/package.json +0 -10
  48. package/examples/crypto-dashboard/runtime.ts +0 -97
  49. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
  50. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
  51. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
  52. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
  53. package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
  54. package/examples/crypto-dashboard/styles.ts +0 -23
  55. package/examples/crypto-dashboard/worker.ts +0 -3
  56. package/llms.txt +0 -69
  57. package/packages/elm-ssr/README.md +0 -67
  58. package/packages/elm-ssr/package.json +0 -61
  59. package/scripts/benchmark.mjs +0 -60
  60. package/test/action.test.ts +0 -81
  61. package/test/adapters.test.ts +0 -173
  62. package/test/advanced-robustness.test.ts +0 -75
  63. package/test/app.test.ts +0 -209
  64. package/test/browser-island.test.ts +0 -184
  65. package/test/cli-migrate.test.ts +0 -97
  66. package/test/cli.test.ts +0 -94
  67. package/test/cookies.test.ts +0 -156
  68. package/test/crypto-dashboard.test.ts +0 -35
  69. package/test/effects.test.ts +0 -117
  70. package/test/http.test.ts +0 -50
  71. package/test/integration/redis-postgres.test.ts +0 -174
  72. package/test/island-runtime.test.ts +0 -214
  73. package/test/middleware.test.ts +0 -134
  74. package/test/migrations.test.ts +0 -244
  75. package/test/profile.test.ts +0 -159
  76. package/test/robustness.test.ts +0 -135
  77. package/test/serialize.test.ts +0 -92
  78. package/test/sessions.test.ts +0 -429
  79. package/test/svg.test.ts +0 -65
  80. package/tsconfig.json +0 -20
  81. package/wrangler.jsonc +0 -11
  82. /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
  83. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
  84. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
  85. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
  86. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
  87. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
  88. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
  89. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
  90. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
  91. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
  92. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +0 -0
  93. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
  94. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
  95. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
  96. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
  97. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
  98. /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
  99. /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
  100. /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
  101. /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
  102. /package/{packages/elm-ssr/src → src}/app.ts +0 -0
  103. /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
  104. /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
  105. /package/{packages/elm-ssr/src → src}/http.ts +0 -0
  106. /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
  107. /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
  108. /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
  109. /package/{packages/elm-ssr/src → src}/render.ts +0 -0
  110. /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
  111. /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
  112. /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
  113. /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
  114. /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
  115. /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
  116. /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
  117. /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
  118. /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
  119. /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)
@@ -1,14 +0,0 @@
1
- {
2
- "apps": [
3
- {
4
- "name": "basic",
5
- "root": "examples/basic",
6
- "module": "Example.Basic"
7
- },
8
- {
9
- "name": "crypto-dashboard",
10
- "root": "examples/crypto-dashboard",
11
- "module": "CryptoDashboard"
12
- }
13
- ]
14
- }
@@ -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
- });