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 +67 -0
- package/bin/elm-ssr.mjs +102 -0
- package/elm-src/ElmSsr/Action.elm +210 -0
- package/elm-src/ElmSsr/Document/Encode.elm +83 -0
- package/elm-src/ElmSsr/Document/Events.elm +125 -0
- package/elm-src/ElmSsr/Document.elm +26 -0
- package/elm-src/ElmSsr/Html/Attributes.elm +344 -0
- package/elm-src/ElmSsr/Html/Events.elm +95 -0
- package/elm-src/ElmSsr/Html.elm +706 -0
- package/elm-src/ElmSsr/Island/Shared.elm +38 -0
- package/elm-src/ElmSsr/Island.elm +49 -0
- package/elm-src/ElmSsr/Loader.elm +297 -0
- package/elm-src/ElmSsr/Page.elm +102 -0
- package/elm-src/ElmSsr/Route.elm +136 -0
- package/elm-src/ElmSsr/Runtime.elm +170 -0
- package/elm-src/ElmSsr/Svg/Attributes.elm +1208 -0
- package/elm-src/ElmSsr/Svg.elm +309 -0
- package/lib/build.mjs +256 -0
- package/lib/migrate.mjs +146 -0
- package/lib/scaffold.mjs +472 -0
- package/lib/workspace.mjs +21 -0
- package/package.json +60 -0
- package/src/app.ts +74 -0
- package/src/backends.ts +116 -0
- package/src/client-runtime/islands.ts +247 -0
- package/src/effects.ts +267 -0
- package/src/http.ts +86 -0
- package/src/middleware.ts +104 -0
- package/src/migrations.ts +225 -0
- package/src/protocol.ts +119 -0
- package/src/render.ts +111 -0
- package/src/request-handler.ts +208 -0
- package/src/response-headers.ts +18 -0
- package/src/serialize.ts +47 -0
- package/src/tasks.ts +139 -0
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.
|
package/bin/elm-ssr.mjs
ADDED
|
@@ -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
|
+
}
|