elm-ssr 0.6.1 → 0.90.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/bin/elm-ssr.mjs +83 -3
- package/elm-src/ElmSsr/Action.elm +19 -1
- package/elm-src/ElmSsr/Db/Dsl.elm +223 -0
- package/elm-src/ElmSsr/Loader.elm +33 -1
- package/elm-src/ElmSsr/Request/Decode.elm +478 -0
- package/elm-src/ElmSsr/Runtime.elm +3 -0
- package/lib/build.mjs +66 -1
- package/lib/query.mjs +464 -0
- package/lib/scaffold.mjs +156 -0
- package/package.json +1 -1
- package/src/app.ts +8 -2
- package/src/client-runtime/islands.ts +71 -4
- package/src/debugger.ts +535 -0
- package/src/effects.ts +8 -0
- package/src/request-handler.ts +38 -8
package/bin/elm-ssr.mjs
CHANGED
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
import { readFile, stat } from "node:fs/promises";
|
|
4
4
|
import { watch } from "node:fs";
|
|
5
5
|
import { dirname, resolve } from "node:path";
|
|
6
|
-
import { createAppScaffold } from "../lib/scaffold.mjs";
|
|
6
|
+
import { createAppScaffold, createRouteScaffold } from "../lib/scaffold.mjs";
|
|
7
7
|
import { readWorkspaceConfig } from "../lib/workspace.mjs";
|
|
8
8
|
import { build } from "../lib/build.mjs";
|
|
9
9
|
import { migrate } from "../lib/migrate.mjs";
|
|
10
|
+
import { generateQueries } from "../lib/query.mjs";
|
|
10
11
|
|
|
11
12
|
const defaultRootPath = process.cwd();
|
|
12
13
|
const args = process.argv.slice(2);
|
|
@@ -36,7 +37,14 @@ const findWorkspaceRoot = async (startPath) => {
|
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
const userRoot = findFlagValue("--root");
|
|
39
|
-
|
|
40
|
+
let rootPath;
|
|
41
|
+
if (userRoot) {
|
|
42
|
+
rootPath = resolve(userRoot);
|
|
43
|
+
} else if (["new", "init", "migrate", "help"].includes(command)) {
|
|
44
|
+
rootPath = defaultRootPath;
|
|
45
|
+
} else {
|
|
46
|
+
rootPath = resolve(await findWorkspaceRoot(defaultRootPath));
|
|
47
|
+
}
|
|
40
48
|
const packageJsonPath = resolve(rootPath, "package.json");
|
|
41
49
|
|
|
42
50
|
let packageJson = { name: "unknown" };
|
|
@@ -72,6 +80,8 @@ const printHelp = () => {
|
|
|
72
80
|
new <name> Create a new app at <workspace>/<name>/ (or <workspace>/<subdir>/<name>/
|
|
73
81
|
with --in <subdir>) and register it in elm-ssr.config.json
|
|
74
82
|
routes Print configured apps and their public modules
|
|
83
|
+
route <path> Scaffold a new route (standard HTML, --api JSON, --ws WebSocket, or --sse EventSource)
|
|
84
|
+
query Generate type-safe Elm Db modules from SQL table definitions
|
|
75
85
|
info Print current workspace package and configured app names
|
|
76
86
|
migrate ... Apply / revert / inspect SQL migrations (see: elm-ssr migrate --help)
|
|
77
87
|
`);
|
|
@@ -128,7 +138,7 @@ switch (command) {
|
|
|
128
138
|
const srcDir = resolve(rootPath, app.root, "src");
|
|
129
139
|
try {
|
|
130
140
|
const watcher = watch(srcDir, { recursive: true }, (eventType, filename) => {
|
|
131
|
-
if (filename && filename.endsWith(".elm")) {
|
|
141
|
+
if (filename && (filename.endsWith(".elm") || filename.endsWith(".css"))) {
|
|
132
142
|
triggerBuild();
|
|
133
143
|
}
|
|
134
144
|
});
|
|
@@ -218,12 +228,82 @@ switch (command) {
|
|
|
218
228
|
}
|
|
219
229
|
break;
|
|
220
230
|
|
|
231
|
+
case "route": {
|
|
232
|
+
requireConfig();
|
|
233
|
+
const routePath = args[1];
|
|
234
|
+
if (!routePath) {
|
|
235
|
+
console.error("Usage: elm-ssr route <path> [--app <app-name>] [--api] [--ws] [--sse]");
|
|
236
|
+
process.exit(1);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const appName = findFlagValue("--app");
|
|
240
|
+
let appConfig;
|
|
241
|
+
if (appName) {
|
|
242
|
+
appConfig = config.apps.find(a => a.name === appName);
|
|
243
|
+
if (!appConfig) {
|
|
244
|
+
console.error(`Error: App "${appName}" not found in elm-ssr.config.json`);
|
|
245
|
+
process.exit(1);
|
|
246
|
+
}
|
|
247
|
+
} else {
|
|
248
|
+
if (config.apps.length === 1) {
|
|
249
|
+
appConfig = config.apps[0];
|
|
250
|
+
} else {
|
|
251
|
+
console.error("Error: Multiple apps found in workspace config. Please specify which app to add the route to using --app <app-name>");
|
|
252
|
+
console.error("Available apps:", config.apps.map(a => a.name).join(", "));
|
|
253
|
+
process.exit(1);
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const isApi = args.includes("--api");
|
|
258
|
+
const isWs = args.includes("--ws") || args.includes("--websocket");
|
|
259
|
+
const isSse = args.includes("--sse");
|
|
260
|
+
|
|
261
|
+
const result = await createRouteScaffold(rootPath, appConfig, routePath, { isApi, isWs, isSse });
|
|
262
|
+
console.log(`Scaffolded ${result.type} route at ${result.path}`);
|
|
263
|
+
if (result.instructions) {
|
|
264
|
+
console.log("\nInstructions to wire it up:\n" + result.instructions);
|
|
265
|
+
}
|
|
266
|
+
break;
|
|
267
|
+
}
|
|
268
|
+
|
|
221
269
|
case "info":
|
|
222
270
|
requireConfig();
|
|
223
271
|
console.log(`workspace: ${packageJson.name}`);
|
|
224
272
|
console.log(`apps: ${config.apps.map((app) => app.name).join(", ")}`);
|
|
225
273
|
break;
|
|
226
274
|
|
|
275
|
+
case "query": {
|
|
276
|
+
requireConfig();
|
|
277
|
+
const appName = findFlagValue("--app");
|
|
278
|
+
let appConfig;
|
|
279
|
+
if (appName) {
|
|
280
|
+
appConfig = config.apps.find(a => a.name === appName);
|
|
281
|
+
if (!appConfig) {
|
|
282
|
+
console.error(`Error: App "${appName}" not found in elm-ssr.config.json`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
if (config.apps.length === 1) {
|
|
287
|
+
appConfig = config.apps[0];
|
|
288
|
+
} else {
|
|
289
|
+
console.error("Error: Multiple apps found in workspace config. Please specify which app to query using --app <app-name>");
|
|
290
|
+
console.error("Available apps:", config.apps.map(a => a.name).join(", "));
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const migrationsDir = findFlagValue("--dir");
|
|
296
|
+
const outputDir = findFlagValue("--output");
|
|
297
|
+
|
|
298
|
+
try {
|
|
299
|
+
await generateQueries({ rootPath, appConfig, migrationsDir, outputDir });
|
|
300
|
+
} catch (err) {
|
|
301
|
+
console.error(`Error generating query modules: ${err.message}`);
|
|
302
|
+
process.exit(1);
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
|
|
227
307
|
case "migrate":
|
|
228
308
|
await migrate(args.slice(1));
|
|
229
309
|
break;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module ElmSsr.Action exposing
|
|
2
2
|
( Action
|
|
3
|
-
, succeed, fail, redirect, json
|
|
3
|
+
, succeed, fail, redirect, json, requireUser
|
|
4
4
|
, map, andThen, fromLoader
|
|
5
5
|
, Cookie, SameSite(..), setCookie, clearCookie, defaultCookie, sessionCookie
|
|
6
6
|
, Effect, Step(..), step, collectCookies, encodeStep, encodeCookies
|
|
@@ -225,6 +225,21 @@ json =
|
|
|
225
225
|
JsonResult
|
|
226
226
|
|
|
227
227
|
|
|
228
|
+
{-| Require a user session for an action. If missing, redirect to the login path. -}
|
|
229
|
+
requireUser : Decode.Decoder user -> String -> (user -> Action a) -> Action a
|
|
230
|
+
requireUser userDecoder loginPath toAction =
|
|
231
|
+
fromLoader (Loader.session userDecoder)
|
|
232
|
+
|> andThen
|
|
233
|
+
(\maybeUser ->
|
|
234
|
+
case maybeUser of
|
|
235
|
+
Just user ->
|
|
236
|
+
toAction user
|
|
237
|
+
|
|
238
|
+
Nothing ->
|
|
239
|
+
redirect loginPath
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
|
|
228
243
|
{-| Transform the value an action resolves to. -}
|
|
229
244
|
map : (a -> b) -> Action a -> Action b
|
|
230
245
|
map fn action =
|
|
@@ -284,6 +299,9 @@ fromLoader loader =
|
|
|
284
299
|
Loader.Errored status message ->
|
|
285
300
|
Failed status message
|
|
286
301
|
|
|
302
|
+
Loader.Moved url ->
|
|
303
|
+
Redirect url
|
|
304
|
+
|
|
287
305
|
Loader.Await effect continue ->
|
|
288
306
|
Pending effect (\value -> fromLoader (continue value))
|
|
289
307
|
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
module ElmSsr.Db.Dsl exposing
|
|
2
|
+
( Table, table
|
|
3
|
+
, Column, column
|
|
4
|
+
, AnyColumn, col
|
|
5
|
+
, Expression
|
|
6
|
+
, Query
|
|
7
|
+
, select, selectAll
|
|
8
|
+
, where_, limit
|
|
9
|
+
, eq, neq, gt, gte, lt, lte, like, inList
|
|
10
|
+
, isNull, isNotNull
|
|
11
|
+
, and, or
|
|
12
|
+
, toLoader, toLoaderOne
|
|
13
|
+
, compileQuery
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
import Json.Decode as Decode exposing (Decoder)
|
|
17
|
+
import Json.Encode as Encode
|
|
18
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
type Table table
|
|
22
|
+
= Table String
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
table : String -> Table table
|
|
26
|
+
table name =
|
|
27
|
+
Table name
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
type Column table val
|
|
31
|
+
= Column String (val -> Encode.Value)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
column : String -> (val -> Encode.Value) -> Column table val
|
|
35
|
+
column name encoder =
|
|
36
|
+
Column name encoder
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
type AnyColumn table
|
|
40
|
+
= AnyColumn String
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
col : Column table val -> AnyColumn table
|
|
44
|
+
col (Column name _) =
|
|
45
|
+
AnyColumn name
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
type Expression table
|
|
49
|
+
= Expression String (List Encode.Value)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
type Query table a
|
|
53
|
+
= Query
|
|
54
|
+
{ tableName : String
|
|
55
|
+
, selection : List String
|
|
56
|
+
, conditions : List (Expression table)
|
|
57
|
+
, limitVal : Maybe Int
|
|
58
|
+
, decoder : Decoder a
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
eq : val -> Column table val -> Expression table
|
|
63
|
+
eq val (Column colName encoder) =
|
|
64
|
+
Expression (colName ++ " = ?") [ encoder val ]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
neq : val -> Column table val -> Expression table
|
|
68
|
+
neq val (Column colName encoder) =
|
|
69
|
+
Expression (colName ++ " != ?") [ encoder val ]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
gt : val -> Column table val -> Expression table
|
|
73
|
+
gt val (Column colName encoder) =
|
|
74
|
+
Expression (colName ++ " > ?") [ encoder val ]
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
gte : val -> Column table val -> Expression table
|
|
78
|
+
gte val (Column colName encoder) =
|
|
79
|
+
Expression (colName ++ " >= ?") [ encoder val ]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
lt : val -> Column table val -> Expression table
|
|
83
|
+
lt val (Column colName encoder) =
|
|
84
|
+
Expression (colName ++ " < ?") [ encoder val ]
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
lte : val -> Column table val -> Expression table
|
|
88
|
+
lte val (Column colName encoder) =
|
|
89
|
+
Expression (colName ++ " <= ?") [ encoder val ]
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
like : String -> Column table val -> Expression table
|
|
93
|
+
like search (Column colName _) =
|
|
94
|
+
Expression (colName ++ " LIKE ?") [ Encode.string search ]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
inList : List val -> Column table val -> Expression table
|
|
98
|
+
inList values (Column colName encoder) =
|
|
99
|
+
let
|
|
100
|
+
placeholders =
|
|
101
|
+
List.map (\_ -> "?") values |> String.join ", "
|
|
102
|
+
in
|
|
103
|
+
Expression (colName ++ " IN (" ++ placeholders ++ ")") (List.map encoder values)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
isNull : Column table val -> Expression table
|
|
107
|
+
isNull (Column colName _) =
|
|
108
|
+
Expression (colName ++ " IS NULL") []
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
isNotNull : Column table val -> Expression table
|
|
112
|
+
isNotNull (Column colName _) =
|
|
113
|
+
Expression (colName ++ " IS NOT NULL") []
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
and : Expression table -> Expression table -> Expression table
|
|
117
|
+
and (Expression sql1 params1) (Expression sql2 params2) =
|
|
118
|
+
Expression ("(" ++ sql1 ++ " AND " ++ sql2 ++ ")") (params1 ++ params2)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
or : Expression table -> Expression table -> Expression table
|
|
122
|
+
or (Expression sql1 params1) (Expression sql2 params2) =
|
|
123
|
+
Expression ("(" ++ sql1 ++ " OR " ++ sql2 ++ ")") (params1 ++ params2)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
selectAll : Table table -> Decoder a -> Query table a
|
|
127
|
+
selectAll (Table tableName) dec =
|
|
128
|
+
Query
|
|
129
|
+
{ tableName = tableName
|
|
130
|
+
, selection = []
|
|
131
|
+
, conditions = []
|
|
132
|
+
, limitVal = Nothing
|
|
133
|
+
, decoder = dec
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
select : Table table -> List (AnyColumn table) -> Decoder a -> Query table a
|
|
138
|
+
select (Table tableName) columns dec =
|
|
139
|
+
let
|
|
140
|
+
selects =
|
|
141
|
+
List.map (\(AnyColumn colName) -> colName) columns
|
|
142
|
+
in
|
|
143
|
+
Query
|
|
144
|
+
{ tableName = tableName
|
|
145
|
+
, selection = selects
|
|
146
|
+
, conditions = []
|
|
147
|
+
, limitVal = Nothing
|
|
148
|
+
, decoder = dec
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
where_ : Expression table -> Query table a -> Query table a
|
|
153
|
+
where_ cond (Query q) =
|
|
154
|
+
Query { q | conditions = q.conditions ++ [ cond ] }
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
limit : Int -> Query table a -> Query table a
|
|
158
|
+
limit val (Query q) =
|
|
159
|
+
Query { q | limitVal = Just val }
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
compileQuery : Query table a -> { sql : String, params : List Encode.Value, decoder : Decoder a }
|
|
163
|
+
compileQuery (Query q) =
|
|
164
|
+
let
|
|
165
|
+
selectsStr =
|
|
166
|
+
if List.isEmpty q.selection then
|
|
167
|
+
"*"
|
|
168
|
+
|
|
169
|
+
else
|
|
170
|
+
String.join ", " q.selection
|
|
171
|
+
|
|
172
|
+
baseSql =
|
|
173
|
+
"SELECT " ++ selectsStr ++ " FROM " ++ q.tableName
|
|
174
|
+
|
|
175
|
+
( whereSql, params ) =
|
|
176
|
+
if List.isEmpty q.conditions then
|
|
177
|
+
( "", [] )
|
|
178
|
+
|
|
179
|
+
else
|
|
180
|
+
let
|
|
181
|
+
sqls =
|
|
182
|
+
List.map (\(Expression sql _) -> sql) q.conditions
|
|
183
|
+
|> String.join " AND"
|
|
184
|
+
-- Note: we join multi-where conditions with AND
|
|
185
|
+
-- and wrap the expressions in parentheses if needed,
|
|
186
|
+
-- but a simple join with AND works since operators have higher precedence.
|
|
187
|
+
|
|
188
|
+
vals =
|
|
189
|
+
List.map (\(Expression _ valsList) -> valsList) q.conditions
|
|
190
|
+
|> List.concat
|
|
191
|
+
in
|
|
192
|
+
( " WHERE " ++ sqls, vals )
|
|
193
|
+
|
|
194
|
+
limitSql =
|
|
195
|
+
case q.limitVal of
|
|
196
|
+
Just l ->
|
|
197
|
+
" LIMIT " ++ String.fromInt l
|
|
198
|
+
|
|
199
|
+
Nothing ->
|
|
200
|
+
""
|
|
201
|
+
in
|
|
202
|
+
{ sql = baseSql ++ whereSql ++ limitSql
|
|
203
|
+
, params = params
|
|
204
|
+
, decoder = q.decoder
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
toLoader : Query table a -> Loader (List a)
|
|
209
|
+
toLoader q =
|
|
210
|
+
let
|
|
211
|
+
c =
|
|
212
|
+
compileQuery q
|
|
213
|
+
in
|
|
214
|
+
Loader.query { sql = c.sql, params = c.params, decoder = c.decoder }
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
toLoaderOne : Query table a -> Loader (Maybe a)
|
|
218
|
+
toLoaderOne q =
|
|
219
|
+
let
|
|
220
|
+
c =
|
|
221
|
+
compileQuery q
|
|
222
|
+
in
|
|
223
|
+
Loader.queryOne { sql = c.sql, params = c.params, decoder = c.decoder }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module ElmSsr.Loader exposing
|
|
2
2
|
( Loader
|
|
3
|
-
, succeed, fail
|
|
3
|
+
, succeed, fail, redirect, requireUser
|
|
4
4
|
, map, map2, andThen
|
|
5
5
|
, fetchJson
|
|
6
6
|
, cacheGet, cachePut
|
|
@@ -89,6 +89,7 @@ import Json.Encode as Encode
|
|
|
89
89
|
type Loader a
|
|
90
90
|
= Done a
|
|
91
91
|
| Failed Int String
|
|
92
|
+
| Redirect String
|
|
92
93
|
| Pending Effect (Decode.Value -> Loader a)
|
|
93
94
|
|
|
94
95
|
|
|
@@ -113,6 +114,27 @@ fail status message =
|
|
|
113
114
|
Failed status message
|
|
114
115
|
|
|
115
116
|
|
|
117
|
+
{-| Redirect the browser progressively to a same-origin or external URL. -}
|
|
118
|
+
redirect : String -> Loader a
|
|
119
|
+
redirect url =
|
|
120
|
+
Redirect url
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
{-| Require a user session. If missing, redirect to the login path. -}
|
|
124
|
+
requireUser : Decoder user -> String -> (user -> Loader a) -> Loader a
|
|
125
|
+
requireUser userDecoder loginPath toLoader =
|
|
126
|
+
session userDecoder
|
|
127
|
+
|> andThen
|
|
128
|
+
(\maybeUser ->
|
|
129
|
+
case maybeUser of
|
|
130
|
+
Just user ->
|
|
131
|
+
toLoader user
|
|
132
|
+
|
|
133
|
+
Nothing ->
|
|
134
|
+
redirect loginPath
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
|
|
116
138
|
{-| Transform the value a loader resolves to. -}
|
|
117
139
|
map : (a -> b) -> Loader a -> Loader b
|
|
118
140
|
map fn loader =
|
|
@@ -123,6 +145,9 @@ map fn loader =
|
|
|
123
145
|
Failed status message ->
|
|
124
146
|
Failed status message
|
|
125
147
|
|
|
148
|
+
Redirect url ->
|
|
149
|
+
Redirect url
|
|
150
|
+
|
|
126
151
|
Pending effect continue ->
|
|
127
152
|
Pending effect (\value -> map fn (continue value))
|
|
128
153
|
|
|
@@ -139,6 +164,9 @@ andThen fn loader =
|
|
|
139
164
|
Failed status message ->
|
|
140
165
|
Failed status message
|
|
141
166
|
|
|
167
|
+
Redirect url ->
|
|
168
|
+
Redirect url
|
|
169
|
+
|
|
142
170
|
Pending effect continue ->
|
|
143
171
|
Pending effect (\value -> andThen fn (continue value))
|
|
144
172
|
|
|
@@ -537,6 +565,7 @@ the effect result.
|
|
|
537
565
|
type Step a
|
|
538
566
|
= Resolved a
|
|
539
567
|
| Errored Int String
|
|
568
|
+
| Moved String
|
|
540
569
|
| Await Effect (Decode.Value -> Loader a)
|
|
541
570
|
|
|
542
571
|
|
|
@@ -550,6 +579,9 @@ step loader =
|
|
|
550
579
|
Failed status message ->
|
|
551
580
|
Errored status message
|
|
552
581
|
|
|
582
|
+
Redirect url ->
|
|
583
|
+
Moved url
|
|
584
|
+
|
|
553
585
|
Pending effect continue ->
|
|
554
586
|
Await effect continue
|
|
555
587
|
|