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
|
@@ -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
|
-
|
|
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
|
|
132
|
-
|
|
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
|
|
140
|
-
|
|
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
|
-
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
copy.
|
|
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.
|