elm-ssr 0.1.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 ADDED
@@ -0,0 +1,67 @@
1
+ # elm-ssr
2
+
3
+ Elm-first SSR library and framework for Cloudflare Workers (and Bun locally).
4
+ One package, three pieces:
5
+
6
+ - A **CLI** (`elm-ssr build|new|migrate|dev`) that scans your routes/islands,
7
+ generates the router + manifest, and runs `elm make`.
8
+ - A **Worker runtime** (`createWorkerApp`, `renderApp`, effect adapters,
9
+ background tasks, SQL migrations, middleware) exported via subpaths.
10
+ - A set of **Elm authoring modules** under `elm-src/ElmSsr/` (Route, Loader,
11
+ Action, Html, Svg, Island, Page, Document, Runtime) which the build syncs
12
+ into each app's `.elm-ssr/src/ElmSsr/`.
13
+
14
+ ## Install
15
+
16
+ ```sh
17
+ bun add elm-ssr
18
+ ```
19
+
20
+ Then use the CLI as `elm-ssr <command>` (or `bun elm-ssr <command>` in a
21
+ workspace) and import the runtime via subpaths.
22
+
23
+ ## CLI commands
24
+
25
+ - **`elm-ssr build`** — scans each configured app's `src/<Namespace>/Routes/`
26
+ and `Islands/`, generates `.elm-ssr/Main.elm` (the router with file-based
27
+ routes + dynamic segments) and the islands manifest, syncs the Elm authoring
28
+ modules into the app's `.elm-ssr/src/ElmSsr/`, and compiles via `elm make`
29
+ (one combined island bundle exposing `Elm.<App>.Islands.<Name>`).
30
+ - **`elm-ssr new <name>`** — scaffold a new app and register it in
31
+ `elm-ssr.config.json`.
32
+ - **`elm-ssr dev`** — `build` then `wrangler dev`.
33
+ - **`elm-ssr compress`** — pre-compress generated bundles with gzip.
34
+ - **`elm-ssr migrate <up|down|status>`** — apply / revert / inspect SQL-file
35
+ migrations. `--db postgres://…`, `sqlite://path`, or a plain SQLite file path;
36
+ `--dir <path>` (default `./migrations`); `--count N` (for `down`, default 1).
37
+ Reads `DATABASE_URL` if `--db` is omitted.
38
+
39
+ Configuration lives in `elm-ssr.config.json` at the repo root:
40
+
41
+ ```jsonc
42
+ {
43
+ "apps": [
44
+ { "name": "basic", "root": "examples/basic", "module": "Example.Basic" }
45
+ ]
46
+ }
47
+ ```
48
+
49
+ ## Runtime exports
50
+
51
+ ```ts
52
+ import { createWorkerApp } from "elm-ssr";
53
+ import { renderApp, type CompiledElmModule } from "elm-ssr/render";
54
+ import type { RouteCatalog } from "elm-ssr/http";
55
+ import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";
56
+ import { withCache, redisCache, postgresSql } from "elm-ssr/backends";
57
+ import { withTasks, withQueueProducer, createQueueConsumer } from "elm-ssr/tasks";
58
+ import { runMigrations, revertMigrations, listMigrations } from "elm-ssr/migrations";
59
+ import { composeMiddleware } from "elm-ssr/middleware";
60
+ ```
61
+
62
+ See the [top-level README](../../README.md) for end-to-end usage and the
63
+ authoring guide.
64
+
65
+ ## License
66
+
67
+ MIT.
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { readFile } from "node:fs/promises";
4
+ import { resolve } from "node:path";
5
+ import { createExampleScaffold } from "../lib/scaffold.mjs";
6
+ import { readWorkspaceConfig } from "../lib/workspace.mjs";
7
+ import { build } from "../lib/build.mjs";
8
+ import { migrate } from "../lib/migrate.mjs";
9
+
10
+ const defaultRootPath = process.cwd();
11
+ const packageJsonPath = resolve(defaultRootPath, "package.json");
12
+
13
+ let packageJson = { name: "unknown" };
14
+ try {
15
+ packageJson = JSON.parse(await readFile(packageJsonPath, "utf8"));
16
+ } catch {
17
+ // Not in a package root, that's okay for some commands
18
+ }
19
+ const args = process.argv.slice(2);
20
+ const command = args[0] ?? "help";
21
+
22
+ const findFlagValue = (flagName) => {
23
+ const index = args.indexOf(flagName);
24
+ return index >= 0 ? args[index + 1] : undefined;
25
+ };
26
+
27
+ const rootPath = resolve(findFlagValue("--root") ?? defaultRootPath);
28
+
29
+ const run = async (cmd, cmdArgs, cwd = rootPath) => {
30
+ const child = Bun.spawn([cmd, ...cmdArgs], {
31
+ cwd,
32
+ stdout: "inherit",
33
+ stderr: "inherit",
34
+ stdin: "inherit",
35
+ env: process.env
36
+ });
37
+
38
+ const exitCode = await child.exited;
39
+
40
+ if (exitCode !== 0) {
41
+ process.exit(exitCode);
42
+ }
43
+ };
44
+
45
+ const printHelp = () => {
46
+ console.log(`elm-ssr commands
47
+
48
+ build Generate wrapper modules and compile configured Elm SSR apps
49
+ compress Pre-compress island and app bundles using Gzip for faster edge delivery
50
+ dev Build and start wrangler dev using the current workspace config
51
+ new <name> Create a new example app and register it in elm-ssr.config.json
52
+ routes Print configured apps and their public modules
53
+ info Print current workspace package and configured app names
54
+ migrate ... Apply / revert / inspect SQL migrations (see: elm-ssr migrate --help)
55
+ `);
56
+ };
57
+
58
+ const config = await readWorkspaceConfig(rootPath);
59
+
60
+ switch (command) {
61
+ case "build":
62
+ case "compress":
63
+ await build({ rootPath, config });
64
+ break;
65
+
66
+ case "dev":
67
+ await run("bun", ["run", "build"], rootPath);
68
+ await run("./node_modules/.bin/wrangler", ["dev"], rootPath);
69
+ break;
70
+
71
+ case "new": {
72
+ const name = args[1];
73
+
74
+ if (!name) {
75
+ console.error("Usage: elm-ssr new <name>");
76
+ process.exit(1);
77
+ }
78
+
79
+ const created = await createExampleScaffold(rootPath, name);
80
+ console.log(`Created ${created.name} at ${created.root}`);
81
+ break;
82
+ }
83
+
84
+ case "routes":
85
+ for (const app of config.apps) {
86
+ console.log(`${app.name}: root=${app.root} module=${app.module} routes=src/${app.module.split(".").join("/")}/Routes`);
87
+ }
88
+ break;
89
+
90
+ case "info":
91
+ console.log(`workspace: ${packageJson.name}`);
92
+ console.log(`apps: ${config.apps.map((app) => app.name).join(", ")}`);
93
+ break;
94
+
95
+ case "migrate":
96
+ await migrate(args.slice(1));
97
+ break;
98
+
99
+ default:
100
+ printHelp();
101
+ break;
102
+ }
@@ -0,0 +1,210 @@
1
+ module ElmSsr.Action exposing
2
+ ( Action
3
+ , succeed, fail, redirect, json
4
+ , map, andThen, fromLoader
5
+ , Effect, Step(..), step, encodeStep
6
+ )
7
+
8
+ {-| An `Action` describes what should happen in response to a non-GET request
9
+ (usually a `POST` form submission).
10
+
11
+ Like `Loader`, it is a description of work, not a side effect: the author
12
+ composes it, and the runtime interprets it — running any effects through the
13
+ Worker — until it resolves to a document, a redirect, a JSON body, or an error.
14
+
15
+ A typical form action validates `Route.formValue`s, performs an effect, then
16
+ redirects (the Post/Redirect/Get pattern):
17
+
18
+ action request =
19
+ case Route.formValue "email" request of
20
+ Nothing ->
21
+ Action.fail 422 "Email is required"
22
+
23
+ Just email ->
24
+ Action.fromLoader (saveSubscriber email)
25
+ |> Action.andThen (\_ -> Action.redirect "/thanks")
26
+
27
+
28
+ # Building actions
29
+
30
+ @docs Action
31
+ @docs succeed, fail, redirect, json
32
+ @docs map, andThen, fromLoader
33
+
34
+
35
+ # Runtime interpretation
36
+
37
+ Used by the elm-ssr runtime to drive an action. Application authors do not need
38
+ these.
39
+
40
+ @docs Effect, Step, step, encodeStep
41
+
42
+ -}
43
+
44
+ import ElmSsr.Loader as Loader exposing (Loader)
45
+ import Json.Decode as Decode
46
+ import Json.Encode as Encode
47
+
48
+
49
+ {-| A description of how to respond to a non-GET request. -}
50
+ type Action a
51
+ = Done a
52
+ | Failed Int String
53
+ | Redirect String
54
+ | JsonResult Encode.Value
55
+ | Pending Effect (Decode.Value -> Action a)
56
+
57
+
58
+ {-| A single side effect the Worker runs, addressed by `kind`. Shared with
59
+ [`ElmSsr.Loader`](./Loader.elm), so actions reuse the same effect vocabulary. -}
60
+ type alias Effect =
61
+ Loader.Effect
62
+
63
+
64
+ {-| An action that resolves to a value with no further work. -}
65
+ succeed : a -> Action a
66
+ succeed =
67
+ Done
68
+
69
+
70
+ {-| Fail an action with an HTTP status and message. -}
71
+ fail : Int -> String -> Action a
72
+ fail =
73
+ Failed
74
+
75
+
76
+ {-| Redirect the client to a new URL (303-style Post/Redirect/Get). -}
77
+ redirect : String -> Action a
78
+ redirect =
79
+ Redirect
80
+
81
+
82
+ {-| Respond with a JSON body directly. -}
83
+ json : Encode.Value -> Action a
84
+ json =
85
+ JsonResult
86
+
87
+
88
+ {-| Transform the value an action resolves to. -}
89
+ map : (a -> b) -> Action a -> Action b
90
+ map fn action =
91
+ case action of
92
+ Done value ->
93
+ Done (fn value)
94
+
95
+ Failed status message ->
96
+ Failed status message
97
+
98
+ Redirect url ->
99
+ Redirect url
100
+
101
+ JsonResult value ->
102
+ JsonResult value
103
+
104
+ Pending effect continue ->
105
+ Pending effect (\value -> map fn (continue value))
106
+
107
+
108
+ {-| Sequence actions: run a second step that depends on the first's result.
109
+ Effects run one after the other, each completing before the next begins. -}
110
+ andThen : (a -> Action b) -> Action a -> Action b
111
+ andThen fn action =
112
+ case action of
113
+ Done value ->
114
+ fn value
115
+
116
+ Failed status message ->
117
+ Failed status message
118
+
119
+ Redirect url ->
120
+ Redirect url
121
+
122
+ JsonResult value ->
123
+ JsonResult value
124
+
125
+ Pending effect continue ->
126
+ Pending effect (\value -> andThen fn (continue value))
127
+
128
+
129
+ {-| Lift a [`Loader`](./Loader.elm) into an action so its effects run as part of
130
+ the action. This is how an action does server work (fetch, KV, D1, …) before
131
+ deciding how to respond. -}
132
+ fromLoader : Loader a -> Action a
133
+ fromLoader loader =
134
+ case Loader.step loader of
135
+ Loader.Resolved value ->
136
+ Done value
137
+
138
+ Loader.Errored status message ->
139
+ Failed status message
140
+
141
+ Loader.Await effect continue ->
142
+ Pending effect (\value -> fromLoader (continue value))
143
+
144
+
145
+ {-| One observable step of an action. The runtime resumes a pending action by
146
+ calling the continuation with the effect result. -}
147
+ type Step a
148
+ = Resolved a
149
+ | Errored Int String
150
+ | Moved String
151
+ | SentJson Encode.Value
152
+ | Await Effect (Decode.Value -> Action a)
153
+
154
+
155
+ {-| Inspect the next step of an action. -}
156
+ step : Action a -> Step a
157
+ step action =
158
+ case action of
159
+ Done value ->
160
+ Resolved value
161
+
162
+ Failed status message ->
163
+ Errored status message
164
+
165
+ Redirect url ->
166
+ Moved url
167
+
168
+ JsonResult value ->
169
+ SentJson value
170
+
171
+ Pending effect continue ->
172
+ Await effect continue
173
+
174
+
175
+ {-| Encode a terminal step for the Worker runtime. `Await` is never encoded —
176
+ the runtime runs its effect first — so it is reported defensively as an error. -}
177
+ encodeStep : (a -> Encode.Value) -> Step a -> Encode.Value
178
+ encodeStep encoder step_ =
179
+ case step_ of
180
+ Resolved value ->
181
+ Encode.object
182
+ [ ( "kind", Encode.string "resolved" )
183
+ , ( "value", encoder value )
184
+ ]
185
+
186
+ Errored status message ->
187
+ Encode.object
188
+ [ ( "kind", Encode.string "errored" )
189
+ , ( "status", Encode.int status )
190
+ , ( "message", Encode.string message )
191
+ ]
192
+
193
+ Moved url ->
194
+ Encode.object
195
+ [ ( "kind", Encode.string "redirect" )
196
+ , ( "url", Encode.string url )
197
+ ]
198
+
199
+ SentJson value ->
200
+ Encode.object
201
+ [ ( "kind", Encode.string "json" )
202
+ , ( "value", value )
203
+ ]
204
+
205
+ Await _ _ ->
206
+ Encode.object
207
+ [ ( "kind", Encode.string "errored" )
208
+ , ( "status", Encode.int 500 )
209
+ , ( "message", Encode.string "Action step was not resolved before encoding." )
210
+ ]
@@ -0,0 +1,83 @@
1
+ module ElmSsr.Document.Encode exposing (encode)
2
+
3
+ {-| Serialize a document to the JSON the JS runtime renders and patches.
4
+
5
+ @docs encode
6
+
7
+ -}
8
+
9
+ import ElmSsr.Document exposing (Document)
10
+ import ElmSsr.Document.Events as Events
11
+ import ElmSsr.Html exposing (Attribute(..), EventCapture(..), Node(..))
12
+ import Json.Encode as Encode
13
+
14
+
15
+ encode : Document msg -> Encode.Value
16
+ encode document =
17
+ Encode.object
18
+ [ ( "status", Encode.int document.status )
19
+ , ( "lang", Encode.string document.lang )
20
+ , ( "hasIslands", Encode.bool document.hasIslands )
21
+ , ( "head", Encode.list identity (List.indexedMap (\index node -> encodeNode [ index ] node) document.head) )
22
+ , ( "body", Encode.list identity (List.indexedMap (\index node -> encodeNode [ index ] node) document.body) )
23
+ ]
24
+
25
+
26
+ encodeNode : List Int -> Node msg -> Encode.Value
27
+ encodeNode path node =
28
+ case node of
29
+ Element tag attributes children ->
30
+ Encode.object
31
+ [ ( "kind", Encode.string "element" )
32
+ , ( "tag", Encode.string tag )
33
+ , ( "attrs", Encode.list identity (List.map (encodeAttribute path) attributes) )
34
+ , ( "children", Encode.list identity (List.indexedMap (\index child -> encodeNode (path ++ [ index ]) child) children) )
35
+ ]
36
+
37
+ VoidElement tag attributes ->
38
+ Encode.object
39
+ [ ( "kind", Encode.string "void" )
40
+ , ( "tag", Encode.string tag )
41
+ , ( "attrs", Encode.list identity (List.map (encodeAttribute path) attributes) )
42
+ ]
43
+
44
+ Text content ->
45
+ Encode.object
46
+ [ ( "kind", Encode.string "text" )
47
+ , ( "text", Encode.string content )
48
+ ]
49
+
50
+
51
+ encodeAttribute : List Int -> Attribute msg -> Encode.Value
52
+ encodeAttribute path attribute =
53
+ case attribute of
54
+ Property name value ->
55
+ Encode.object
56
+ [ ( "kind", Encode.string "attribute" )
57
+ , ( "name", Encode.string name )
58
+ , ( "value", Encode.string value )
59
+ ]
60
+
61
+ EventHandler name capture _ ->
62
+ Encode.object
63
+ [ ( "kind", Encode.string "event" )
64
+ , ( "name", Encode.string name )
65
+ , ( "payload"
66
+ , Events.encodeEventRef
67
+ { path = path
68
+ , event = name
69
+ , value = Nothing
70
+ }
71
+ )
72
+ , ( "capture", encodeEventCapture capture )
73
+ ]
74
+
75
+
76
+ encodeEventCapture : EventCapture -> Encode.Value
77
+ encodeEventCapture capture =
78
+ case capture of
79
+ NoEventData ->
80
+ Encode.string "none"
81
+
82
+ TargetValue ->
83
+ Encode.string "value"
@@ -0,0 +1,125 @@
1
+ module ElmSsr.Document.Events exposing (EventRef, decodeEventRef, encodeEventRef, findMessage)
2
+
3
+ {-| Browser events are bridged without serializing `Msg`. An event handler is
4
+ rendered as an `EventRef` (a DOM path + event name); when the browser reports an
5
+ event, the runtime looks the message back up against the current view with
6
+ [`findMessage`](#findMessage).
7
+
8
+ @docs EventRef, decodeEventRef, encodeEventRef, findMessage
9
+
10
+ -}
11
+
12
+ import ElmSsr.Document exposing (Document)
13
+ import ElmSsr.Html exposing (Attribute(..), EventValue(..), Node(..))
14
+ import Json.Decode as Decode
15
+ import Json.Encode as Encode
16
+
17
+
18
+ type alias EventRef =
19
+ { path : List Int
20
+ , event : String
21
+ , value : Maybe String
22
+ }
23
+
24
+
25
+ encodeEventRef : EventRef -> Encode.Value
26
+ encodeEventRef eventRef =
27
+ Encode.object
28
+ [ ( "path", Encode.list Encode.int eventRef.path )
29
+ , ( "event", Encode.string eventRef.event )
30
+ , ( "value", maybeString eventRef.value )
31
+ ]
32
+
33
+
34
+ decodeEventRef : Decode.Decoder EventRef
35
+ decodeEventRef =
36
+ Decode.map3 EventRef
37
+ (Decode.field "path" (Decode.list Decode.int))
38
+ (Decode.field "event" Decode.string)
39
+ (Decode.maybe (Decode.field "value" Decode.string))
40
+
41
+
42
+ maybeString : Maybe String -> Encode.Value
43
+ maybeString maybeValue =
44
+ case maybeValue of
45
+ Just value ->
46
+ Encode.string value
47
+
48
+ Nothing ->
49
+ Encode.null
50
+
51
+
52
+ findMessage : EventRef -> Document msg -> Maybe msg
53
+ findMessage eventRef document =
54
+ document.body
55
+ |> getNode eventRef.path
56
+ |> Maybe.andThen (findMessageOnNode eventRef)
57
+
58
+
59
+ getNode : List Int -> List (Node msg) -> Maybe (Node msg)
60
+ getNode path nodes =
61
+ case path of
62
+ [] ->
63
+ Nothing
64
+
65
+ index :: rest ->
66
+ case List.drop index nodes |> List.head of
67
+ Just node ->
68
+ if List.isEmpty rest then
69
+ Just node
70
+
71
+ else
72
+ case node of
73
+ Element _ _ children ->
74
+ getNode rest children
75
+
76
+ VoidElement _ _ ->
77
+ Nothing
78
+
79
+ Text _ ->
80
+ Nothing
81
+
82
+ Nothing ->
83
+ Nothing
84
+
85
+
86
+ findMessageOnNode : EventRef -> Node msg -> Maybe msg
87
+ findMessageOnNode eventRef node =
88
+ case node of
89
+ Element _ attributes _ ->
90
+ findMessageOnAttributes eventRef attributes
91
+
92
+ VoidElement _ attributes ->
93
+ findMessageOnAttributes eventRef attributes
94
+
95
+ Text _ ->
96
+ Nothing
97
+
98
+
99
+ findMessageOnAttributes : EventRef -> List (Attribute msg) -> Maybe msg
100
+ findMessageOnAttributes eventRef attributes =
101
+ case attributes of
102
+ [] ->
103
+ Nothing
104
+
105
+ attribute :: rest ->
106
+ case attribute of
107
+ EventHandler name _ toMessage ->
108
+ if name == eventRef.event then
109
+ Just (toMessage (eventValueFromRef eventRef))
110
+
111
+ else
112
+ findMessageOnAttributes eventRef rest
113
+
114
+ Property _ _ ->
115
+ findMessageOnAttributes eventRef rest
116
+
117
+
118
+ eventValueFromRef : EventRef -> EventValue
119
+ eventValueFromRef eventRef =
120
+ case eventRef.value of
121
+ Just value ->
122
+ StringValue value
123
+
124
+ Nothing ->
125
+ NoValue
@@ -0,0 +1,26 @@
1
+ module ElmSsr.Document exposing (Document, map)
2
+
3
+ import ElmSsr.Html as Html exposing (Node)
4
+
5
+
6
+ type alias Document msg =
7
+ { status : Int
8
+ , lang : String
9
+ , hasIslands : Bool
10
+ , head : List (Node msg)
11
+ , body : List (Node msg)
12
+ }
13
+
14
+
15
+ {-| Reinterpret the message type of a document. Used to lift a stateless
16
+ `Document Never` (a page that cannot emit events) into the runtime's message
17
+ type via `Document.map never`.
18
+ -}
19
+ map : (a -> b) -> Document a -> Document b
20
+ map fn document =
21
+ { status = document.status
22
+ , lang = document.lang
23
+ , hasIslands = document.hasIslands
24
+ , head = List.map (Html.mapNode fn) document.head
25
+ , body = List.map (Html.mapNode fn) document.body
26
+ }