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.
- package/AGENTS.md +289 -0
- package/CHANGELOG.md +87 -0
- package/LICENSE +21 -0
- package/README.md +342 -48
- package/bun.lock +259 -0
- package/docker-compose.yml +33 -0
- package/docs/README.md +51 -0
- package/docs/backends.md +146 -0
- package/docs/cli.md +117 -0
- package/docs/effects.md +91 -0
- package/docs/getting-started.md +94 -0
- package/docs/islands.md +197 -0
- package/docs/loaders-and-actions.md +241 -0
- package/docs/middleware.md +93 -0
- package/docs/migrations.md +143 -0
- package/docs/routing.md +108 -0
- package/docs/sessions.md +218 -0
- package/docs/tasks.md +149 -0
- package/docs/testing.md +84 -0
- package/elm-ssr.config.json +14 -0
- package/examples/basic/elm.json +27 -0
- package/examples/basic/migrations/0001_guestbook.down.sql +1 -0
- package/examples/basic/migrations/0001_guestbook.sql +10 -0
- package/examples/basic/package.json +10 -0
- package/examples/basic/runtime.ts +148 -0
- package/examples/basic/src/Example/Basic/Islands/Counter.elm +110 -0
- package/examples/basic/src/Example/Basic/Islands/Observer.elm +67 -0
- package/examples/basic/src/Example/Basic/Islands/Tasks.elm +151 -0
- package/examples/basic/src/Example/Basic/Routes/Chart.elm +87 -0
- package/examples/basic/src/Example/Basic/Routes/Counter.elm +42 -0
- package/examples/basic/src/Example/Basic/Routes/Echo.elm +76 -0
- package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +37 -0
- package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +86 -0
- package/examples/basic/src/Example/Basic/Routes/Index.elm +41 -0
- package/examples/basic/src/Example/Basic/Routes/NotFound.elm +37 -0
- package/examples/basic/src/Example/Basic/Routes/Profile.elm +112 -0
- package/examples/basic/src/Example/Basic/Routes/Session.elm +89 -0
- package/examples/basic/src/Example/Basic/Routes/Status.elm +90 -0
- package/examples/basic/src/Example/Basic/View/Shared.elm +60 -0
- package/examples/basic/styles.ts +204 -0
- package/examples/basic/worker.ts +3 -0
- package/examples/crypto-dashboard/elm.json +30 -0
- package/examples/crypto-dashboard/package.json +10 -0
- package/examples/crypto-dashboard/runtime.ts +97 -0
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +204 -0
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +200 -0
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +67 -0
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +30 -0
- package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +39 -0
- package/examples/crypto-dashboard/styles.ts +23 -0
- package/examples/crypto-dashboard/worker.ts +3 -0
- package/llms.txt +69 -0
- package/package.json +24 -51
- package/packages/elm-ssr/README.md +67 -0
- package/{bin → packages/elm-ssr/bin}/elm-ssr.mjs +9 -4
- package/packages/elm-ssr/elm-src/ElmSsr/Action.elm +435 -0
- package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Loader.elm +89 -0
- package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Runtime.elm +19 -9
- package/{lib → packages/elm-ssr/lib}/scaffold.mjs +44 -16
- package/packages/elm-ssr/package.json +61 -0
- package/{src → packages/elm-ssr/src}/app.ts +41 -7
- package/{src → packages/elm-ssr/src}/effects.ts +2 -0
- package/{src → packages/elm-ssr/src}/http.ts +2 -0
- package/{src → packages/elm-ssr/src}/render.ts +28 -4
- package/{src → packages/elm-ssr/src}/request-handler.ts +65 -20
- package/packages/elm-ssr/src/sessions/crypto.ts +93 -0
- package/packages/elm-ssr/src/sessions/effects.ts +52 -0
- package/packages/elm-ssr/src/sessions/index.ts +13 -0
- package/packages/elm-ssr/src/sessions/middleware.ts +201 -0
- package/packages/elm-ssr/src/sessions/store.ts +60 -0
- package/packages/elm-ssr/src/sessions/types.ts +32 -0
- package/scripts/benchmark.mjs +60 -0
- package/test/action.test.ts +81 -0
- package/test/adapters.test.ts +173 -0
- package/test/advanced-robustness.test.ts +75 -0
- package/test/app.test.ts +209 -0
- package/test/browser-island.test.ts +184 -0
- package/test/cli-migrate.test.ts +97 -0
- package/test/cli.test.ts +94 -0
- package/test/cookies.test.ts +156 -0
- package/test/crypto-dashboard.test.ts +35 -0
- package/test/effects.test.ts +117 -0
- package/test/http.test.ts +50 -0
- package/test/integration/redis-postgres.test.ts +174 -0
- package/test/island-runtime.test.ts +214 -0
- package/test/middleware.test.ts +134 -0
- package/test/migrations.test.ts +244 -0
- package/test/profile.test.ts +159 -0
- package/test/robustness.test.ts +135 -0
- package/test/serialize.test.ts +92 -0
- package/test/sessions.test.ts +429 -0
- package/test/svg.test.ts +65 -0
- package/tsconfig.json +20 -0
- package/wrangler.jsonc +11 -0
- package/elm-src/ElmSsr/Action.elm +0 -210
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Document/Encode.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Document/Events.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Document.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Html/Attributes.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Html/Events.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Html.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Island/Shared.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Island.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Page.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Route.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
- /package/{elm-src → packages/elm-ssr/elm-src}/ElmSsr/Svg.elm +0 -0
- /package/{lib → packages/elm-ssr/lib}/build.mjs +0 -0
- /package/{lib → packages/elm-ssr/lib}/migrate.mjs +0 -0
- /package/{lib → packages/elm-ssr/lib}/workspace.mjs +0 -0
- /package/{src → packages/elm-ssr/src}/backends.ts +0 -0
- /package/{src → packages/elm-ssr/src}/client-runtime/islands.ts +0 -0
- /package/{src → packages/elm-ssr/src}/middleware.ts +0 -0
- /package/{src → packages/elm-ssr/src}/migrations.ts +0 -0
- /package/{src → packages/elm-ssr/src}/protocol.ts +0 -0
- /package/{src → packages/elm-ssr/src}/response-headers.ts +0 -0
- /package/{src → packages/elm-ssr/src}/serialize.ts +0 -0
- /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.
|