elm-ssr 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -342
- package/elm-src/ElmSsr/Island/Sse.elm +151 -0
- package/package.json +53 -24
- package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
- package/src/sse.ts +162 -0
- package/AGENTS.md +0 -289
- package/CHANGELOG.md +0 -87
- package/LICENSE +0 -21
- package/bun.lock +0 -259
- package/docker-compose.yml +0 -33
- package/docs/README.md +0 -51
- package/docs/backends.md +0 -146
- package/docs/cli.md +0 -117
- package/docs/effects.md +0 -91
- package/docs/getting-started.md +0 -94
- package/docs/islands.md +0 -197
- package/docs/loaders-and-actions.md +0 -241
- package/docs/middleware.md +0 -93
- package/docs/migrations.md +0 -143
- package/docs/routing.md +0 -108
- package/docs/sessions.md +0 -218
- package/docs/tasks.md +0 -149
- package/docs/testing.md +0 -84
- package/elm-ssr.config.json +0 -14
- package/examples/basic/elm.json +0 -27
- package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
- package/examples/basic/migrations/0001_guestbook.sql +0 -10
- package/examples/basic/package.json +0 -10
- package/examples/basic/runtime.ts +0 -148
- package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
- package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
- package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
- package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
- package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
- package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
- package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
- package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
- package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
- package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
- package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
- package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
- package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
- package/examples/basic/styles.ts +0 -204
- package/examples/basic/worker.ts +0 -3
- package/examples/crypto-dashboard/elm.json +0 -30
- package/examples/crypto-dashboard/package.json +0 -10
- package/examples/crypto-dashboard/runtime.ts +0 -97
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
- package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
- package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
- package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
- package/examples/crypto-dashboard/styles.ts +0 -23
- package/examples/crypto-dashboard/worker.ts +0 -3
- package/llms.txt +0 -69
- package/packages/elm-ssr/README.md +0 -67
- package/packages/elm-ssr/package.json +0 -61
- package/scripts/benchmark.mjs +0 -60
- package/test/action.test.ts +0 -81
- package/test/adapters.test.ts +0 -173
- package/test/advanced-robustness.test.ts +0 -75
- package/test/app.test.ts +0 -209
- package/test/browser-island.test.ts +0 -184
- package/test/cli-migrate.test.ts +0 -97
- package/test/cli.test.ts +0 -94
- package/test/cookies.test.ts +0 -156
- package/test/crypto-dashboard.test.ts +0 -35
- package/test/effects.test.ts +0 -117
- package/test/http.test.ts +0 -50
- package/test/integration/redis-postgres.test.ts +0 -174
- package/test/island-runtime.test.ts +0 -214
- package/test/middleware.test.ts +0 -134
- package/test/migrations.test.ts +0 -244
- package/test/profile.test.ts +0 -159
- package/test/robustness.test.ts +0 -135
- package/test/serialize.test.ts +0 -92
- package/test/sessions.test.ts +0 -429
- package/test/svg.test.ts +0 -65
- package/tsconfig.json +0 -20
- package/wrangler.jsonc +0 -11
- /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
- /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
- /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
- /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
- /package/{packages/elm-ssr/src → src}/app.ts +0 -0
- /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
- /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/http.ts +0 -0
- /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
- /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
- /package/{packages/elm-ssr/src → src}/render.ts +0 -0
- /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
- /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
- /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
- /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
- /package/{packages/elm-ssr/src → src}/tasks.ts +0 -0
package/README.md
CHANGED
|
@@ -1,361 +1,67 @@
|
|
|
1
1
|
# elm-ssr
|
|
2
2
|
|
|
3
3
|
Elm-first SSR library and framework for Cloudflare Workers (and Bun locally).
|
|
4
|
+
One package, three pieces:
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
- A **CLI** (`elm-ssr build|new|migrate|dev`) that scans your routes/islands,
|
|
7
|
+
generates the router + manifest, and runs `elm make`.
|
|
8
|
+
- A **Worker runtime** (`createWorkerApp`, `renderApp`, effect adapters,
|
|
9
|
+
background tasks, SQL migrations, middleware) exported via subpaths.
|
|
10
|
+
- A set of **Elm authoring modules** under `elm-src/ElmSsr/` (Route, Loader,
|
|
11
|
+
Action, Html, Svg, Island, Page, Document, Runtime) which the build syncs
|
|
12
|
+
into each app's `.elm-ssr/src/ElmSsr/`.
|
|
6
13
|
|
|
7
|
-
|
|
8
|
-
`ElmSsr.Svg`) because Cloudflare Workers has no DOM — `elm/html`'s virtual-dom
|
|
9
|
-
kernel can't run server-side.
|
|
10
|
-
- **Islands** are *standard* `Browser.element` programs using stock `elm/html`,
|
|
11
|
-
`elm/svg`, `elm/http`, `Html.Keyed`, `elm/time`, … They mount client-side into
|
|
12
|
-
`<elm-ssr-island>` markers the page emits.
|
|
14
|
+
## Install
|
|
13
15
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
adapters execute them — KV/D1 on Cloudflare, Redis/Postgres/SQLite locally —
|
|
17
|
-
without any change to the Elm code. Background jobs run after the response via
|
|
18
|
-
`ctx.waitUntil` or Cloudflare Queues. A built-in SQL migration runner brings the
|
|
19
|
-
schema with it.
|
|
20
|
-
|
|
21
|
-
## Why it exists
|
|
22
|
-
|
|
23
|
-
Stock Elm SSR is awkward because `elm/html` is opaque and DOM-bound. Other
|
|
24
|
-
frameworks fake hydration by replaying the whole tree. This project takes the
|
|
25
|
-
other fork:
|
|
26
|
-
|
|
27
|
-
- **Real SSR for pages** via a serialisable, library-owned AST → works on
|
|
28
|
-
Cloudflare Workers with no DOM, real HTML per request.
|
|
29
|
-
- **No fake hydration** — islands are mounted with normal Elm browser runtime,
|
|
30
|
-
so you get unmodified `elm/html`/`Html.Keyed`/`elm/http`/`elm/svg` inside them.
|
|
31
|
-
- **Backend-neutral effects** so the same Elm runs against Cloudflare KV/D1 or
|
|
32
|
-
local Redis/Postgres/SQLite by swapping the runner adapter — useful for
|
|
33
|
-
parity between local dev and production.
|
|
34
|
-
|
|
35
|
-
## Quickstart
|
|
36
|
-
|
|
37
|
-
```bash
|
|
38
|
-
bun install
|
|
39
|
-
bun run build # CLI scans Routes/ + Islands/, generates Main.elm + islands bundle
|
|
40
|
-
bun run test # 100+ tests, including end-to-end worker.fetch coverage
|
|
41
|
-
bun run dev # wrangler dev
|
|
42
|
-
```
|
|
43
|
-
|
|
44
|
-
Scaffold a new app:
|
|
45
|
-
|
|
46
|
-
```bash
|
|
47
|
-
bun run ssr:new my-app
|
|
48
|
-
```
|
|
49
|
-
|
|
50
|
-
Optional — run integration tests against real Postgres + Redis:
|
|
51
|
-
|
|
52
|
-
```bash
|
|
53
|
-
docker compose up -d --wait
|
|
54
|
-
bun run test:integration
|
|
55
|
-
docker compose down
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
The default `bun test` skips the integration suites when `DATABASE_URL` /
|
|
59
|
-
`REDIS_URL` aren't set, so machines without Docker stay clean.
|
|
60
|
-
|
|
61
|
-
## Repository layout
|
|
62
|
-
|
|
63
|
-
```
|
|
64
|
-
packages/
|
|
65
|
-
elm-ssr/ # The single published package: CLI (`elm-ssr` build/new/migrate/dev),
|
|
66
|
-
# TS runtime + effect adapters + tasks/queues + migrations, and the
|
|
67
|
-
# Elm authoring modules under elm-src/ (synced into each app on build)
|
|
68
|
-
examples/
|
|
69
|
-
basic/ # The reference app (pages, islands, forms, cache, sql, tasks)
|
|
70
|
-
crypto-dashboard/ # Tailwind + elm/svg + elm/http islands with 15s refresh + cross-island bus
|
|
71
|
-
docker-compose.yml # postgres + redis for integration tests
|
|
72
|
-
AGENTS.md # Orientation for AI agents working on this repo
|
|
73
|
-
```
|
|
74
|
-
|
|
75
|
-
## Authoring
|
|
76
|
-
|
|
77
|
-
### Routes (file-based)
|
|
78
|
-
|
|
79
|
-
Drop a module under `src/<App>/Routes/`:
|
|
80
|
-
- `Index.elm` → `/`
|
|
81
|
-
- `Counter.elm` → `/counter`
|
|
82
|
-
- `Greet/Name_.elm` → `/greet/:name` (names ending in `_` are dynamic segments)
|
|
83
|
-
- `NotFound.elm` → fallback
|
|
84
|
-
|
|
85
|
-
Every route exposes `page` (GET/HEAD) and `action` (POST):
|
|
86
|
-
|
|
87
|
-
```elm
|
|
88
|
-
module Demo.Routes.Status exposing (action, page)
|
|
89
|
-
|
|
90
|
-
import ElmSsr.Action as Action exposing (Action)
|
|
91
|
-
import ElmSsr.Document exposing (Document)
|
|
92
|
-
import ElmSsr.Loader as Loader exposing (Loader)
|
|
93
|
-
import ElmSsr.Route exposing (Request)
|
|
94
|
-
|
|
95
|
-
page : Request -> Loader (Document Never)
|
|
96
|
-
page _ =
|
|
97
|
-
Loader.succeed view
|
|
98
|
-
|
|
99
|
-
action : Request -> Action (Document Never)
|
|
100
|
-
action _ =
|
|
101
|
-
Action.fail 405 "Method not allowed"
|
|
102
|
-
```
|
|
103
|
-
|
|
104
|
-
### Loaders + effects
|
|
105
|
-
|
|
106
|
-
Loaders are pure descriptions; the Worker pumps their effects:
|
|
107
|
-
|
|
108
|
-
```elm
|
|
109
|
-
cachedStatus : Loader Status
|
|
110
|
-
cachedStatus =
|
|
111
|
-
Loader.cacheGet { key = "status", decoder = decoder }
|
|
112
|
-
|> Loader.andThen
|
|
113
|
-
(\cached ->
|
|
114
|
-
case cached of
|
|
115
|
-
Just status -> Loader.succeed status
|
|
116
|
-
Nothing ->
|
|
117
|
-
Loader.fetchJson { url = "https://…", decoder = decoder }
|
|
118
|
-
|> Loader.andThen
|
|
119
|
-
(\status ->
|
|
120
|
-
Loader.cachePut { key = "status", value = encode status, ttlSeconds = Just 60 }
|
|
121
|
-
|> Loader.map (\_ -> status)
|
|
122
|
-
)
|
|
123
|
-
)
|
|
16
|
+
```sh
|
|
17
|
+
bun add elm-ssr
|
|
124
18
|
```
|
|
125
19
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
`
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
_ ->
|
|
154
|
-
Action.fail 422 "Message is required."
|
|
155
|
-
```
|
|
156
|
-
|
|
157
|
-
### Islands
|
|
158
|
-
|
|
159
|
-
Drop a `Browser.element` under `src/<App>/Islands/`. The module also exposes a
|
|
160
|
-
one-line `embed` that pages call directly — no generated indirection:
|
|
161
|
-
|
|
162
|
-
```elm
|
|
163
|
-
module Demo.Islands.Counter exposing (embed, main)
|
|
164
|
-
|
|
165
|
-
import Browser
|
|
166
|
-
import ElmSsr.Island as Island
|
|
167
|
-
import Html exposing (Html, button, div, text)
|
|
168
|
-
import Html.Events exposing (onClick)
|
|
169
|
-
import Json.Encode as Encode
|
|
170
|
-
|
|
171
|
-
type alias Flags = { start : Int }
|
|
172
|
-
type alias Model = { count : Int }
|
|
173
|
-
type Msg = Increment
|
|
174
|
-
|
|
175
|
-
embed : Flags -> Island.Node msg
|
|
176
|
-
embed =
|
|
177
|
-
Island.embed "Counter"
|
|
178
|
-
{ encodeFlags = \f -> Encode.object [ ( "start", Encode.int f.start ) ]
|
|
179
|
-
, fallback = \_ -> []
|
|
180
|
-
, id = Nothing
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
main : Program Flags Model Msg
|
|
184
|
-
main =
|
|
185
|
-
Browser.element
|
|
186
|
-
{ init = \flags -> ( { count = flags.start }, Cmd.none )
|
|
187
|
-
, update = \_ m -> ( { m | count = m.count + 1 }, Cmd.none )
|
|
188
|
-
, subscriptions = \_ -> Sub.none
|
|
189
|
-
, view = \m -> div [] [ button [ onClick Increment ] [ text "+" ], text (String.fromInt m.count) ]
|
|
190
|
-
}
|
|
20
|
+
Then use the CLI as `elm-ssr <command>` (or `bun elm-ssr <command>` in a
|
|
21
|
+
workspace) and import the runtime via subpaths.
|
|
22
|
+
|
|
23
|
+
## CLI commands
|
|
24
|
+
|
|
25
|
+
- **`elm-ssr build`** — scans each configured app's `src/<Namespace>/Routes/`
|
|
26
|
+
and `Islands/`, generates `.elm-ssr/Main.elm` (the router with file-based
|
|
27
|
+
routes + dynamic segments) and the islands manifest, syncs the Elm authoring
|
|
28
|
+
modules into the app's `.elm-ssr/src/ElmSsr/`, and compiles via `elm make`
|
|
29
|
+
(one combined island bundle exposing `Elm.<App>.Islands.<Name>`).
|
|
30
|
+
- **`elm-ssr new <name>`** — scaffold a new app and register it in
|
|
31
|
+
`elm-ssr.config.json`.
|
|
32
|
+
- **`elm-ssr dev`** — `build` then `wrangler dev`.
|
|
33
|
+
- **`elm-ssr compress`** — pre-compress generated bundles with gzip.
|
|
34
|
+
- **`elm-ssr migrate <up|down|status>`** — apply / revert / inspect SQL-file
|
|
35
|
+
migrations. `--db postgres://…`, `sqlite://path`, or a plain SQLite file path;
|
|
36
|
+
`--dir <path>` (default `./migrations`); `--count N` (for `down`, default 1).
|
|
37
|
+
Reads `DATABASE_URL` if `--db` is omitted.
|
|
38
|
+
|
|
39
|
+
Configuration lives in `elm-ssr.config.json` at the repo root:
|
|
40
|
+
|
|
41
|
+
```jsonc
|
|
42
|
+
{
|
|
43
|
+
"apps": [
|
|
44
|
+
{ "name": "basic", "root": "examples/basic", "module": "Example.Basic" }
|
|
45
|
+
]
|
|
46
|
+
}
|
|
191
47
|
```
|
|
192
48
|
|
|
193
|
-
|
|
194
|
-
state goes through `ElmSsr.Island.Shared.broadcast`/`listen` (a `window`
|
|
195
|
-
CustomEvent bus).
|
|
196
|
-
|
|
197
|
-
## Effect adapters
|
|
198
|
-
|
|
199
|
-
Compose the worker's `effects` from small adapters:
|
|
49
|
+
## Runtime exports
|
|
200
50
|
|
|
201
51
|
```ts
|
|
52
|
+
import { createWorkerApp } from "elm-ssr";
|
|
53
|
+
import { renderApp, type CompiledElmModule } from "elm-ssr/render";
|
|
54
|
+
import type { RouteCatalog } from "elm-ssr/http";
|
|
202
55
|
import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";
|
|
203
56
|
import { withCache, redisCache, postgresSql } from "elm-ssr/backends";
|
|
204
|
-
import { withTasks, withQueueProducer } from "elm-ssr/tasks";
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const effects = withTasks(cloudflareEffects({ cacheBinding: "CACHE", dbBinding: "DB" }), {
|
|
208
|
-
sendEmail,
|
|
209
|
-
warmCache
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
// Local dev / tests:
|
|
213
|
-
const effects = withTasks(
|
|
214
|
-
withCache(
|
|
215
|
-
inMemoryEffects({ sql: postgresSql(myPgClient), env: { /* … */ } }),
|
|
216
|
-
redisCache(myRedisClient)
|
|
217
|
-
),
|
|
218
|
-
{ sendEmail, warmCache }
|
|
219
|
-
);
|
|
220
|
-
```
|
|
221
|
-
|
|
222
|
-
The Elm code is identical on both. `redisCache(client)` and `postgresSql(client)`
|
|
223
|
-
take minimal client interfaces (see `packages/elm-ssr/src/backends.ts`) so
|
|
224
|
-
they work with `Bun.redis`/`Bun.sql`, `ioredis`, `node-postgres`, SQLite, etc.
|
|
225
|
-
|
|
226
|
-
For durable background jobs (instead of `waitUntil`), swap `withTasks` for
|
|
227
|
-
`withQueueProducer({ queueBinding })` and wire `createQueueConsumer(handlers)`
|
|
228
|
-
in the consumer worker's `queue` handler.
|
|
229
|
-
|
|
230
|
-
## Database migrations
|
|
231
|
-
|
|
232
|
-
SQL-file migrations live in a directory (e.g. `migrations/0001_init.sql`,
|
|
233
|
-
`0002_add_users.sql`). The runner creates a tracking table
|
|
234
|
-
(`__elm_ssr_migrations`) and applies each pending file in alphabetical order,
|
|
235
|
-
**transactionally** (each migration + its tracking insert are one `BEGIN…COMMIT`,
|
|
236
|
-
so a failure rolls back without leaving a partial schema). Re-runs are
|
|
237
|
-
idempotent. Optional down migrations live alongside as `<name>.down.sql`.
|
|
238
|
-
|
|
239
|
-
The example app ships migrations in [`examples/basic/migrations/`](./examples/basic/migrations/) — the `0001_guestbook.sql` schema used by `/guestbook`, plus a paired `0001_guestbook.down.sql`.
|
|
240
|
-
|
|
241
|
-
### From the CLI
|
|
242
|
-
|
|
243
|
-
```bash
|
|
244
|
-
elm-ssr migrate up --dir ./migrations --db ./app.db
|
|
245
|
-
elm-ssr migrate status --dir ./migrations --db ./app.db
|
|
246
|
-
elm-ssr migrate down --dir ./migrations --db ./app.db # revert the last applied
|
|
247
|
-
elm-ssr migrate down --count 3 --dir ./migrations --db postgres://user:pass@localhost:5432/db
|
|
248
|
-
```
|
|
249
|
-
|
|
250
|
-
`--db` accepts a Postgres URL (`postgres://…` / `postgresql://…`), a `sqlite://` URL, or a plain SQLite file path. If omitted it reads `DATABASE_URL` from the environment.
|
|
251
|
-
|
|
252
|
-
### Programmatic API
|
|
253
|
-
|
|
254
|
-
```ts
|
|
255
|
-
import { Database } from "bun:sqlite";
|
|
256
|
-
import {
|
|
257
|
-
runMigrations,
|
|
258
|
-
revertMigrations,
|
|
259
|
-
listMigrations
|
|
260
|
-
} from "elm-ssr/migrations";
|
|
261
|
-
|
|
262
|
-
const db = new Database("app.db");
|
|
263
|
-
const adapter = {
|
|
264
|
-
exec: async (sql) => { db.exec(sql); },
|
|
265
|
-
list: async (sql) => db.query(sql).all()
|
|
266
|
-
};
|
|
267
|
-
|
|
268
|
-
const { applied, skipped } = await runMigrations(adapter, { dir: "./migrations" });
|
|
269
|
-
const status = await listMigrations(adapter, { dir: "./migrations" });
|
|
270
|
-
const { reverted } = await revertMigrations(adapter, { dir: "./migrations", count: 1 });
|
|
271
|
-
```
|
|
272
|
-
|
|
273
|
-
The same `MigrationsAdapter` shape wires to Postgres (`Bun.sql`/`node-postgres`)
|
|
274
|
-
— optionally with `runInTransaction(fn)` if the driver exposes native
|
|
275
|
-
transaction scopes (`sql.begin` / `pool.connect`) — or Cloudflare D1.
|
|
276
|
-
|
|
277
|
-
## Commands
|
|
278
|
-
|
|
279
|
-
```bash
|
|
280
|
-
bun install
|
|
281
|
-
bun run build # generate Main.elm, compile app + islands bundle, write generated/
|
|
282
|
-
bun run check # build + tsc --noEmit
|
|
283
|
-
bun run test # build + bun test (skips integration when env unset)
|
|
284
|
-
bun run test:integration # bun test test/integration/ (needs DATABASE_URL + REDIS_URL)
|
|
285
|
-
bun run test:docker # docker compose up --wait; bun run test; docker compose down
|
|
286
|
-
bun run dev # build + wrangler dev
|
|
287
|
-
bun run deploy # build + wrangler deploy
|
|
288
|
-
bun run ssr:new <name> # scaffold a new example app
|
|
289
|
-
bun run ssr:routes # print configured app modules
|
|
290
|
-
|
|
291
|
-
# Migration CLI (any directory + any backend)
|
|
292
|
-
elm-ssr migrate up --dir ./migrations --db ./app.db
|
|
293
|
-
elm-ssr migrate status --dir ./migrations --db ./app.db
|
|
294
|
-
elm-ssr migrate down --dir ./migrations --db ./app.db [--count N]
|
|
57
|
+
import { withTasks, withQueueProducer, createQueueConsumer } from "elm-ssr/tasks";
|
|
58
|
+
import { runMigrations, revertMigrations, listMigrations } from "elm-ssr/migrations";
|
|
59
|
+
import { composeMiddleware } from "elm-ssr/middleware";
|
|
295
60
|
```
|
|
296
61
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
- `GET /` — pure SSR page, no client JS.
|
|
300
|
-
- `GET /status` — loader page with **server-side caching** (`cacheGet` → miss →
|
|
301
|
-
`fetchJson` → `cachePut`) and an `env` read.
|
|
302
|
-
- `GET /counter` — SSR page that embeds two `Browser.element` islands.
|
|
303
|
-
- `GET /greet/:name` — SSR page with a dynamic segment.
|
|
304
|
-
- `GET /chart` — pure-SSR inline SVG via `ElmSsr.Svg`.
|
|
305
|
-
- `GET /echo`, `POST /echo` — form action: validate → effect → PRG redirect, no JS.
|
|
306
|
-
- `GET /guestbook`, `POST /guestbook` — list via `query`, insert via `execute`,
|
|
307
|
-
enqueue a background `auditEntry` task after the redirect.
|
|
308
|
-
|
|
309
|
-
## Phases shipped
|
|
310
|
-
|
|
311
|
-
- **Phase 1** — File-based routes, Loaders, server effect loop. (`docs/`)
|
|
312
|
-
- **Phase 1.5** — Dynamic segments, small-modules split.
|
|
313
|
-
- **Islands pivot** — Islands are `Browser.element` (full `elm/html` ecosystem).
|
|
314
|
-
- **Phase 2** — Effectful `Action` (free monad like Loader), `fromLoader`, form
|
|
315
|
-
actions, PRG redirects.
|
|
316
|
-
- **Phase 3** — Backend-neutral effects (`cacheGet`/`cachePut`, `query`/`queryOne`/
|
|
317
|
-
`execute`, `env`), Cloudflare + in-memory adapters, composable
|
|
318
|
-
`withCache`/`postgresSql`/`redisCache` over driver-agnostic client interfaces.
|
|
319
|
-
- **Phase 4** — Background tasks (`enqueue`) via `withTasks` (`waitUntil`) or
|
|
320
|
-
`withQueueProducer` + `createQueueConsumer` (CF Queues).
|
|
321
|
-
- **Migrations** — file-based SQL with transactional per-migration safety.
|
|
322
|
-
|
|
323
|
-
## Tradeoffs
|
|
324
|
-
|
|
325
|
-
- Pages use a library-specific SSR HTML tree, not stock `elm/html`. (Workers has
|
|
326
|
-
no DOM.) Islands use stock Elm.
|
|
327
|
-
- No "fake full hydration" for arbitrary `elm/html` apps.
|
|
328
|
-
- Islands ship one combined bundle per app (no per-route code splitting — Elm
|
|
329
|
-
has no lazy loading).
|
|
330
|
-
- The default `withTasks` keeps the isolate alive via `waitUntil`; durable jobs
|
|
331
|
-
need Queues (an adapter swap, no Elm change).
|
|
332
|
-
|
|
333
|
-
## Guarantees
|
|
334
|
-
|
|
335
|
-
- SSR render is request-scoped, with per-request `env`/bindings on the effect
|
|
336
|
-
runner.
|
|
337
|
-
- Island state is client-scoped and isolated per mounted root.
|
|
338
|
-
- Pages without island markers ship no browser runtime.
|
|
339
|
-
- `Html.Keyed` works through Elm's own runtime inside islands.
|
|
340
|
-
- Each SQL migration runs inside a transaction; tracking is updated only on
|
|
341
|
-
successful commit.
|
|
342
|
-
- Worker concerns (middleware, REST, asset serving) stay outside the
|
|
343
|
-
author-facing Elm modules.
|
|
344
|
-
|
|
345
|
-
## More
|
|
346
|
-
|
|
347
|
-
- [`docs/`](./docs/) — topic-by-topic documentation (routing, effects,
|
|
348
|
-
backends, tasks, islands, migrations, sessions, CLI, middleware, testing).
|
|
349
|
-
- [`CHANGELOG.md`](./CHANGELOG.md) — release notes.
|
|
350
|
-
- [`llms.txt`](./llms.txt) — entry point for LLMs/AI agents reading this repo
|
|
351
|
-
([llmstxt.org](https://llmstxt.org/) format).
|
|
352
|
-
- [`AGENTS.md`](./AGENTS.md) — orientation for AI agents working on this
|
|
353
|
-
repo (hard rules + footguns).
|
|
354
|
-
- [`packages/elm-ssr/README.md`](./packages/elm-ssr/README.md) — the package
|
|
355
|
-
readme (CLI commands, runtime exports, Elm authoring modules).
|
|
356
|
-
|
|
357
|
-
The package also ships the Elm authoring modules (under `packages/elm-ssr/elm-src/`) which the build syncs into each app's `.elm-ssr/src/ElmSsr/` at compile time.
|
|
62
|
+
See the [top-level README](../../README.md) for end-to-end usage and the
|
|
63
|
+
authoring guide.
|
|
358
64
|
|
|
359
65
|
## License
|
|
360
66
|
|
|
361
|
-
MIT.
|
|
67
|
+
MIT.
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
port module ElmSsr.Island.Sse exposing
|
|
2
|
+
( Event, Error(..)
|
|
3
|
+
, open, close
|
|
4
|
+
, events, errors, match
|
|
5
|
+
)
|
|
6
|
+
|
|
7
|
+
{-| Server-Sent Events subscription for islands.
|
|
8
|
+
|
|
9
|
+
The browser opens an `EventSource` per URL; the Worker streams
|
|
10
|
+
`text/event-stream` from a route built with `createSseStream` (see the
|
|
11
|
+
TS-side `elm-ssr/sse` subpath). Multiple subscriptions to the same URL share
|
|
12
|
+
a single underlying connection.
|
|
13
|
+
|
|
14
|
+
Lifecycle:
|
|
15
|
+
- Open the stream in `init` (or any `update` branch) with [`open url`](#open).
|
|
16
|
+
- Receive raw frames via [`events`](#events) in `subscriptions`; route them
|
|
17
|
+
by URL with [`match`](#match) plus your decoder.
|
|
18
|
+
- Optionally observe connection errors via [`errors`](#errors).
|
|
19
|
+
- Close with [`close url`](#close) when you no longer need the stream;
|
|
20
|
+
SPA navigation closes them automatically for non-persistent islands.
|
|
21
|
+
|
|
22
|
+
A minimal island:
|
|
23
|
+
|
|
24
|
+
type Msg
|
|
25
|
+
= GotEvent Event
|
|
26
|
+
| NoOp
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
init flags =
|
|
30
|
+
( { time = "" }, Sse.open "/__elm-ssr/live" )
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
subscriptions _ =
|
|
34
|
+
Sse.events GotEvent
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
update msg model =
|
|
38
|
+
case msg of
|
|
39
|
+
GotEvent event ->
|
|
40
|
+
case Sse.match "/__elm-ssr/live" tickDecoder event of
|
|
41
|
+
Just (Ok tick) ->
|
|
42
|
+
( { model | time = tick.time }, Cmd.none )
|
|
43
|
+
|
|
44
|
+
_ ->
|
|
45
|
+
( model, Cmd.none )
|
|
46
|
+
|
|
47
|
+
NoOp ->
|
|
48
|
+
( model, Cmd.none )
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# Types
|
|
52
|
+
|
|
53
|
+
@docs Event, Error
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# Opening and closing
|
|
57
|
+
|
|
58
|
+
@docs open, close
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# Receiving
|
|
62
|
+
|
|
63
|
+
@docs events, errors, match
|
|
64
|
+
|
|
65
|
+
-}
|
|
66
|
+
|
|
67
|
+
import Json.Decode as Decode exposing (Decoder)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
{-| A raw SSE message. `data` is the unparsed string payload — use
|
|
71
|
+
[`match`](#match) plus a decoder to turn it into a typed value.
|
|
72
|
+
-}
|
|
73
|
+
type alias Event =
|
|
74
|
+
{ url : String, data : String }
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
{-| Why an SSE callback might fail. -}
|
|
78
|
+
type Error
|
|
79
|
+
= DecodeError String
|
|
80
|
+
| NetworkError String
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
port sseOpen : String -> Cmd msg
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
port sseClose : String -> Cmd msg
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
port sseEventIn : (Event -> msg) -> Sub msg
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
port sseErrorIn : ({ url : String, message : String } -> msg) -> Sub msg
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
{-| Open (or reuse) an SSE connection to a URL. Idempotent — opening the same
|
|
96
|
+
URL twice does not open a second connection. -}
|
|
97
|
+
open : String -> Cmd msg
|
|
98
|
+
open url =
|
|
99
|
+
sseOpen url
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
{-| Close the SSE connection for a URL. Idempotent. -}
|
|
103
|
+
close : String -> Cmd msg
|
|
104
|
+
close url =
|
|
105
|
+
sseClose url
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
{-| Subscribe to every SSE message across every open stream. Filter by URL in
|
|
109
|
+
your `update` (the [`match`](#match) helper is the convenient way). -}
|
|
110
|
+
events : (Event -> msg) -> Sub msg
|
|
111
|
+
events toMsg =
|
|
112
|
+
sseEventIn toMsg
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
{-| Subscribe to connection errors (`onerror` events from `EventSource`).
|
|
116
|
+
The browser auto-reconnects after these — this is informational. -}
|
|
117
|
+
errors : ({ url : String, message : String } -> msg) -> Sub msg
|
|
118
|
+
errors toMsg =
|
|
119
|
+
sseErrorIn toMsg
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
{-| If this event is for `url`, decode its `data` with `decoder`. Returns
|
|
123
|
+
`Nothing` for a different URL so the caller can `Maybe`-pattern-match in
|
|
124
|
+
`update` without nested `case`s.
|
|
125
|
+
|
|
126
|
+
update msg model =
|
|
127
|
+
case msg of
|
|
128
|
+
GotEvent event ->
|
|
129
|
+
case Sse.match "/__elm-ssr/live" tickDecoder event of
|
|
130
|
+
Just (Ok tick) ->
|
|
131
|
+
( { model | tick = tick }, Cmd.none )
|
|
132
|
+
|
|
133
|
+
Just (Err _) ->
|
|
134
|
+
( model, Cmd.none )
|
|
135
|
+
|
|
136
|
+
Nothing ->
|
|
137
|
+
( model, Cmd.none )
|
|
138
|
+
|
|
139
|
+
-}
|
|
140
|
+
match : String -> Decoder a -> Event -> Maybe (Result Error a)
|
|
141
|
+
match url decoder event =
|
|
142
|
+
if event.url == url then
|
|
143
|
+
case Decode.decodeString decoder event.data of
|
|
144
|
+
Ok value ->
|
|
145
|
+
Just (Ok value)
|
|
146
|
+
|
|
147
|
+
Err err ->
|
|
148
|
+
Just (Err (DecodeError (Decode.errorToString err)))
|
|
149
|
+
|
|
150
|
+
else
|
|
151
|
+
Nothing
|
package/package.json
CHANGED
|
@@ -1,33 +1,62 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elm-ssr",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
"version": "0.3.0",
|
|
4
|
+
"description": "Elm-first SSR library and framework for Cloudflare Workers (and Bun): file-based routes/islands, backend-neutral effect adapters (KV/D1/Redis/Postgres), background tasks (waitUntil/Queues), SQL-file migrations, CLI scaffold + build.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Michał Majchrzak <michmajchrzak@gmail.com>",
|
|
7
|
+
"homepage": "https://github.com/the-man-with-a-golden-mind/elm-ssr#readme",
|
|
8
|
+
"bugs": {
|
|
9
|
+
"url": "https://github.com/the-man-with-a-golden-mind/elm-ssr/issues"
|
|
10
|
+
},
|
|
11
|
+
"repository": {
|
|
12
|
+
"type": "git",
|
|
13
|
+
"url": "git+https://github.com/the-man-with-a-golden-mind/elm-ssr.git",
|
|
14
|
+
"directory": "packages/elm-ssr"
|
|
15
|
+
},
|
|
16
|
+
"keywords": [
|
|
17
|
+
"elm",
|
|
18
|
+
"ssr",
|
|
19
|
+
"server-side-rendering",
|
|
20
|
+
"cloudflare-workers",
|
|
21
|
+
"bun",
|
|
22
|
+
"islands",
|
|
23
|
+
"framework",
|
|
24
|
+
"edge",
|
|
25
|
+
"file-based-routing",
|
|
26
|
+
"migrations"
|
|
8
27
|
],
|
|
9
|
-
"
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"
|
|
17
|
-
"
|
|
18
|
-
"
|
|
19
|
-
"
|
|
20
|
-
"
|
|
21
|
-
"
|
|
28
|
+
"type": "module",
|
|
29
|
+
"scripts": {},
|
|
30
|
+
"bin": {
|
|
31
|
+
"elm-ssr": "bin/elm-ssr.mjs"
|
|
32
|
+
},
|
|
33
|
+
"main": "./src/app.ts",
|
|
34
|
+
"exports": {
|
|
35
|
+
".": "./src/app.ts",
|
|
36
|
+
"./render": "./src/render.ts",
|
|
37
|
+
"./http": "./src/http.ts",
|
|
38
|
+
"./effects": "./src/effects.ts",
|
|
39
|
+
"./tasks": "./src/tasks.ts",
|
|
40
|
+
"./backends": "./src/backends.ts",
|
|
41
|
+
"./migrations": "./src/migrations.ts",
|
|
42
|
+
"./middleware": "./src/middleware.ts",
|
|
43
|
+
"./sessions": "./src/sessions/index.ts",
|
|
44
|
+
"./sse": "./src/sse.ts",
|
|
45
|
+
"./response-headers": "./src/response-headers.ts",
|
|
46
|
+
"./serialize": "./src/serialize.ts",
|
|
47
|
+
"./protocol": "./src/protocol.ts",
|
|
48
|
+
"./islands-runtime": "./src/client-runtime/islands.ts"
|
|
22
49
|
},
|
|
50
|
+
"files": [
|
|
51
|
+
"bin",
|
|
52
|
+
"lib",
|
|
53
|
+
"elm-src",
|
|
54
|
+
"src"
|
|
55
|
+
],
|
|
23
56
|
"engines": {
|
|
24
57
|
"bun": ">=1.3"
|
|
25
58
|
},
|
|
26
|
-
"
|
|
27
|
-
"
|
|
28
|
-
"elm": "^0.19.1-6",
|
|
29
|
-
"happy-dom": "^20.9.0",
|
|
30
|
-
"typescript": "^5.9.0",
|
|
31
|
-
"wrangler": "^4.0.0"
|
|
59
|
+
"dependencies": {
|
|
60
|
+
"cookie": "^1.0.1"
|
|
32
61
|
}
|
|
33
62
|
}
|