elm-ssr 0.1.0 → 0.2.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 (118) hide show
  1. package/AGENTS.md +289 -0
  2. package/CHANGELOG.md +87 -0
  3. package/LICENSE +21 -0
  4. package/README.md +342 -48
  5. package/bun.lock +259 -0
  6. package/docker-compose.yml +33 -0
  7. package/docs/README.md +51 -0
  8. package/docs/backends.md +146 -0
  9. package/docs/cli.md +117 -0
  10. package/docs/effects.md +91 -0
  11. package/docs/getting-started.md +94 -0
  12. package/docs/islands.md +197 -0
  13. package/docs/loaders-and-actions.md +241 -0
  14. package/docs/middleware.md +93 -0
  15. package/docs/migrations.md +143 -0
  16. package/docs/routing.md +108 -0
  17. package/docs/sessions.md +218 -0
  18. package/docs/tasks.md +149 -0
  19. package/docs/testing.md +84 -0
  20. package/elm-ssr.config.json +14 -0
  21. package/examples/basic/elm.json +27 -0
  22. package/examples/basic/migrations/0001_guestbook.down.sql +1 -0
  23. package/examples/basic/migrations/0001_guestbook.sql +10 -0
  24. package/examples/basic/package.json +10 -0
  25. package/examples/basic/runtime.ts +148 -0
  26. package/examples/basic/src/Example/Basic/Islands/Counter.elm +110 -0
  27. package/examples/basic/src/Example/Basic/Islands/Observer.elm +67 -0
  28. package/examples/basic/src/Example/Basic/Islands/Tasks.elm +151 -0
  29. package/examples/basic/src/Example/Basic/Routes/Chart.elm +87 -0
  30. package/examples/basic/src/Example/Basic/Routes/Counter.elm +42 -0
  31. package/examples/basic/src/Example/Basic/Routes/Echo.elm +76 -0
  32. package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +37 -0
  33. package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +86 -0
  34. package/examples/basic/src/Example/Basic/Routes/Index.elm +41 -0
  35. package/examples/basic/src/Example/Basic/Routes/NotFound.elm +37 -0
  36. package/examples/basic/src/Example/Basic/Routes/Profile.elm +112 -0
  37. package/examples/basic/src/Example/Basic/Routes/Session.elm +89 -0
  38. package/examples/basic/src/Example/Basic/Routes/Status.elm +90 -0
  39. package/examples/basic/src/Example/Basic/View/Shared.elm +60 -0
  40. package/examples/basic/styles.ts +204 -0
  41. package/examples/basic/worker.ts +3 -0
  42. package/examples/crypto-dashboard/elm.json +30 -0
  43. package/examples/crypto-dashboard/package.json +10 -0
  44. package/examples/crypto-dashboard/runtime.ts +97 -0
  45. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +204 -0
  46. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +200 -0
  47. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +67 -0
  48. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +30 -0
  49. package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +39 -0
  50. package/examples/crypto-dashboard/styles.ts +23 -0
  51. package/examples/crypto-dashboard/worker.ts +3 -0
  52. package/llms.txt +69 -0
  53. package/package.json +24 -51
  54. package/packages/elm-ssr/README.md +67 -0
  55. package/{bin → packages/elm-ssr/bin}/elm-ssr.mjs +9 -4
  56. package/packages/elm-ssr/elm-src/ElmSsr/Action.elm +435 -0
  57. package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Loader.elm +89 -0
  58. package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Runtime.elm +19 -9
  59. package/{lib → packages/elm-ssr/lib}/scaffold.mjs +44 -16
  60. package/packages/elm-ssr/package.json +61 -0
  61. package/{src → packages/elm-ssr/src}/app.ts +41 -7
  62. package/{src → packages/elm-ssr/src}/effects.ts +2 -0
  63. package/{src → packages/elm-ssr/src}/http.ts +2 -0
  64. package/{src → packages/elm-ssr/src}/render.ts +28 -4
  65. package/{src → packages/elm-ssr/src}/request-handler.ts +65 -20
  66. package/packages/elm-ssr/src/sessions/crypto.ts +93 -0
  67. package/packages/elm-ssr/src/sessions/effects.ts +52 -0
  68. package/packages/elm-ssr/src/sessions/index.ts +13 -0
  69. package/packages/elm-ssr/src/sessions/middleware.ts +201 -0
  70. package/packages/elm-ssr/src/sessions/store.ts +60 -0
  71. package/packages/elm-ssr/src/sessions/types.ts +32 -0
  72. package/scripts/benchmark.mjs +60 -0
  73. package/test/action.test.ts +81 -0
  74. package/test/adapters.test.ts +173 -0
  75. package/test/advanced-robustness.test.ts +75 -0
  76. package/test/app.test.ts +209 -0
  77. package/test/browser-island.test.ts +184 -0
  78. package/test/cli-migrate.test.ts +97 -0
  79. package/test/cli.test.ts +94 -0
  80. package/test/cookies.test.ts +156 -0
  81. package/test/crypto-dashboard.test.ts +35 -0
  82. package/test/effects.test.ts +117 -0
  83. package/test/http.test.ts +50 -0
  84. package/test/integration/redis-postgres.test.ts +174 -0
  85. package/test/island-runtime.test.ts +214 -0
  86. package/test/middleware.test.ts +134 -0
  87. package/test/migrations.test.ts +244 -0
  88. package/test/profile.test.ts +159 -0
  89. package/test/robustness.test.ts +135 -0
  90. package/test/serialize.test.ts +92 -0
  91. package/test/sessions.test.ts +429 -0
  92. package/test/svg.test.ts +65 -0
  93. package/tsconfig.json +20 -0
  94. package/wrangler.jsonc +11 -0
  95. package/elm-src/ElmSsr/Action.elm +0 -210
  96. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Document/Encode.elm +0 -0
  97. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Document/Events.elm +0 -0
  98. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Document.elm +0 -0
  99. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Html/Attributes.elm +0 -0
  100. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Html/Events.elm +0 -0
  101. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Html.elm +0 -0
  102. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Island/Shared.elm +0 -0
  103. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Island.elm +0 -0
  104. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Page.elm +0 -0
  105. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Route.elm +0 -0
  106. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
  107. /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Svg.elm +0 -0
  108. /package/{lib → packages/elm-ssr/lib}/build.mjs +0 -0
  109. /package/{lib → packages/elm-ssr/lib}/migrate.mjs +0 -0
  110. /package/{lib → packages/elm-ssr/lib}/workspace.mjs +0 -0
  111. /package/{src → packages/elm-ssr/src}/backends.ts +0 -0
  112. /package/{src → packages/elm-ssr/src}/client-runtime/islands.ts +0 -0
  113. /package/{src → packages/elm-ssr/src}/middleware.ts +0 -0
  114. /package/{src → packages/elm-ssr/src}/migrations.ts +0 -0
  115. /package/{src → packages/elm-ssr/src}/protocol.ts +0 -0
  116. /package/{src → packages/elm-ssr/src}/response-headers.ts +0 -0
  117. /package/{src → packages/elm-ssr/src}/serialize.ts +0 -0
  118. /package/{src → packages/elm-ssr/src}/tasks.ts +0 -0
package/AGENTS.md ADDED
@@ -0,0 +1,289 @@
1
+ # AGENTS.md — orientation for AI assistants working on elm-ssr
2
+
3
+ If you are an AI agent (Claude Code, Cursor, …) opening this repo, read this
4
+ file first. It encodes the project's architecture, conventions, and the small
5
+ set of footguns that tend to bite agents specifically.
6
+
7
+ For deeper, topic-by-topic docs see [`docs/`](./docs/). For an LLM-oriented
8
+ entry-point with links to the most useful pages, see
9
+ [`llms.txt`](./llms.txt) at the repo root.
10
+
11
+ ## TL;DR
12
+
13
+ `elm-ssr` is a small Elm-first SSR library + framework for Cloudflare Workers
14
+ (and Bun locally). Two execution worlds, glued by a marker element:
15
+
16
+ - **Pages** are SSR-only Elm. They use a custom serializable AST
17
+ (`ElmSsr.Html`) because Cloudflare Workers has no DOM, so `elm/html`'s
18
+ `virtual-dom` kernel can't run server-side.
19
+ - **Islands** are *standard* `Browser.element` programs using stock `elm/html`,
20
+ `elm/svg`, `elm/http`, `Html.Keyed`, etc. They mount client-side into
21
+ `<elm-ssr-island>` markers the page emits.
22
+
23
+ The Worker exposes a *backend-neutral* effect surface (cache/sql/env/cookie/
24
+ fetchJson/enqueue) that the Elm side describes; pluggable TS adapters execute
25
+ them against KV/D1 on Cloudflare or Redis/Postgres/SQLite locally.
26
+
27
+ ## Hard rules (read before editing)
28
+
29
+ These match real bugs I've shipped or nearly shipped. Don't repeat them.
30
+
31
+ 1. **Islands MUST use `elm/html`, not `ElmSsr.Html`.** Every island's
32
+ `view : Model -> Html Msg` imports `Html exposing (...)`. `ElmSsr.Html` is
33
+ only used inside the island module for the SSR `fallback` markup (which is
34
+ part of the page tree). If you find yourself rewriting an island's view to
35
+ `ElmSsr.Html` you are wrong — that's the pre-pivot architecture we deleted.
36
+ 2. **Pages use `ElmSsr.Html`.** Pages return `Document Never`, serialized on the
37
+ server to HTML. They cannot use `elm/html` because Workers has no DOM.
38
+ 3. **Verify against code, not memory or this file.** Memory files in
39
+ `~/.claude/projects/.../memory/` carry useful context but can also be stale.
40
+ Run `grep`/`ls`/`Read` before asserting how something currently works.
41
+ 4. **No `Generated.*` modules in author-facing API.** The author imports the
42
+ island module directly (`import App.Islands.Counter as Counter`) and calls
43
+ `Counter.embed {...}`. There is *no* `Generated.Islands` re-export. Codegen
44
+ is reserved for things the author never touches (`Main.elm`, the islands
45
+ client program, the manifest).
46
+ 5. **Workspace subpath imports.** TS imports `elm-ssr/effects`,
47
+ `/tasks`, `/backends`, `/migrations`, `/middleware`, `/http`, etc. — never
48
+ relative paths like `../../packages/elm-ssr/src/effects`.
49
+ 6. **Small modules.** Split by responsibility (the client runtime is split into
50
+ `islands.ts` + the inline core; the effect adapters are composable
51
+ `withCache`/`withTasks`/`withQueueProducer`). Don't grow one file.
52
+ 7. **Don't add comments that narrate the change.** No "added for issue #X",
53
+ "now we also …", "refactored from …". Either explain a non-obvious *why* in
54
+ one short line, or stay silent.
55
+ 8. **Elm sources live in `packages/elm-ssr/elm-src/`** (the package is the
56
+ canonical home; the build syncs them into each app's `.elm-ssr/src/ElmSsr/`).
57
+ There is **one** published package — `elm-ssr` — covering CLI, TS runtime,
58
+ effect adapters, tasks/queues, migrations, and the Elm authoring modules.
59
+ The earlier split into `@elm-ssr/cli` + `@elm-ssr/runtime-worker` was
60
+ collapsed pre-release; don't look for those.
61
+
62
+ ## File layout
63
+
64
+ ```
65
+ packages/
66
+ elm-ssr/ # The single published package
67
+ bin/elm-ssr.mjs # `elm-ssr` CLI entry
68
+ elm-src/ElmSsr/ # Route, Loader, Action, Html(+.Attributes/.Events),
69
+ # Svg(+.Attributes), Island(+.Shared), Document(+.Encode/.Events),
70
+ # Page, Runtime — synced into each app's .elm-ssr/src/ on build
71
+ lib/
72
+ build.mjs # Scans Routes/ + Islands/, generates Main.elm, runs `elm make`
73
+ migrate.mjs # `elm-ssr migrate up|down|status` with Postgres + SQLite adapters
74
+ scaffold.mjs # `elm-ssr new <name>`
75
+ workspace.mjs # reads elm-ssr.config.json
76
+ src/
77
+ app.ts # createWorkerApp({elmModule, islands, ..., effects})
78
+ request-handler.ts # Routes + dispatch; threads effectContext into render
79
+ render.ts # Drives the Elm runtime's effect loop; returns RenderedDocument
80
+ effects.ts # EffectRunner, defaultEffectRunner, inMemoryEffects, cloudflareEffects
81
+ tasks.ts # withTasks, withQueueProducer, createQueueConsumer
82
+ backends.ts # CacheBackend, withCache, redisCache, SqlClient, postgresSql
83
+ middleware.ts # composeMiddleware + the standard middlewares
84
+ http.ts # AppContext type, json/text/withHeaders
85
+ response-headers.ts # htmlHeaders, jsonHeaders, cssHeaders, assetHeaders
86
+ serialize.ts # SsrDocument → HTML string
87
+ protocol.ts # SsrNode/Attribute/Document types + isNode validation
88
+ migrations.ts # runMigrations, revertMigrations, listMigrations
89
+ client-runtime/islands.ts # The client island runtime (source string)
90
+ examples/
91
+ basic/ # The reference app
92
+ crypto-dashboard/ # Tailwind + elm/svg + elm/http islands + cross-island bus
93
+ generated/ # Build output (gitignored)
94
+ test/ # bun test, happy-dom for the client runtime
95
+ ```
96
+
97
+ ## Architecture cheat sheet
98
+
99
+ - **Routing is file-based** (Next/Remix-style). `src/<App>/Routes/Index.elm` → `/`,
100
+ `Foo/Bar.elm` → `/foo/bar`, names ending in `_` are dynamic segments
101
+ (`Greet/Name_.elm` → `/greet/:name`, captured via `Route.param "name"`),
102
+ `NotFound.elm` is the fallback. Each route module exposes
103
+ `page : Request -> Loader (Document Never)` and `action : Request -> Action (Document Never)`.
104
+ - **Loaders/Actions are descriptions.** They produce `Pending Effect (Value -> …)`.
105
+ The Worker (`render.ts`) pumps the effect loop via ports until terminal.
106
+ - **`Action.fromLoader` lifts a `Loader` into an `Action`** — that's how actions
107
+ reuse every Loader effect (cacheGet, query, execute, env, fetchJson, enqueue).
108
+ - **Islands** live in `src/<App>/Islands/`. The codegen scans the directory,
109
+ emits `Generated.Islands` only for the *client registry/manifest* (never
110
+ re-exported to authors), and one combined `islands.mjs` is shipped per app.
111
+ - **Cross-island state** uses `ElmSsr.Island.Shared.broadcast`/`listen`, a
112
+ `window` CustomEvent bus. A broadcaster also hears its own broadcast — filter
113
+ by `tag` in `update`.
114
+ - **Client SPA navigation** is in `client-runtime/islands.ts`: intercepts
115
+ same-origin link clicks (skips hash-only/same-path), fetches `/api/render`,
116
+ swaps `#elm-ssr-root` innerHTML, re-boots islands, syncs `<head>`. Persistent
117
+ islands (with `id`) transfer across navigation; non-persistent ones leak the
118
+ Elm runtime (Elm has no program teardown — only the bus listener is reclaimable).
119
+
120
+ ## Effect vocabulary
121
+
122
+ In Elm (`ElmSsr.Loader`, reusable from `Action` via `fromLoader`):
123
+
124
+ | Effect | Kind string | Maps to (cloudflareEffects) | Local default |
125
+ |---|---|---|---|
126
+ | `fetchJson { url, decoder }` | `fetchJson` | real `fetch` | real `fetch` (override via inMemoryEffects.fetchJson) |
127
+ | `cacheGet { key, decoder }` / `cachePut { key, value, ttlSeconds }` | `cacheGet`/`cachePut` | `env.CACHE` (KV) | in-memory `Map` (or `withCache(redisCache(client))`) |
128
+ | `query` / `queryOne` / `execute` | `query`/`queryOne`/`execute` | `env.DB` (D1) | `inMemoryEffects({ sql })` hook (plug bun:sqlite / Postgres / SQLite) |
129
+ | `env name` | `env` | `context.env[name]` | the `env` option object |
130
+ | `getCookie name` | `cookie` | parsed from `context.request` cookie header | same |
131
+ | `enqueue { task, payload }` | `enqueue` | `withTasks(...)` → `ctx.waitUntil`, OR `withQueueProducer({queueBinding})` → CF Queue | `withTasks` fire-and-forget |
132
+
133
+ Cookies are also writable from `Action`: `Action.setCookie`,
134
+ `Action.clearCookie`, plus the hardened `Action.sessionCookie` (Secure,
135
+ HttpOnly, SameSite=Lax, Max-Age=7d). They propagate through `map`/`andThen`/
136
+ `fromLoader` and attach as `Set-Cookie` headers on the response (including
137
+ redirects and `/api/render`). See
138
+ [docs/loaders-and-actions.md#cookies](./docs/loaders-and-actions.md#cookies).
139
+
140
+ For higher-level **sessions + CSRF**, opt in via `sessions:` + `csrf:` on
141
+ `createWorkerApp`. That wires `sessionMiddleware` (signed cookie, pluggable
142
+ store) + `csrfMiddleware` and auto-wraps your effect runner with
143
+ `sessionEffects` so the Elm side can call `Loader.session`,
144
+ `Loader.csrfToken`, `Loader.setSession`, and `Loader.clearSession`. See
145
+ [docs/sessions.md](./docs/sessions.md) and
146
+ [examples/basic/src/Example/Basic/Routes/Profile.elm](./examples/basic/src/Example/Basic/Routes/Profile.elm).
147
+
148
+ The adapters are *composable*. A realistic stack:
149
+
150
+ ```ts
151
+ const effects = withTasks(
152
+ withCache(
153
+ inMemoryEffects({ sql: postgresSql(pgClient), env }),
154
+ redisCache(redisClient)
155
+ ),
156
+ { sendEmail, warmCache }
157
+ );
158
+ ```
159
+
160
+ ## How to add things
161
+
162
+ ### A new route
163
+
164
+ Drop `src/<App>/Routes/<Name>.elm` exposing `page` and `action`. The CLI build
165
+ regenerates `Main.elm` automatically. Dynamic segments via trailing `_`.
166
+
167
+ ### A new island
168
+
169
+ Drop `src/<App>/Islands/<Name>.elm`. It MUST:
170
+ - be a `Browser.element` (`main : Program Flags Model Msg`) using stock `elm/html`.
171
+ - expose `embed = Island.embed "<Name>" { encodeFlags, fallback, id }` — the
172
+ build validates the name string matches the module path.
173
+
174
+ The page imports the island and calls `<Name>.embed {...}`.
175
+
176
+ ### A new server effect
177
+
178
+ Decide if it's logical (deserves its own kind) or just a fetchJson variant.
179
+ For a new kind:
180
+ 1. Add a constructor to `ElmSsr.Loader` emitting `Pending { kind = "myKind", payload = ... } continue`.
181
+ 2. Handle the kind in the relevant adapter(s) — `defaultEffectRunner`,
182
+ `inMemoryEffects`, `cloudflareEffects`. Use `EffectContext.env`/`request`.
183
+ 3. Test by calling the runner directly with the effect (see `test/adapters.test.ts`).
184
+
185
+ ### A new background task handler
186
+
187
+ Pure TS: register it in the `withTasks` handlers object (or in the
188
+ `createQueueConsumer` map). Elm side calls `Loader.enqueue { task = "name", payload }`.
189
+
190
+ ### A new backend adapter
191
+
192
+ Match a minimal client interface (see `CacheClient` / `SqlClient` in
193
+ `backends.ts`) and write a small mapping function. Test with a fake client; the
194
+ user wires the real driver in their entrypoint.
195
+
196
+ ## Build pipeline
197
+
198
+ `bun run build` → `bun run packages/elm-ssr/bin/elm-ssr.mjs build`:
199
+ 1. Reads `elm-ssr.config.json` (workspace root) listing apps.
200
+ 2. For each app, scans `src/<Namespace>/Routes/` and `Islands/`, generates
201
+ `.elm-ssr/Main.elm` (router) + the islands manifest, and syncs
202
+ `packages/elm-ssr/elm-src/ElmSsr/*` into `<app>/.elm-ssr/src/ElmSsr/*` so the
203
+ example's `elm.json` `source-directories` can list `".elm-ssr/src"`.
204
+ 3. Runs `elm make` to produce `generated/<app>/app.mjs` and a combined
205
+ `islands.mjs` (one bundle exposing every island as `Elm.<Module>`).
206
+
207
+ `bun run check` = `build` + `tsc --noEmit`. `bun test` = `build` + `bun test`.
208
+
209
+ The test runner uses `happy-dom` for the client island runtime (`bun:sqlite` is
210
+ plugged into `inMemoryEffects({ sql })` for the SQL adapter test).
211
+
212
+ ## Migrations
213
+
214
+ `elm-ssr/migrations` exports three operations:
215
+
216
+ - `runMigrations(adapter, { dir, tableName?, now? })` — apply pending `*.sql`, alphabetical, transactional per-migration, idempotent. Files named `*.down.sql` are ignored on the up pass.
217
+ - `revertMigrations(adapter, { dir, tableName?, count? })` — revert the most-recently-applied N (default 1) by running each `<name>.down.sql`; errors clearly if a paired down file is missing.
218
+ - `listMigrations(adapter, { dir, tableName? })` — `{ applied: [{name, appliedAt}], pending: [name] }`.
219
+
220
+ The adapter is two callbacks plus an optional transaction hook:
221
+
222
+ ```ts
223
+ interface MigrationsAdapter {
224
+ exec(sql: string): Promise<void>; // multi-statement
225
+ list(sql: string): Promise<Array<Record<string, unknown>>>; // SELECT
226
+ runInTransaction?(fn: () => Promise<void>): Promise<void>; // use the driver's native txn scope
227
+ }
228
+ ```
229
+
230
+ Wire it to bun:sqlite (`{ exec: s => { db.exec(s); }, list: s => db.query(s).all() }`), `Bun.sql` (`runInTransaction: fn => sql.begin(fn)`), `node-postgres`, or D1. Real-world wiring lives in `test/migrations.test.ts` (SQLite, 16 tests), `test/integration/redis-postgres.test.ts` (Postgres, incl. revert + status), and `test/cli-migrate.test.ts` (the CLI driving SQLite end-to-end on the example's migrations dir).
231
+
232
+ ### CLI
233
+
234
+ `elm-ssr migrate <up|down|status> [--dir <path>] [--db <conn>] [--count N] [--table <name>]`:
235
+
236
+ - `--db postgres://…` → builds a `Bun.sql` adapter with `runInTransaction = sql.begin`.
237
+ - `--db sqlite://path` or a bare file path → bun:sqlite adapter.
238
+ - Reads `DATABASE_URL` if `--db` is omitted; errors clearly if neither is set.
239
+
240
+ CLI lives in `packages/elm-ssr/lib/migrate.mjs`, dispatched from `packages/elm-ssr/bin/elm-ssr.mjs`.
241
+
242
+ ## Integration tests (Docker)
243
+
244
+ `docker-compose.yml` brings up Postgres 16 + Redis 7. `test/integration/redis-postgres.test.ts` uses `describe.skip` when `DATABASE_URL` / `REDIS_URL` aren't set, so the default `bun test` stays clean on machines without Docker.
245
+
246
+ ```
247
+ docker compose up -d
248
+ bun run test:integration
249
+ docker compose down
250
+ ```
251
+
252
+ The integration test wires `Bun.redis` to `redisCache` and `Bun.sql` to `postgresSql`, runs a real migration against Postgres, and verifies the round-trip of every effect against the real backend.
253
+
254
+ ## Testing strategy
255
+
256
+ Each TS module has unit tests where it has logic worth isolating
257
+ (`middleware.test.ts`, `http.test.ts`, `adapters.test.ts`, `effects.test.ts`,
258
+ `serialize.test.ts`, `svg.test.ts`). Routes/actions/effects are tested
259
+ end-to-end through `worker.fetch` (`app.test.ts`, `action.test.ts`,
260
+ `crypto-dashboard.test.ts`). Islands are tested via:
261
+ - `browser-island.test.ts` — mount a per-island bundle in happy-dom, click,
262
+ assert (uses a fresh div as the mount point, NOT the marker, so it dodges
263
+ Browser.element's node-replacement).
264
+ - `island-runtime.test.ts` — drives the *real* client runtime source
265
+ (`createIslandsRuntime`) under happy-dom, with the real generated bundle,
266
+ to cover the marker child-mount + persistence transfer + head sync.
267
+
268
+ Cookie/redis/postgres/queues are unit-tested with fakes — no real servers.
269
+
270
+ ## Common pitfalls
271
+
272
+ - **`Browser.element` replaces the mount node.** If you find yourself storing
273
+ `marker` after `Elm.<Module>.init({ node: marker })`, you stored a detached
274
+ node. The client runtime mounts into a child `<div>` for exactly this reason
275
+ (see `client-runtime/islands.ts` — `bootMarker`). Persistence references the
276
+ marker, not the inner Elm-managed view.
277
+ - **happy-dom `querySelector` is broken** in some selector cases — the existing
278
+ tests use `getElementsByTagName` + a manual class walker. Don't reach for
279
+ `querySelector` in new tests.
280
+ - **Don't add elm/html things to `ElmSsr.Html.Events`.** Only `click`/`input`/
281
+ `change`/`submit` are wired through `Document.Events.findMessage`. Pages are
282
+ static — they don't need handlers. (The Events module is more of a vestige
283
+ from the pre-pivot architecture; new event vocabulary belongs in islands via
284
+ `Html.Events`.)
285
+ - **`tsc` does not include `test/`.** Only `packages/**` and `examples/**` are
286
+ type-checked. A test file can `import { Database } from "bun:sqlite"` even
287
+ though tsc wouldn't accept that import elsewhere.
288
+ - **`bun run check` exits with `tail`'s status if you pipe it.** Capture
289
+ `bun run check > /tmp/log 2>&1; echo "exit: $?"` to read the real exit code.
package/CHANGELOG.md ADDED
@@ -0,0 +1,87 @@
1
+ # Changelog
2
+
3
+ All notable changes to the `elm-ssr` package. Dates are ISO; "Unreleased" lives
4
+ at the top until a version is cut.
5
+
6
+ ## 0.2.0 — 2026-05-30
7
+
8
+ Sessions + CSRF land as a first-class, opt-in layer; cookie primitives gain
9
+ hardened defaults; the CLI's `new` command stops scaffolding under
10
+ `examples/`.
11
+
12
+ ### Added
13
+
14
+ - **Sessions and CSRF** ([docs/sessions.md](docs/sessions.md))
15
+ - New subpath `elm-ssr/sessions` exports `sessionMiddleware`,
16
+ `csrfMiddleware`, `sessionEffects`, `memorySessionStore`,
17
+ `cacheStore`, `signValue`, `verifyValue`, `generateSessionId`,
18
+ `generateCsrfToken`, plus the `SessionStore` / `SessionRecord` /
19
+ `RequestSession` types.
20
+ - `createWorkerApp` gains two opt-in options:
21
+ - `sessions: { secret, store, cookieName?, maxAgeSeconds?, cookiePath?, cookieDomain?, secure?, sameSite? }` — installs the signed-cookie session middleware and auto-wraps your effect runner with `sessionEffects`.
22
+ - `csrf: true | CsrfMiddlewareOptions` — installs the CSRF middleware (requires `sessions`). Header (`X-CSRF-Token`) or form field (`_csrf`); `skipPaths` for webhook receivers.
23
+ - HMAC-SHA256 cookie signing via WebCrypto (constant-time verify).
24
+ - `memorySessionStore()` for dev/tests; `cacheStore(backend, options?)` reuses any `CacheBackend` (works over `redisCache(...)`, KV-backed wrappers, …).
25
+ - **Elm-side session API** ([packages/elm-ssr/elm-src/ElmSsr/Loader.elm](packages/elm-ssr/elm-src/ElmSsr/Loader.elm))
26
+ - `Loader.session : Decoder a -> Loader (Maybe a)` — read the current session payload.
27
+ - `Loader.csrfToken : Loader (Maybe String)` — embed in forms (hidden `_csrf` input) or `X-CSRF-Token` on `fetch`.
28
+ - `Loader.setSession : Value -> Loader ()` — replace session data; middleware persists + rolls the cookie.
29
+ - `Loader.clearSession : Loader ()` — destroy the session.
30
+ - **Cookies as a first-class `Action` primitive** ([docs/loaders-and-actions.md#cookies](docs/loaders-and-actions.md#cookies))
31
+ - `Action.setCookie : Cookie -> Action a -> Action a`, `Action.clearCookie`, propagated through `map`/`andThen`/`fromLoader`.
32
+ - `Action.defaultCookie` (permissive base) and `Action.sessionCookie` (hardened: `Secure`, `HttpOnly`, `SameSite=Lax`, 7-day `Max-Age`, `Path=/`).
33
+ - `Action.Cookie`, `Action.SameSite = Lax | Strict | None`.
34
+ - `Loader.getCookie : String -> Loader (Maybe String)` for the matching read side.
35
+ - `Set-Cookie` headers attach to **every** response path: HTML page, redirect, JSON, and `/api/render`'s SPA-nav preview.
36
+ - **CLI**
37
+ - `elm-ssr new <name>` scaffolds at `<workspace>/<name>/` (previously hardcoded `examples/<name>/`).
38
+ - New `--in <subdir>` flag: `elm-ssr new my-app --in apps` → `<workspace>/apps/my-app/`.
39
+ - **Test loops**
40
+ - `bun run test:unit` — fast loop, no Docker.
41
+ - `bun run test:integration` — only the integration suite, self-manages Docker.
42
+ - `bun run test` — full suite, self-manages Docker. (Replaces the old `test:docker`, which is now redundant.)
43
+ - **Docs**
44
+ - New [docs/sessions.md](docs/sessions.md).
45
+ - Doc tree: [docs/README.md](docs/README.md) + 11 topic pages
46
+ (getting-started, routing, loaders-and-actions, effects, backends,
47
+ tasks, islands, migrations, cli, middleware, sessions, testing).
48
+ - [llms.txt](llms.txt) at the repo root for AI agents (llmstxt.org format).
49
+ - **Example app**
50
+ - `examples/basic/src/Example/Basic/Routes/Profile.elm` — end-to-end session + CSRF demo with `Loader.session` / `csrfToken` / `setSession` / `clearSession`. Exposed via `createSessionExampleWorker` in [runtime.ts](examples/basic/runtime.ts).
51
+ - `examples/basic/src/Example/Basic/Routes/Session.elm` — raw cookie demo (login/logout via `Action.setCookie` + `Action.clearCookie`).
52
+
53
+ ### Changed
54
+
55
+ - `Action.encodeStep` now takes a `List Cookie` parameter for the cookies to
56
+ attach. Used internally by the runtime; no impact unless you build your
57
+ own encoded steps by hand.
58
+ - `Action.step` skips top-level `WithCookies` wrappers; use the new
59
+ `Action.collectCookies : Action a -> ( List Cookie, Action a )` first if
60
+ you need the cookies separately.
61
+ - `AppContext` and `EffectContext` gained an optional `session?` field
62
+ populated by `sessionMiddleware`. Existing code is source-compatible.
63
+ - Default `bun run test` now brings Postgres + Redis up via Docker
64
+ automatically and tears them down on exit. Use `bun run test:unit` for the
65
+ fast no-Docker loop.
66
+
67
+ ### Fixed
68
+
69
+ - `elm-ssr new <name>` no longer hardcodes `examples/<name>/`.
70
+
71
+ ### Removed
72
+
73
+ - `bun run test:docker` (redundant — `bun run test` now self-manages Docker).
74
+ - The `@elm-ssr/cli` + `@elm-ssr/runtime-worker` scoped packages were
75
+ collapsed into the single unscoped `elm-ssr` package back in 0.1.0; this
76
+ release does not republish them. Deprecate them in your registry to point
77
+ users at `elm-ssr`:
78
+ ```
79
+ npm deprecate @elm-ssr/cli@0.1.0 "Replaced by the unscoped 'elm-ssr' package."
80
+ npm deprecate @elm-ssr/runtime-worker@0.1.0 "Replaced by the unscoped 'elm-ssr' package."
81
+ ```
82
+
83
+ ## 0.1.0 — 2026-05-30
84
+
85
+ First public release. Single package `elm-ssr` covering CLI, TS runtime,
86
+ effect adapters, tasks/queues, SQL migrations, and the Elm authoring
87
+ modules (`ElmSsr.*`).
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Michał Majchrzak and elm-ssr contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.