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
@@ -23,6 +23,8 @@ function createIslandsRuntime(deps) {
23
23
  return;
24
24
  }
25
25
 
26
+ const teardowns = [];
27
+
26
28
  if (app.ports.broadcastOut) {
27
29
  app.ports.broadcastOut.subscribe((event) => {
28
30
  window.dispatchEvent(new window.CustomEvent("elm-ssr-broadcast", { detail: event }));
@@ -32,7 +34,69 @@ function createIslandsRuntime(deps) {
32
34
  if (app.ports.broadcastIn) {
33
35
  const handler = (event) => app.ports.broadcastIn.send(event.detail);
34
36
  window.addEventListener("elm-ssr-broadcast", handler);
35
- cleanups.set(marker, () => window.removeEventListener("elm-ssr-broadcast", handler));
37
+ teardowns.push(() => window.removeEventListener("elm-ssr-broadcast", handler));
38
+ }
39
+
40
+ // Server-Sent Events ports. One EventSource per (url, island); the island
41
+ // opens/closes via sseOpen/sseClose, and receives raw frames via sseEventIn.
42
+ if (app.ports.sseOpen || app.ports.sseClose || app.ports.sseEventIn || app.ports.sseErrorIn) {
43
+ const sources = new Map(); // url -> EventSource
44
+
45
+ const open = (url) => {
46
+ if (sources.has(url)) {
47
+ return;
48
+ }
49
+ let source;
50
+ try {
51
+ source = new window.EventSource(url);
52
+ } catch (error) {
53
+ if (app.ports.sseErrorIn) {
54
+ app.ports.sseErrorIn.send({ url, message: String(error) });
55
+ }
56
+ return;
57
+ }
58
+ source.onmessage = (event) => {
59
+ if (app.ports.sseEventIn) {
60
+ app.ports.sseEventIn.send({ url, data: typeof event.data === "string" ? event.data : "" });
61
+ }
62
+ };
63
+ source.onerror = () => {
64
+ if (app.ports.sseErrorIn) {
65
+ app.ports.sseErrorIn.send({ url, message: "EventSource error" });
66
+ }
67
+ };
68
+ sources.set(url, source);
69
+ };
70
+
71
+ const close = (url) => {
72
+ const source = sources.get(url);
73
+ if (source) {
74
+ source.close();
75
+ sources.delete(url);
76
+ }
77
+ };
78
+
79
+ if (app.ports.sseOpen) {
80
+ app.ports.sseOpen.subscribe(open);
81
+ }
82
+ if (app.ports.sseClose) {
83
+ app.ports.sseClose.subscribe(close);
84
+ }
85
+
86
+ teardowns.push(() => {
87
+ for (const source of sources.values()) {
88
+ source.close();
89
+ }
90
+ sources.clear();
91
+ });
92
+ }
93
+
94
+ if (teardowns.length > 0) {
95
+ cleanups.set(marker, () => {
96
+ for (const fn of teardowns) {
97
+ fn();
98
+ }
99
+ });
36
100
  }
37
101
  };
38
102
 
@@ -128,30 +192,64 @@ function createIslandsRuntime(deps) {
128
192
  }
129
193
  };
130
194
 
131
- const managedHeadNodes = (head) => {
132
- const metas = Array.prototype.slice.call(head.getElementsByTagName("meta"));
133
- const links = Array.prototype.slice
195
+ const stylesheetLinks = (head) =>
196
+ Array.prototype.slice
134
197
  .call(head.getElementsByTagName("link"))
135
198
  .filter((link) => link.getAttribute("rel") === "stylesheet");
136
- return metas.concat(links);
137
- };
138
199
 
139
- const syncHead = (sourceDoc) => {
140
- document.title = sourceDoc.title;
200
+ const metaNodes = (head) => Array.prototype.slice.call(head.getElementsByTagName("meta"));
201
+
202
+ // Stable signature for diffing: href (+ media if set) for stylesheets,
203
+ // full attribute set for metas. Two nodes with the same signature are
204
+ // treated as identical; we never tear one down and re-create it.
205
+ const stylesheetKey = (link) => (link.getAttribute("href") || "") + "|" + (link.getAttribute("media") || "");
206
+ const metaKey = (meta) =>
207
+ meta
208
+ .getAttributeNames()
209
+ .sort()
210
+ .map((name) => name + "=" + meta.getAttribute(name))
211
+ .join("|");
212
+
213
+ // Diff-based sync: only add what's new, only remove what's gone. Avoids the
214
+ // flash of unstyled content that came from removing-then-readding the
215
+ // <link rel=stylesheet>, even when the href was identical across pages.
216
+ const syncCollection = (currentNodes, incomingNodes, keyOf) => {
217
+ const currentByKey = new Map();
218
+ for (const node of currentNodes) {
219
+ currentByKey.set(keyOf(node), node);
220
+ }
221
+ const incomingByKey = new Map();
222
+ for (const node of incomingNodes) {
223
+ incomingByKey.set(keyOf(node), node);
224
+ }
141
225
 
142
- for (const node of managedHeadNodes(document.head)) {
143
- node.remove();
226
+ // Remove only what's not in the incoming doc.
227
+ for (const [key, node] of currentByKey) {
228
+ if (!incomingByKey.has(key)) {
229
+ node.remove();
230
+ }
144
231
  }
145
232
 
146
- for (const node of managedHeadNodes(sourceDoc.head)) {
147
- const copy = document.createElement(node.tagName);
148
- for (const name of node.getAttributeNames()) {
149
- copy.setAttribute(name, node.getAttribute(name));
233
+ // Add only what's not already present.
234
+ for (const [key, node] of incomingByKey) {
235
+ if (!currentByKey.has(key)) {
236
+ const copy = document.createElement(node.tagName);
237
+ for (const name of node.getAttributeNames()) {
238
+ copy.setAttribute(name, node.getAttribute(name));
239
+ }
240
+ document.head.appendChild(copy);
150
241
  }
151
- document.head.appendChild(copy);
152
242
  }
153
243
  };
154
244
 
245
+ const syncHead = (sourceDoc) => {
246
+ if (document.title !== sourceDoc.title) {
247
+ document.title = sourceDoc.title;
248
+ }
249
+ syncCollection(stylesheetLinks(document.head), stylesheetLinks(sourceDoc.head), stylesheetKey);
250
+ syncCollection(metaNodes(document.head), metaNodes(sourceDoc.head), metaKey);
251
+ };
252
+
155
253
  const navigate = async (url, push = true) => {
156
254
  try {
157
255
  const response = await window.fetch("/api/render?path=" + encodeURIComponent(url.pathname + url.search));
package/src/sse.ts ADDED
@@ -0,0 +1,162 @@
1
+ // Server-Sent Events (text/event-stream) primitive. Use this to expose a
2
+ // streaming endpoint that an island subscribes to via `ElmSsr.Island.Sse`.
3
+ //
4
+ // Usage:
5
+ //
6
+ // import { createSseStream } from "elm-ssr/sse";
7
+ //
8
+ // if (url.pathname === "/__elm-ssr/live") {
9
+ // return createSseStream(request, async (send, signal) => {
10
+ // while (!signal.aborted) {
11
+ // send(JSON.stringify({ time: Date.now() }));
12
+ // await new Promise((r) => setTimeout(r, 1000));
13
+ // }
14
+ // });
15
+ // }
16
+ //
17
+ // `send` accepts either a string (becomes `data:`) or a full `SseEvent` for
18
+ // custom event names / ids / retry hints. Returning from the handler closes
19
+ // the stream; throwing closes it with an error logged.
20
+
21
+ export interface SseEvent {
22
+ data: string;
23
+ event?: string;
24
+ id?: string;
25
+ /** Reconnect hint in milliseconds (browser's EventSource respects this on reconnect). */
26
+ retry?: number;
27
+ }
28
+
29
+ export type SseSend = (event: SseEvent | string) => void;
30
+
31
+ export type SseStreamHandler = (send: SseSend, signal: AbortSignal) => Promise<void> | void;
32
+
33
+ export interface SseStreamOptions {
34
+ /** Extra response headers to merge with the SSE defaults. */
35
+ headers?: HeadersInit;
36
+ /** Initial reconnect hint sent to the client (ms). Defaults to 3000. */
37
+ retryHintMs?: number;
38
+ }
39
+
40
+ const encoder = new TextEncoder();
41
+
42
+ const formatField = (name: string, value: string): string => {
43
+ // Each value line gets its own `name:` prefix per the spec.
44
+ return value
45
+ .split(/\r?\n/)
46
+ .map((line) => `${name}: ${line}\n`)
47
+ .join("");
48
+ };
49
+
50
+ export const encodeSseEvent = (event: SseEvent | string): string => {
51
+ if (typeof event === "string") {
52
+ return `${formatField("data", event)}\n`;
53
+ }
54
+ let frame = "";
55
+ if (event.event) {
56
+ frame += formatField("event", event.event);
57
+ }
58
+ if (event.id !== undefined) {
59
+ frame += formatField("id", event.id);
60
+ }
61
+ if (event.retry !== undefined) {
62
+ frame += `retry: ${Math.max(0, Math.floor(event.retry))}\n`;
63
+ }
64
+ frame += formatField("data", event.data);
65
+ frame += "\n";
66
+ return frame;
67
+ };
68
+
69
+ const sseHeaders = (extra?: HeadersInit): Headers => {
70
+ const headers = new Headers(extra);
71
+ headers.set("content-type", "text/event-stream; charset=utf-8");
72
+ headers.set("cache-control", "no-cache, no-transform");
73
+ headers.set("connection", "keep-alive");
74
+ // Hint to disable nginx-style proxy buffering.
75
+ if (!headers.has("x-accel-buffering")) {
76
+ headers.set("x-accel-buffering", "no");
77
+ }
78
+ return headers;
79
+ };
80
+
81
+ /**
82
+ * Create a streaming SSE Response. The handler runs inside a `ReadableStream`
83
+ * and receives:
84
+ * - `send(event)` — enqueue an event (string shorthand for `{ data }`).
85
+ * - `signal` — fires when the client disconnects OR the handler throws.
86
+ *
87
+ * Returning from the handler closes the stream cleanly; throwing closes with
88
+ * an error logged. The client-disconnect signal is sourced from
89
+ * `request.signal`.
90
+ */
91
+ export const createSseStream = (
92
+ request: Request,
93
+ handler: SseStreamHandler,
94
+ options: SseStreamOptions = {}
95
+ ): Response => {
96
+ const controller = new AbortController();
97
+ const onAbort = () => controller.abort();
98
+ request.signal.addEventListener("abort", onAbort, { once: true });
99
+
100
+ const stream = new ReadableStream<Uint8Array>({
101
+ async start(streamController) {
102
+ const enqueue = (frame: string): void => {
103
+ try {
104
+ streamController.enqueue(encoder.encode(frame));
105
+ } catch {
106
+ // Stream already closed (client disconnected mid-write). Best-effort.
107
+ }
108
+ };
109
+
110
+ // Emit the retry hint up front so reconnects respect it.
111
+ const retry = options.retryHintMs ?? 3000;
112
+ enqueue(`retry: ${retry}\n\n`);
113
+
114
+ const send: SseSend = (event) => enqueue(encodeSseEvent(event));
115
+
116
+ try {
117
+ await handler(send, controller.signal);
118
+ } catch (error) {
119
+ console.error("elm-ssr: SSE handler threw", error);
120
+ controller.abort();
121
+ } finally {
122
+ request.signal.removeEventListener("abort", onAbort);
123
+ try {
124
+ streamController.close();
125
+ } catch {
126
+ // Already closed.
127
+ }
128
+ }
129
+ },
130
+ cancel() {
131
+ controller.abort();
132
+ }
133
+ });
134
+
135
+ return new Response(stream, {
136
+ status: 200,
137
+ headers: sseHeaders(options.headers)
138
+ });
139
+ };
140
+
141
+ /**
142
+ * Convenience for endpoints that publish a single event-stream named channel.
143
+ * Calls `send({ event, data })` with auto-incrementing ids.
144
+ */
145
+ export const createNamedSseStream = (
146
+ request: Request,
147
+ channel: string,
148
+ handler: (publish: (data: string) => void, signal: AbortSignal) => Promise<void> | void,
149
+ options?: SseStreamOptions
150
+ ): Response =>
151
+ createSseStream(
152
+ request,
153
+ async (send, signal) => {
154
+ let nextId = 0;
155
+ const publish = (data: string): void => {
156
+ nextId += 1;
157
+ send({ event: channel, id: String(nextId), data });
158
+ };
159
+ await handler(publish, signal);
160
+ },
161
+ options
162
+ );
package/AGENTS.md DELETED
@@ -1,289 +0,0 @@
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.