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
|
@@ -1,241 +0,0 @@
|
|
|
1
|
-
# Loaders and Actions
|
|
2
|
-
|
|
3
|
-
`Loader` and `Action` are **descriptions of work**, not side effects. The
|
|
4
|
-
author composes them; the runtime pumps the actual IO through the Worker's
|
|
5
|
-
effect adapter and feeds the results back until each one terminates.
|
|
6
|
-
|
|
7
|
-
This keeps Elm pure end-to-end — no manual ports, no flag-shaped JSON
|
|
8
|
-
threading, no `Cmd` for server work.
|
|
9
|
-
|
|
10
|
-
## Loader (data fetching, for `page`)
|
|
11
|
-
|
|
12
|
-
A `Loader a` resolves to a value of type `a` after the runtime has executed
|
|
13
|
-
any effects it requested. The page can then render based on that value.
|
|
14
|
-
|
|
15
|
-
### Constructors
|
|
16
|
-
|
|
17
|
-
```elm
|
|
18
|
-
-- pure success
|
|
19
|
-
Loader.succeed : a -> Loader a
|
|
20
|
-
|
|
21
|
-
-- abort with an HTTP status and message
|
|
22
|
-
Loader.fail : Int -> String -> Loader a
|
|
23
|
-
```
|
|
24
|
-
|
|
25
|
-
### Composition
|
|
26
|
-
|
|
27
|
-
```elm
|
|
28
|
-
Loader.map : (a -> b) -> Loader a -> Loader b
|
|
29
|
-
Loader.map2 : (a -> b -> c) -> Loader a -> Loader b -> Loader c
|
|
30
|
-
Loader.andThen : (a -> Loader b) -> Loader a -> Loader b
|
|
31
|
-
```
|
|
32
|
-
|
|
33
|
-
Loaders are sequential. `andThen` runs the next effect only after the prior
|
|
34
|
-
one resolves.
|
|
35
|
-
|
|
36
|
-
### Effects
|
|
37
|
-
|
|
38
|
-
See [Effects](effects.md) for the full vocabulary. Quick reference:
|
|
39
|
-
|
|
40
|
-
```elm
|
|
41
|
-
Loader.fetchJson : { url : String, decoder : Decoder a } -> Loader a
|
|
42
|
-
Loader.cacheGet : { key : String, decoder : Decoder a } -> Loader (Maybe a)
|
|
43
|
-
Loader.cachePut : { key : String, value : Value, ttlSeconds : Maybe Int } -> Loader ()
|
|
44
|
-
Loader.query : { sql : String, params : List Value, decoder : Decoder a } -> Loader (List a)
|
|
45
|
-
Loader.queryOne : { sql : String, params : List Value, decoder : Decoder a } -> Loader (Maybe a)
|
|
46
|
-
Loader.execute : { sql : String, params : List Value } -> Loader { rowsAffected : Int }
|
|
47
|
-
Loader.env : String -> Loader (Maybe String)
|
|
48
|
-
Loader.enqueue : { task : String, payload : Value } -> Loader ()
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
`getCookie` is also available via `Loader` — see [Effects](effects.md).
|
|
52
|
-
|
|
53
|
-
### Example: cache → fetch → cache
|
|
54
|
-
|
|
55
|
-
```elm
|
|
56
|
-
cachedStatus : Loader Status
|
|
57
|
-
cachedStatus =
|
|
58
|
-
Loader.cacheGet { key = "status", decoder = statusDecoder }
|
|
59
|
-
|> Loader.andThen
|
|
60
|
-
(\cached ->
|
|
61
|
-
case cached of
|
|
62
|
-
Just status ->
|
|
63
|
-
Loader.succeed status
|
|
64
|
-
|
|
65
|
-
Nothing ->
|
|
66
|
-
Loader.fetchJson
|
|
67
|
-
{ url = "https://api.example.com/status"
|
|
68
|
-
, decoder = statusDecoder
|
|
69
|
-
}
|
|
70
|
-
|> Loader.andThen
|
|
71
|
-
(\status ->
|
|
72
|
-
Loader.cachePut
|
|
73
|
-
{ key = "status"
|
|
74
|
-
, value = encodeStatus status
|
|
75
|
-
, ttlSeconds = Just 60
|
|
76
|
-
}
|
|
77
|
-
|> Loader.map (\_ -> status)
|
|
78
|
-
)
|
|
79
|
-
)
|
|
80
|
-
```
|
|
81
|
-
|
|
82
|
-
## Action (form handling, for `action`)
|
|
83
|
-
|
|
84
|
-
`Action a` is the non-GET equivalent. It can resolve to a value, fail, redirect
|
|
85
|
-
(303-style Post/Redirect/Get), or respond with JSON.
|
|
86
|
-
|
|
87
|
-
### Constructors
|
|
88
|
-
|
|
89
|
-
```elm
|
|
90
|
-
Action.succeed : a -> Action a
|
|
91
|
-
Action.fail : Int -> String -> Action a
|
|
92
|
-
Action.redirect : String -> Action a
|
|
93
|
-
Action.json : Value -> Action a
|
|
94
|
-
```
|
|
95
|
-
|
|
96
|
-
### Cookies
|
|
97
|
-
|
|
98
|
-
Any `Action` can attach `Set-Cookie` headers to its response — including
|
|
99
|
-
redirects, JSON responses, and even failures. Cookies travel through
|
|
100
|
-
`map`/`andThen`/`fromLoader` so you can compose freely.
|
|
101
|
-
|
|
102
|
-
```elm
|
|
103
|
-
Action.Cookie : { name : String, value : String, maxAge : Maybe Int, expires : Maybe String, domain : Maybe String, path : Maybe String, secure : Bool, httpOnly : Bool, sameSite : Maybe SameSite }
|
|
104
|
-
|
|
105
|
-
Action.SameSite = Lax | Strict | None
|
|
106
|
-
|
|
107
|
-
-- Build a cookie:
|
|
108
|
-
Action.defaultCookie : String -> String -> Cookie -- permissive (Path=/), fill in what you need
|
|
109
|
-
Action.sessionCookie : String -> String -> Cookie -- HARDENED: Secure, HttpOnly, SameSite=Lax, Max-Age=7d
|
|
110
|
-
|
|
111
|
-
-- Attach to an action:
|
|
112
|
-
Action.setCookie : Cookie -> Action a -> Action a
|
|
113
|
-
Action.clearCookie : { name : String, path : Maybe String, domain : Maybe String } -> Action a -> Action a
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
**Security defaults matter.** `Action.sessionCookie` is what you should reach
|
|
117
|
-
for any time a cookie grants authority (session IDs, auth tokens). It sets:
|
|
118
|
-
|
|
119
|
-
- `HttpOnly` — JavaScript cannot read it (XSS-resistant).
|
|
120
|
-
- `Secure` — only sent over HTTPS.
|
|
121
|
-
- `SameSite=Lax` — sent on top-level navigations, blocked on cross-site
|
|
122
|
-
sub-requests (CSRF-resistant for unsafe verbs).
|
|
123
|
-
- `Path=/`, `Max-Age=7 days`.
|
|
124
|
-
|
|
125
|
-
`Action.defaultCookie` is the unopinionated escape hatch for non-sensitive
|
|
126
|
-
cookies (preferences, analytics consent, etc).
|
|
127
|
-
|
|
128
|
-
### Example: login (PRG + hardened session cookie)
|
|
129
|
-
|
|
130
|
-
```elm
|
|
131
|
-
action : Request -> Action (Document Never)
|
|
132
|
-
action request =
|
|
133
|
-
case Route.formValue "username" request of
|
|
134
|
-
Just username ->
|
|
135
|
-
Action.fromLoader (mintSessionToken username)
|
|
136
|
-
|> Action.andThen
|
|
137
|
-
(\token ->
|
|
138
|
-
Action.redirect "/dashboard"
|
|
139
|
-
|> Action.setCookie (Action.sessionCookie "session" token)
|
|
140
|
-
)
|
|
141
|
-
|
|
142
|
-
Nothing ->
|
|
143
|
-
Action.fail 422 "Username is required"
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
### Example: logout
|
|
147
|
-
|
|
148
|
-
```elm
|
|
149
|
-
action _ =
|
|
150
|
-
Action.redirect "/login"
|
|
151
|
-
|> Action.clearCookie { name = "session", path = Just "/", domain = Nothing }
|
|
152
|
-
```
|
|
153
|
-
|
|
154
|
-
### Reading cookies back
|
|
155
|
-
|
|
156
|
-
From the matching `page` (or any `Loader`), use `Loader.getCookie`:
|
|
157
|
-
|
|
158
|
-
```elm
|
|
159
|
-
page _ =
|
|
160
|
-
Loader.getCookie "session"
|
|
161
|
-
|> Loader.andThen
|
|
162
|
-
(\session ->
|
|
163
|
-
case session of
|
|
164
|
-
Just token ->
|
|
165
|
-
Loader.map renderDashboard (lookupUser token)
|
|
166
|
-
|
|
167
|
-
Nothing ->
|
|
168
|
-
Loader.succeed renderLoginPrompt
|
|
169
|
-
)
|
|
170
|
-
```
|
|
171
|
-
|
|
172
|
-
### Stacking multiple cookies
|
|
173
|
-
|
|
174
|
-
`setCookie` is composable — call it more than once to attach multiple
|
|
175
|
-
cookies to the same response:
|
|
176
|
-
|
|
177
|
-
```elm
|
|
178
|
-
Action.redirect "/onboarding/step-2"
|
|
179
|
-
|> Action.setCookie (Action.sessionCookie "session" token)
|
|
180
|
-
|> Action.setCookie ({ defaultCookie "onboarding" "step-2" | sameSite = Just Lax })
|
|
181
|
-
```
|
|
182
|
-
|
|
183
|
-
### Local dev gotcha
|
|
184
|
-
|
|
185
|
-
`Secure` cookies are rejected by modern browsers on plain `http://`. If
|
|
186
|
-
you're hitting your app on `http://localhost`, either run it over HTTPS
|
|
187
|
-
(wrangler dev does, by default) or override `secure = False` on the cookie.
|
|
188
|
-
See [examples/basic/src/Example/Basic/Routes/Session.elm](../examples/basic/src/Example/Basic/Routes/Session.elm)
|
|
189
|
-
for the pattern.
|
|
190
|
-
|
|
191
|
-
### Composition
|
|
192
|
-
|
|
193
|
-
```elm
|
|
194
|
-
Action.map : (a -> b) -> Action a -> Action b
|
|
195
|
-
Action.andThen : (a -> Action b) -> Action a -> Action b
|
|
196
|
-
|
|
197
|
-
-- Lift any Loader (and all its effects: cacheGet, query, execute, env, ...)
|
|
198
|
-
-- into an Action so it runs as part of the action's effect chain.
|
|
199
|
-
Action.fromLoader : Loader a -> Action a
|
|
200
|
-
```
|
|
201
|
-
|
|
202
|
-
`fromLoader` is how actions do server work — there's no separate "action
|
|
203
|
-
effect" type; the entire `Loader` effect vocabulary is reusable.
|
|
204
|
-
|
|
205
|
-
### Example: a guestbook POST (PRG pattern)
|
|
206
|
-
|
|
207
|
-
```elm
|
|
208
|
-
action : Request -> Action (Document Never)
|
|
209
|
-
action request =
|
|
210
|
-
case Route.formValue "message" request of
|
|
211
|
-
Nothing ->
|
|
212
|
-
Action.fail 422 "Message is required."
|
|
213
|
-
|
|
214
|
-
Just message ->
|
|
215
|
-
if String.isEmpty (String.trim message) then
|
|
216
|
-
Action.fail 422 "Message is required."
|
|
217
|
-
|
|
218
|
-
else
|
|
219
|
-
Action.fromLoader
|
|
220
|
-
(Loader.execute
|
|
221
|
-
{ sql = "INSERT INTO entries (message) VALUES (?)"
|
|
222
|
-
, params = [ Encode.string message ]
|
|
223
|
-
}
|
|
224
|
-
)
|
|
225
|
-
|> Action.andThen (\_ -> Action.redirect "/guestbook")
|
|
226
|
-
```
|
|
227
|
-
|
|
228
|
-
The client `POST /guestbook`s with the form, the row is inserted, the action
|
|
229
|
-
redirects with `303 See Other` to `/guestbook`, the browser issues a `GET`, the
|
|
230
|
-
page re-renders with the new entry. No JS required.
|
|
231
|
-
|
|
232
|
-
## Failures
|
|
233
|
-
|
|
234
|
-
Both `Loader.fail` and `Action.fail` take an HTTP status. The runtime turns
|
|
235
|
-
them into the matching response (with the message). Decode failures during
|
|
236
|
-
`fetchJson`/`cacheGet`/`query` map to `502` automatically.
|
|
237
|
-
|
|
238
|
-
## What next
|
|
239
|
-
|
|
240
|
-
- [Effects](effects.md) — the full effect surface and what each kind does.
|
|
241
|
-
- [Backends](backends.md) — choosing/composing the adapter that runs effects.
|
package/docs/middleware.md
DELETED
|
@@ -1,93 +0,0 @@
|
|
|
1
|
-
# Middleware
|
|
2
|
-
|
|
3
|
-
`createWorkerApp` wraps the request handler in a fixed stack of middlewares.
|
|
4
|
-
You usually don't need to touch this — the defaults give you request IDs,
|
|
5
|
-
timing headers, structured request logging, error normalization, and HEAD
|
|
6
|
-
support out of the box.
|
|
7
|
-
|
|
8
|
-
## Default stack
|
|
9
|
-
|
|
10
|
-
In order (outermost first):
|
|
11
|
-
|
|
12
|
-
1. **`errorMiddleware`** — catches anything thrown downstream, logs
|
|
13
|
-
`elm_ssr_request_failed { requestId, path, error }`, and returns
|
|
14
|
-
`{ "error": "internal_error", "requestId": ... }` (JSON, status 500) for
|
|
15
|
-
`/api/*` or plain `Internal Server Error` (status 500) for everything
|
|
16
|
-
else.
|
|
17
|
-
2. **`requestIdMiddleware`** — uses the incoming `x-request-id` header if
|
|
18
|
-
non-empty; otherwise generates a fresh UUID. Echoes the value back in the
|
|
19
|
-
response header.
|
|
20
|
-
3. **`timingMiddleware`** — adds `server-timing: app;dur=…` and
|
|
21
|
-
`x-response-time: …ms` based on `performance.now() - context.startedAt`.
|
|
22
|
-
4. **`loggingMiddleware`** — after the response is built, logs a JSON line
|
|
23
|
-
`{ event: "request_completed", requestId, method, path, status, durationMs }`.
|
|
24
|
-
On Cloudflare the logging is scheduled via `ctx.waitUntil` so it doesn't
|
|
25
|
-
block the response.
|
|
26
|
-
5. **`headMiddleware`** — for `HEAD` requests, strips the response body while
|
|
27
|
-
preserving status + headers.
|
|
28
|
-
|
|
29
|
-
## Composing your own
|
|
30
|
-
|
|
31
|
-
```ts
|
|
32
|
-
import {
|
|
33
|
-
composeMiddleware,
|
|
34
|
-
errorMiddleware,
|
|
35
|
-
requestIdMiddleware,
|
|
36
|
-
timingMiddleware,
|
|
37
|
-
loggingMiddleware,
|
|
38
|
-
headMiddleware,
|
|
39
|
-
type Middleware
|
|
40
|
-
} from "elm-ssr/middleware";
|
|
41
|
-
|
|
42
|
-
const authMiddleware: Middleware = async (context, next) => {
|
|
43
|
-
if (context.url.pathname.startsWith("/admin/")) {
|
|
44
|
-
const session = context.request.headers.get("cookie") ?? "";
|
|
45
|
-
if (!session.includes("admin=1")) {
|
|
46
|
-
return new Response("Forbidden", { status: 403 });
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
return next(context);
|
|
50
|
-
};
|
|
51
|
-
|
|
52
|
-
const handler = composeMiddleware(myRouteHandler, [
|
|
53
|
-
errorMiddleware,
|
|
54
|
-
requestIdMiddleware,
|
|
55
|
-
timingMiddleware,
|
|
56
|
-
loggingMiddleware(),
|
|
57
|
-
authMiddleware,
|
|
58
|
-
headMiddleware
|
|
59
|
-
]);
|
|
60
|
-
```
|
|
61
|
-
|
|
62
|
-
`composeMiddleware(handler, [a, b, c])` runs `a` outermost: `a → b → c →
|
|
63
|
-
handler → c → b → a`.
|
|
64
|
-
|
|
65
|
-
If you want to customize the **default** stack used by `createWorkerApp`,
|
|
66
|
-
you can't drop middlewares from it directly — pass your own `log` to override
|
|
67
|
-
the logger, or build your own `WorkerHandler` from scratch with
|
|
68
|
-
`composeMiddleware` if you need different ordering.
|
|
69
|
-
|
|
70
|
-
## `AppContext`
|
|
71
|
-
|
|
72
|
-
Every middleware and handler receives an `AppContext`:
|
|
73
|
-
|
|
74
|
-
```ts
|
|
75
|
-
interface AppContext {
|
|
76
|
-
request: Request;
|
|
77
|
-
url: URL;
|
|
78
|
-
requestId: string; // set by requestIdMiddleware (empty before)
|
|
79
|
-
startedAt: number; // performance.now() at request start
|
|
80
|
-
executionCtx?: WorkerExecutionContext; // present on Cloudflare; has waitUntil
|
|
81
|
-
env?: Record<string, unknown>; // Cloudflare bindings
|
|
82
|
-
}
|
|
83
|
-
```
|
|
84
|
-
|
|
85
|
-
`executionCtx?.waitUntil(promise)` is how the logging and task adapters
|
|
86
|
-
schedule work after the response. If absent (Bun, tests), middlewares fall
|
|
87
|
-
back to fire-and-forget.
|
|
88
|
-
|
|
89
|
-
## Source
|
|
90
|
-
|
|
91
|
-
- [packages/elm-ssr/src/middleware.ts](../packages/elm-ssr/src/middleware.ts)
|
|
92
|
-
- [packages/elm-ssr/src/http.ts](../packages/elm-ssr/src/http.ts) — types
|
|
93
|
-
- [packages/elm-ssr/src/app.ts](../packages/elm-ssr/src/app.ts) — default stack
|
package/docs/migrations.md
DELETED
|
@@ -1,143 +0,0 @@
|
|
|
1
|
-
# SQL migrations
|
|
2
|
-
|
|
3
|
-
A small, backend-neutral, transactional SQL-file migration runner. Files live
|
|
4
|
-
in a directory (e.g. `migrations/0001_init.sql`), get applied in alphabetical
|
|
5
|
-
order, and are tracked in `__elm_ssr_migrations(name PRIMARY KEY, applied_at)`.
|
|
6
|
-
|
|
7
|
-
The runner is also wired into the CLI as
|
|
8
|
-
[`elm-ssr migrate up|down|status`](cli.md).
|
|
9
|
-
|
|
10
|
-
## Files
|
|
11
|
-
|
|
12
|
-
```
|
|
13
|
-
migrations/
|
|
14
|
-
0001_init.sql
|
|
15
|
-
0001_init.down.sql # optional, paired for `migrate down`
|
|
16
|
-
0002_add_users.sql
|
|
17
|
-
0002_add_users.down.sql
|
|
18
|
-
```
|
|
19
|
-
|
|
20
|
-
- Numeric prefix → alphabetical sort matches creation order.
|
|
21
|
-
- `.down.sql` is optional but required if you want to revert that migration.
|
|
22
|
-
- The runner ignores any non-`.sql` files and ignores `.down.sql` on the up
|
|
23
|
-
pass.
|
|
24
|
-
|
|
25
|
-
## Safety
|
|
26
|
-
|
|
27
|
-
Each migration runs inside a single `BEGIN…COMMIT` transaction **together
|
|
28
|
-
with** its tracking insert. If the SQL fails, the transaction rolls back and
|
|
29
|
-
the tracking row is never written, so the next run picks up exactly where
|
|
30
|
-
this one stopped.
|
|
31
|
-
|
|
32
|
-
Backends with native transaction support can override this by providing
|
|
33
|
-
`runInTransaction(fn)` on the adapter. The Postgres adapter the CLI ships
|
|
34
|
-
uses `sql.begin(fn)` for proper transactional safety; SQLite uses raw
|
|
35
|
-
`BEGIN`/`COMMIT`.
|
|
36
|
-
|
|
37
|
-
## CLI
|
|
38
|
-
|
|
39
|
-
```sh
|
|
40
|
-
elm-ssr migrate up # Apply all pending
|
|
41
|
-
elm-ssr migrate down # Revert the most recent
|
|
42
|
-
elm-ssr migrate down --count 3 # Revert the last 3
|
|
43
|
-
elm-ssr migrate status # Show applied (with timestamps) + pending
|
|
44
|
-
|
|
45
|
-
# Connection string options:
|
|
46
|
-
elm-ssr migrate up --db postgres://user:pass@localhost:5432/db
|
|
47
|
-
elm-ssr migrate up --db sqlite://./app.db
|
|
48
|
-
elm-ssr migrate up --db ./app.db # bare path = SQLite
|
|
49
|
-
|
|
50
|
-
# Reads DATABASE_URL if --db is omitted.
|
|
51
|
-
DATABASE_URL=postgres://... elm-ssr migrate up
|
|
52
|
-
|
|
53
|
-
# Other flags:
|
|
54
|
-
elm-ssr migrate up --dir ./db/migrations # default: ./migrations
|
|
55
|
-
elm-ssr migrate up --table schema_history # default: __elm_ssr_migrations
|
|
56
|
-
```
|
|
57
|
-
|
|
58
|
-
## Programmatic API
|
|
59
|
-
|
|
60
|
-
```ts
|
|
61
|
-
import {
|
|
62
|
-
runMigrations,
|
|
63
|
-
revertMigrations,
|
|
64
|
-
listMigrations,
|
|
65
|
-
type MigrationsAdapter
|
|
66
|
-
} from "elm-ssr/migrations";
|
|
67
|
-
|
|
68
|
-
// Adapter shape — wire any backend:
|
|
69
|
-
interface MigrationsAdapter {
|
|
70
|
-
exec(sql: string): Promise<void>; // multi-statement, no params
|
|
71
|
-
list(sql: string): Promise<Array<Record<string, unknown>>>; // query
|
|
72
|
-
runInTransaction?(fn: () => Promise<void>): Promise<void>; // optional native txn
|
|
73
|
-
}
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
### bun:sqlite
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
import { Database } from "bun:sqlite";
|
|
80
|
-
|
|
81
|
-
const db = new Database("./app.db");
|
|
82
|
-
const adapter: MigrationsAdapter = {
|
|
83
|
-
exec: async (sql) => { db.exec(sql); },
|
|
84
|
-
list: async (sql) => db.query(sql).all() as Array<Record<string, unknown>>
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
await runMigrations(adapter, { dir: "./migrations" });
|
|
88
|
-
```
|
|
89
|
-
|
|
90
|
-
### Postgres (Bun.sql)
|
|
91
|
-
|
|
92
|
-
```ts
|
|
93
|
-
import { SQL } from "bun";
|
|
94
|
-
|
|
95
|
-
const sql = new SQL(process.env.DATABASE_URL!);
|
|
96
|
-
const adapter: MigrationsAdapter = {
|
|
97
|
-
exec: async (text) => { await sql.unsafe(text); },
|
|
98
|
-
list: async (text) => {
|
|
99
|
-
const rows = await sql.unsafe(text);
|
|
100
|
-
return Array.isArray(rows) ? rows : [...(rows ?? [])];
|
|
101
|
-
},
|
|
102
|
-
runInTransaction: async (fn) => { await sql.begin(fn); }
|
|
103
|
-
};
|
|
104
|
-
|
|
105
|
-
await runMigrations(adapter, { dir: "./migrations" });
|
|
106
|
-
```
|
|
107
|
-
|
|
108
|
-
### D1 (Cloudflare)
|
|
109
|
-
|
|
110
|
-
D1 doesn't expose multi-statement `exec` with `runInTransaction`, so use one
|
|
111
|
-
statement per migration file and run from a scheduled Worker or a build step.
|
|
112
|
-
|
|
113
|
-
## Return values
|
|
114
|
-
|
|
115
|
-
```ts
|
|
116
|
-
type MigrationResult = { applied: string[]; skipped: string[] };
|
|
117
|
-
type RevertResult = { reverted: string[] };
|
|
118
|
-
type MigrationsStatus = {
|
|
119
|
-
applied: Array<{ name: string; appliedAt: string }>;
|
|
120
|
-
pending: string[];
|
|
121
|
-
};
|
|
122
|
-
```
|
|
123
|
-
|
|
124
|
-
## What you can rely on
|
|
125
|
-
|
|
126
|
-
- **Idempotent.** Re-running `up` skips anything already in
|
|
127
|
-
`__elm_ssr_migrations`.
|
|
128
|
-
- **Forward-only by default.** `down` errors loudly if the paired
|
|
129
|
-
`.down.sql` is missing.
|
|
130
|
-
- **Safe failure.** A throwing migration rolls back (assuming a transactional
|
|
131
|
-
backend); the tracking row is never written.
|
|
132
|
-
- **Custom table name.** Pass `tableName` if `__elm_ssr_migrations` clashes
|
|
133
|
-
with anything. The name is validated against
|
|
134
|
-
`^[A-Za-z_][A-Za-z0-9_]*$` to prevent SQL injection via the option.
|
|
135
|
-
|
|
136
|
-
## Source
|
|
137
|
-
|
|
138
|
-
- [packages/elm-ssr/src/migrations.ts](../packages/elm-ssr/src/migrations.ts)
|
|
139
|
-
- [packages/elm-ssr/lib/migrate.mjs](../packages/elm-ssr/lib/migrate.mjs)
|
|
140
|
-
- Tests:
|
|
141
|
-
[test/migrations.test.ts](../test/migrations.test.ts),
|
|
142
|
-
[test/cli-migrate.test.ts](../test/cli-migrate.test.ts),
|
|
143
|
-
[test/integration/redis-postgres.test.ts](../test/integration/redis-postgres.test.ts)
|
package/docs/routing.md
DELETED
|
@@ -1,108 +0,0 @@
|
|
|
1
|
-
# Routing
|
|
2
|
-
|
|
3
|
-
Routing is file-based, Next/Remix-style. The CLI scans
|
|
4
|
-
`src/<Namespace>/Routes/` and generates a router that dispatches by URL path
|
|
5
|
-
and HTTP method.
|
|
6
|
-
|
|
7
|
-
## File layout → URL
|
|
8
|
-
|
|
9
|
-
| File | URL |
|
|
10
|
-
| ---- | --- |
|
|
11
|
-
| `Routes/Index.elm` | `/` |
|
|
12
|
-
| `Routes/About.elm` | `/about` |
|
|
13
|
-
| `Routes/Posts/Index.elm` | `/posts` |
|
|
14
|
-
| `Routes/Posts/Slug_.elm` | `/posts/:slug` (dynamic) |
|
|
15
|
-
| `Routes/Greet/Name_.elm` | `/greet/:name` |
|
|
16
|
-
| `Routes/NotFound.elm` | fallback for everything else |
|
|
17
|
-
|
|
18
|
-
Trailing-underscore names are **dynamic segments**, captured into
|
|
19
|
-
`Request.params` and read with `Route.param "slug"`.
|
|
20
|
-
|
|
21
|
-
## Every route exposes `page` and `action`
|
|
22
|
-
|
|
23
|
-
```elm
|
|
24
|
-
module Demo.Routes.About exposing (page, action)
|
|
25
|
-
|
|
26
|
-
import ElmSsr.Action as Action exposing (Action)
|
|
27
|
-
import ElmSsr.Document exposing (Document)
|
|
28
|
-
import ElmSsr.Html exposing (p, text)
|
|
29
|
-
import ElmSsr.Loader as Loader exposing (Loader)
|
|
30
|
-
import ElmSsr.Page as Page
|
|
31
|
-
import ElmSsr.Route exposing (Request)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
page : Request -> Loader (Document Never)
|
|
35
|
-
page _ =
|
|
36
|
-
Loader.succeed view
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
action : Request -> Action (Document Never)
|
|
40
|
-
action _ =
|
|
41
|
-
Action.fail 405 "Method not allowed"
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
view : Document Never
|
|
45
|
-
view =
|
|
46
|
-
Page.page
|
|
47
|
-
{ title = "About"
|
|
48
|
-
, head = []
|
|
49
|
-
, body = [ p [] [ text "About this app" ] ]
|
|
50
|
-
}
|
|
51
|
-
```
|
|
52
|
-
|
|
53
|
-
- `page` handles `GET` and `HEAD`. Returns a `Loader (Document Never)`.
|
|
54
|
-
- `action` handles `POST` (and other non-GET). Returns an
|
|
55
|
-
`Action (Document Never)`. Most pages reject non-GET with `Action.fail 405`.
|
|
56
|
-
|
|
57
|
-
See [Loaders and Actions](loaders-and-actions.md) for the data-fetching side.
|
|
58
|
-
|
|
59
|
-
## Reading the request
|
|
60
|
-
|
|
61
|
-
```elm
|
|
62
|
-
import ElmSsr.Route as Route exposing (Request)
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
page : Request -> Loader (Document Never)
|
|
66
|
-
page request =
|
|
67
|
-
case Route.param "slug" request of
|
|
68
|
-
Just slug ->
|
|
69
|
-
-- dynamic segment captured from the filename
|
|
70
|
-
loadPost slug
|
|
71
|
-
|
|
72
|
-
Nothing ->
|
|
73
|
-
Loader.fail 400 "Missing slug"
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
`ElmSsr.Route` exports:
|
|
77
|
-
- `method : Request -> String` — uppercased HTTP method.
|
|
78
|
-
- `segments : Request -> List String` — path split into non-empty parts.
|
|
79
|
-
- `query : String -> Request -> Maybe String` — query-string parameter.
|
|
80
|
-
- `param : String -> Request -> Maybe String` — dynamic route segment.
|
|
81
|
-
- `params : Request -> List ( String, String )` — all dynamic segments.
|
|
82
|
-
- `formValue : String -> Request -> Maybe String` — form field, for actions.
|
|
83
|
-
|
|
84
|
-
## NotFound
|
|
85
|
-
|
|
86
|
-
`Routes/NotFound.elm` is the fallback. Use `Page.notFound` (which sets the
|
|
87
|
-
right status) instead of `Page.page`:
|
|
88
|
-
|
|
89
|
-
```elm
|
|
90
|
-
view : Document Never
|
|
91
|
-
view =
|
|
92
|
-
Page.notFound
|
|
93
|
-
{ title = "Not found"
|
|
94
|
-
, head = []
|
|
95
|
-
, body = [ p [] [ text "This route does not exist." ] ]
|
|
96
|
-
}
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
If you don't ship a `NotFound.elm`, unknown routes get a plain text 404 from
|
|
100
|
-
the runtime.
|
|
101
|
-
|
|
102
|
-
## Tips
|
|
103
|
-
|
|
104
|
-
- Keep route modules small. Move shared markup into `View/Shared.elm`.
|
|
105
|
-
- For a static (no-data) page, `Loader.succeed view` is enough — no effects
|
|
106
|
-
are pumped.
|
|
107
|
-
- For interactive bits inside a page, embed an island. The page itself stays
|
|
108
|
-
SSR-only; only the island ships JS. See [Islands](islands.md).
|