elm-ssr 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/README.md +48 -342
  2. package/elm-src/ElmSsr/Island/Sse.elm +151 -0
  3. package/package.json +53 -24
  4. package/{packages/elm-ssr/src → src}/client-runtime/islands.ts +113 -15
  5. package/src/sse.ts +162 -0
  6. package/AGENTS.md +0 -289
  7. package/CHANGELOG.md +0 -87
  8. package/LICENSE +0 -21
  9. package/bun.lock +0 -259
  10. package/docker-compose.yml +0 -33
  11. package/docs/README.md +0 -51
  12. package/docs/backends.md +0 -146
  13. package/docs/cli.md +0 -117
  14. package/docs/effects.md +0 -91
  15. package/docs/getting-started.md +0 -94
  16. package/docs/islands.md +0 -197
  17. package/docs/loaders-and-actions.md +0 -241
  18. package/docs/middleware.md +0 -93
  19. package/docs/migrations.md +0 -143
  20. package/docs/routing.md +0 -108
  21. package/docs/sessions.md +0 -218
  22. package/docs/tasks.md +0 -149
  23. package/docs/testing.md +0 -84
  24. package/elm-ssr.config.json +0 -14
  25. package/examples/basic/elm.json +0 -27
  26. package/examples/basic/migrations/0001_guestbook.down.sql +0 -1
  27. package/examples/basic/migrations/0001_guestbook.sql +0 -10
  28. package/examples/basic/package.json +0 -10
  29. package/examples/basic/runtime.ts +0 -148
  30. package/examples/basic/src/Example/Basic/Islands/Counter.elm +0 -110
  31. package/examples/basic/src/Example/Basic/Islands/Observer.elm +0 -67
  32. package/examples/basic/src/Example/Basic/Islands/Tasks.elm +0 -151
  33. package/examples/basic/src/Example/Basic/Routes/Chart.elm +0 -87
  34. package/examples/basic/src/Example/Basic/Routes/Counter.elm +0 -42
  35. package/examples/basic/src/Example/Basic/Routes/Echo.elm +0 -76
  36. package/examples/basic/src/Example/Basic/Routes/Greet/Name_.elm +0 -37
  37. package/examples/basic/src/Example/Basic/Routes/Guestbook.elm +0 -86
  38. package/examples/basic/src/Example/Basic/Routes/Index.elm +0 -41
  39. package/examples/basic/src/Example/Basic/Routes/NotFound.elm +0 -37
  40. package/examples/basic/src/Example/Basic/Routes/Profile.elm +0 -112
  41. package/examples/basic/src/Example/Basic/Routes/Session.elm +0 -89
  42. package/examples/basic/src/Example/Basic/Routes/Status.elm +0 -90
  43. package/examples/basic/src/Example/Basic/View/Shared.elm +0 -60
  44. package/examples/basic/styles.ts +0 -204
  45. package/examples/basic/worker.ts +0 -3
  46. package/examples/crypto-dashboard/elm.json +0 -30
  47. package/examples/crypto-dashboard/package.json +0 -10
  48. package/examples/crypto-dashboard/runtime.ts +0 -97
  49. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/MarketOverview.elm +0 -204
  50. package/examples/crypto-dashboard/src/CryptoDashboard/Islands/PriceChart.elm +0 -200
  51. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/Index.elm +0 -67
  52. package/examples/crypto-dashboard/src/CryptoDashboard/Routes/NotFound.elm +0 -30
  53. package/examples/crypto-dashboard/src/CryptoDashboard/View/Shared.elm +0 -39
  54. package/examples/crypto-dashboard/styles.ts +0 -23
  55. package/examples/crypto-dashboard/worker.ts +0 -3
  56. package/llms.txt +0 -69
  57. package/packages/elm-ssr/README.md +0 -67
  58. package/packages/elm-ssr/package.json +0 -61
  59. package/scripts/benchmark.mjs +0 -60
  60. package/test/action.test.ts +0 -81
  61. package/test/adapters.test.ts +0 -173
  62. package/test/advanced-robustness.test.ts +0 -75
  63. package/test/app.test.ts +0 -209
  64. package/test/browser-island.test.ts +0 -184
  65. package/test/cli-migrate.test.ts +0 -97
  66. package/test/cli.test.ts +0 -94
  67. package/test/cookies.test.ts +0 -156
  68. package/test/crypto-dashboard.test.ts +0 -35
  69. package/test/effects.test.ts +0 -117
  70. package/test/http.test.ts +0 -50
  71. package/test/integration/redis-postgres.test.ts +0 -174
  72. package/test/island-runtime.test.ts +0 -214
  73. package/test/middleware.test.ts +0 -134
  74. package/test/migrations.test.ts +0 -244
  75. package/test/profile.test.ts +0 -159
  76. package/test/robustness.test.ts +0 -135
  77. package/test/serialize.test.ts +0 -92
  78. package/test/sessions.test.ts +0 -429
  79. package/test/svg.test.ts +0 -65
  80. package/tsconfig.json +0 -20
  81. package/wrangler.jsonc +0 -11
  82. /package/{packages/elm-ssr/bin → bin}/elm-ssr.mjs +0 -0
  83. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Action.elm +0 -0
  84. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Encode.elm +0 -0
  85. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document/Events.elm +0 -0
  86. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Document.elm +0 -0
  87. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Attributes.elm +0 -0
  88. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html/Events.elm +0 -0
  89. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Html.elm +0 -0
  90. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island/Shared.elm +0 -0
  91. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Island.elm +0 -0
  92. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Loader.elm +0 -0
  93. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Page.elm +0 -0
  94. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Route.elm +0 -0
  95. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Runtime.elm +0 -0
  96. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg/Attributes.elm +0 -0
  97. /package/{packages/elm-ssr/elm-src → elm-src}/ElmSsr/Svg.elm +0 -0
  98. /package/{packages/elm-ssr/lib → lib}/build.mjs +0 -0
  99. /package/{packages/elm-ssr/lib → lib}/migrate.mjs +0 -0
  100. /package/{packages/elm-ssr/lib → lib}/scaffold.mjs +0 -0
  101. /package/{packages/elm-ssr/lib → lib}/workspace.mjs +0 -0
  102. /package/{packages/elm-ssr/src → src}/app.ts +0 -0
  103. /package/{packages/elm-ssr/src → src}/backends.ts +0 -0
  104. /package/{packages/elm-ssr/src → src}/effects.ts +0 -0
  105. /package/{packages/elm-ssr/src → src}/http.ts +0 -0
  106. /package/{packages/elm-ssr/src → src}/middleware.ts +0 -0
  107. /package/{packages/elm-ssr/src → src}/migrations.ts +0 -0
  108. /package/{packages/elm-ssr/src → src}/protocol.ts +0 -0
  109. /package/{packages/elm-ssr/src → src}/render.ts +0 -0
  110. /package/{packages/elm-ssr/src → src}/request-handler.ts +0 -0
  111. /package/{packages/elm-ssr/src → src}/response-headers.ts +0 -0
  112. /package/{packages/elm-ssr/src → src}/serialize.ts +0 -0
  113. /package/{packages/elm-ssr/src → src}/sessions/crypto.ts +0 -0
  114. /package/{packages/elm-ssr/src → src}/sessions/effects.ts +0 -0
  115. /package/{packages/elm-ssr/src → src}/sessions/index.ts +0 -0
  116. /package/{packages/elm-ssr/src → src}/sessions/middleware.ts +0 -0
  117. /package/{packages/elm-ssr/src → src}/sessions/store.ts +0 -0
  118. /package/{packages/elm-ssr/src → src}/sessions/types.ts +0 -0
  119. /package/{packages/elm-ssr/src → src}/tasks.ts +0 -0
package/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
- Two execution worlds, glued by a marker element:
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
- - **Pages** are SSR-only Elm. They use a custom serialisable AST (`ElmSsr.Html`,
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
- The Worker exposes a **backend-neutral** effect surface (cache, sql, env,
15
- cookie, fetchJson, enqueue) that Elm `Loader`/`Action` describe; pluggable TS
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
- Available effects (all backend-neutral): `fetchJson`, `cacheGet`/`cachePut`,
127
- `query`/`queryOne`/`execute`, `env`, `getCookie`, `enqueue`. To write
128
- cookies, use `Action.setCookie` / `Action.sessionCookie` / `Action.clearCookie`
129
- from inside an `Action` — see
130
- [docs/loaders-and-actions.md](docs/loaders-and-actions.md#cookies).
131
-
132
- For higher-level **sessions + CSRF** (signed-cookie sessions, pluggable
133
- stores, CSRF protection, the `Loader.session`/`csrfToken`/`setSession`/
134
- `clearSession` effects), see [docs/sessions.md](docs/sessions.md). Opt in
135
- with `sessions:` + `csrf:` on `createWorkerApp`.
136
-
137
- ### Actions (forms without JS)
138
-
139
- Actions are the POST equivalent of loaders describe validation, run a server
140
- effect, then redirect (Post/Redirect/Get). Reuse any Loader effect via
141
- `Action.fromLoader`:
142
-
143
- ```elm
144
- action : Request -> Action (Document Never)
145
- action request =
146
- case Route.formValue "message" request of
147
- Just msg when not (String.isEmpty msg) ->
148
- Action.fromLoader
149
- (Loader.execute { sql = "INSERT INTO entries (message) VALUES (?)"
150
- , params = [ Encode.string msg ] })
151
- |> Action.andThen (\_ -> Action.redirect "/guestbook")
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
- The page imports the island and calls `Counter.embed { start = 0 }`. Cross-island
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
- // Cloudflare deploy:
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
- ## Example routes (`examples/basic`)
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. See [LICENSE](./LICENSE).
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.2.0",
4
- "type": "module",
5
- "workspaces": [
6
- "packages/*",
7
- "examples/*"
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
- "scripts": {
10
- "build:elm": "bun packages/elm-ssr/bin/elm-ssr.mjs build",
11
- "build": "bun run build:elm",
12
- "check": "bun run build && bun ./node_modules/typescript/bin/tsc --noEmit",
13
- "ssr:build": "bun packages/elm-ssr/bin/elm-ssr.mjs build",
14
- "ssr:new": "bun packages/elm-ssr/bin/elm-ssr.mjs new",
15
- "ssr:routes": "bun packages/elm-ssr/bin/elm-ssr.mjs routes",
16
- "test": "docker compose up -d --wait && (DATABASE_URL=postgres://elmssr:elmssr@localhost:5432/elmssr REDIS_URL=redis://localhost:6379 sh -c 'bun run build && bun test'; ec=$?; docker compose down; exit $ec)",
17
- "test:unit": "bun run build && bun test $(find test -name '*.test.ts' -not -path '*/integration/*')",
18
- "test:integration": "docker compose up -d --wait && (DATABASE_URL=postgres://elmssr:elmssr@localhost:5432/elmssr REDIS_URL=redis://localhost:6379 sh -c 'bun run build && bun test test/integration/'; ec=$?; docker compose down; exit $ec)",
19
- "bench": "bun run build && bun run scripts/benchmark.mjs",
20
- "dev": "bun run build && ./node_modules/.bin/wrangler dev",
21
- "deploy": "bun run build && ./node_modules/.bin/wrangler deploy"
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
- "devDependencies": {
27
- "elm-ssr": "workspace:*",
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
  }