elm-ssr 1.0.6 → 1.0.7
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 +4 -0
- package/lib/scaffold/app-templates.mjs +281 -9
- package/lib/scaffold/auth-add.mjs +76 -55
- package/lib/scaffold/auth-deps.mjs +16 -0
- package/lib/scaffold/auth-templates.mjs +87 -379
- package/lib/scaffold/runtime-template.mjs +6 -17
- package/lib/scaffold.mjs +10 -3
- package/package.json +13 -2
- package/src/auth/auth0.ts +103 -0
- package/src/auth/better-auth.ts +145 -0
- package/src/auth/contract.ts +84 -0
package/bin/elm-ssr.mjs
CHANGED
|
@@ -410,6 +410,10 @@ switch (command) {
|
|
|
410
410
|
try {
|
|
411
411
|
const result = await addAuthProvider(rootPath, appConfig, providerArg);
|
|
412
412
|
console.log(`Added ${result.name} to ${appConfig.name}`);
|
|
413
|
+
if (result.warnings && result.warnings.length > 0) {
|
|
414
|
+
console.log(`\nWarnings:`);
|
|
415
|
+
for (const warning of result.warnings) console.log(` - ${warning}`);
|
|
416
|
+
}
|
|
413
417
|
console.log(`\nNext steps:`);
|
|
414
418
|
if (result.provider === "better-auth") {
|
|
415
419
|
console.log(` 1. Run: bun install`);
|
|
@@ -23,7 +23,75 @@ export const elmJsonTemplate = ({ http = false } = {}) => ({
|
|
|
23
23
|
}
|
|
24
24
|
});
|
|
25
25
|
|
|
26
|
-
export const sharedTemplate = (namespace, auth) => `module ${namespace}.View.Shared exposing (head,
|
|
26
|
+
export const sharedTemplate = (namespace, auth) => auth ? `module ${namespace}.View.Shared exposing (head, layoutFor, User, sessionDecoder)
|
|
27
|
+
|
|
28
|
+
import ElmSsr.Html exposing (Node, a, div, header, main_, nav, span, text)
|
|
29
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
30
|
+
import ElmSsr.Page as Page
|
|
31
|
+
import Json.Decode as Decode
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
head : List (Node msg)
|
|
35
|
+
head =
|
|
36
|
+
[ Page.metaCharset "utf-8"
|
|
37
|
+
, Page.metaViewport "width=device-width, initial-scale=1"
|
|
38
|
+
, Page.stylesheet "/styles.css"
|
|
39
|
+
]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
type alias User =
|
|
43
|
+
{ email : String
|
|
44
|
+
, name : Maybe String
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
userDecoder : Decode.Decoder User
|
|
49
|
+
userDecoder =
|
|
50
|
+
Decode.map2 User
|
|
51
|
+
(Decode.field "email" Decode.string)
|
|
52
|
+
(Decode.maybe (Decode.field "name" Decode.string))
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
{-| Decodes the session payload's "user" field, if present. Pair with
|
|
56
|
+
\`Loader.session Shared.sessionDecoder |> Loader.map (Maybe.andThen identity)\`
|
|
57
|
+
to get \`Maybe User\` — outer Maybe is "no session", inner is "session, no user".
|
|
58
|
+
-}
|
|
59
|
+
sessionDecoder : Decode.Decoder (Maybe User)
|
|
60
|
+
sessionDecoder =
|
|
61
|
+
Decode.oneOf
|
|
62
|
+
[ Decode.field "user" (Decode.nullable userDecoder)
|
|
63
|
+
, Decode.succeed Nothing
|
|
64
|
+
]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
{-| Session-aware page chrome — nav shows "Sign in" or the signed-in user. -}
|
|
68
|
+
layoutFor : String -> Maybe User -> List (Node msg) -> Node msg
|
|
69
|
+
layoutFor pageTitle maybeUser body =
|
|
70
|
+
div [ class "page" ]
|
|
71
|
+
[ header [ class "header" ]
|
|
72
|
+
[ div [ class "header-inner" ]
|
|
73
|
+
[ a [ class "brand", href "/" ]
|
|
74
|
+
[ span [ class "brand-icon" ] [ text "◆" ]
|
|
75
|
+
, text "elm-ssr"
|
|
76
|
+
]
|
|
77
|
+
, nav [ class "nav" ]
|
|
78
|
+
[ a [ class "nav-link", href "/" ] [ text "Home" ]
|
|
79
|
+
, a [ class "nav-link", href "/counter" ] [ text "Counter" ]
|
|
80
|
+
, case maybeUser of
|
|
81
|
+
Just user ->
|
|
82
|
+
a [ class "nav-link", href "/profile" ]
|
|
83
|
+
[ text (Maybe.withDefault user.email user.name) ]
|
|
84
|
+
|
|
85
|
+
Nothing ->
|
|
86
|
+
a [ class "nav-link", href "/login" ] [ text "Sign in" ]
|
|
87
|
+
]
|
|
88
|
+
]
|
|
89
|
+
]
|
|
90
|
+
, main_ [ class "main" ]
|
|
91
|
+
[ div [ class "container" ] body
|
|
92
|
+
]
|
|
93
|
+
]
|
|
94
|
+
` : `module ${namespace}.View.Shared exposing (head, layout)
|
|
27
95
|
|
|
28
96
|
import ElmSsr.Html exposing (Node, a, div, header, main_, nav, span, text)
|
|
29
97
|
import ElmSsr.Html.Attributes exposing (class, href)
|
|
@@ -49,8 +117,67 @@ layout pageTitle body =
|
|
|
49
117
|
]
|
|
50
118
|
, nav [ class "nav" ]
|
|
51
119
|
[ a [ class "nav-link", href "/" ] [ text "Home" ]
|
|
52
|
-
, a [ class "nav-link", href "/counter" ] [ text "Counter" ]
|
|
53
|
-
|
|
120
|
+
, a [ class "nav-link", href "/counter" ] [ text "Counter" ]
|
|
121
|
+
]
|
|
122
|
+
]
|
|
123
|
+
]
|
|
124
|
+
, main_ [ class "main" ]
|
|
125
|
+
[ div [ class "container" ] body
|
|
126
|
+
]
|
|
127
|
+
]
|
|
128
|
+
`;
|
|
129
|
+
|
|
130
|
+
// Additive block appended to an existing (non-auth) View/Shared.elm by `auth add`.
|
|
131
|
+
// Deliberately does NOT touch the existing `layout` function or its call sites —
|
|
132
|
+
// existing pages keep compiling unchanged; only newly-added Login/Profile pages
|
|
133
|
+
// (and any page the user migrates by hand) use `layoutFor`.
|
|
134
|
+
export const sharedAuthAddition = () => `
|
|
135
|
+
|
|
136
|
+
type alias User =
|
|
137
|
+
{ email : String
|
|
138
|
+
, name : Maybe String
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
userDecoder : Decode.Decoder User
|
|
143
|
+
userDecoder =
|
|
144
|
+
Decode.map2 User
|
|
145
|
+
(Decode.field "email" Decode.string)
|
|
146
|
+
(Decode.maybe (Decode.field "name" Decode.string))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
{-| Decodes the session payload's "user" field, if present. Pair with
|
|
150
|
+
\`Loader.session Shared.sessionDecoder |> Loader.map (Maybe.andThen identity)\`
|
|
151
|
+
to get \`Maybe User\` — outer Maybe is "no session", inner is "session, no user".
|
|
152
|
+
-}
|
|
153
|
+
sessionDecoder : Decode.Decoder (Maybe User)
|
|
154
|
+
sessionDecoder =
|
|
155
|
+
Decode.oneOf
|
|
156
|
+
[ Decode.field "user" (Decode.nullable userDecoder)
|
|
157
|
+
, Decode.succeed Nothing
|
|
158
|
+
]
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
{-| Session-aware page chrome — nav shows "Sign in" or the signed-in user. -}
|
|
162
|
+
layoutFor : String -> Maybe User -> List (Node msg) -> Node msg
|
|
163
|
+
layoutFor pageTitle maybeUser body =
|
|
164
|
+
div [ class "page" ]
|
|
165
|
+
[ header [ class "header" ]
|
|
166
|
+
[ div [ class "header-inner" ]
|
|
167
|
+
[ a [ class "brand", href "/" ]
|
|
168
|
+
[ span [ class "brand-icon" ] [ text "◆" ]
|
|
169
|
+
, text "elm-ssr"
|
|
170
|
+
]
|
|
171
|
+
, nav [ class "nav" ]
|
|
172
|
+
[ a [ class "nav-link", href "/" ] [ text "Home" ]
|
|
173
|
+
, a [ class "nav-link", href "/counter" ] [ text "Counter" ]
|
|
174
|
+
, case maybeUser of
|
|
175
|
+
Just user ->
|
|
176
|
+
a [ class "nav-link", href "/profile" ]
|
|
177
|
+
[ text (Maybe.withDefault user.email user.name) ]
|
|
178
|
+
|
|
179
|
+
Nothing ->
|
|
180
|
+
a [ class "nav-link", href "/login" ] [ text "Sign in" ]
|
|
54
181
|
]
|
|
55
182
|
]
|
|
56
183
|
]
|
|
@@ -60,7 +187,71 @@ layout pageTitle body =
|
|
|
60
187
|
]
|
|
61
188
|
`;
|
|
62
189
|
|
|
63
|
-
export const indexRouteTemplate = (namespace, auth) => `module ${namespace}.Routes.Index exposing (page, action)
|
|
190
|
+
export const indexRouteTemplate = (namespace, auth) => auth ? `module ${namespace}.Routes.Index exposing (page, action)
|
|
191
|
+
|
|
192
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
193
|
+
import ElmSsr.Document exposing (Document)
|
|
194
|
+
import ElmSsr.Html exposing (a, div, h1, h2, p, section, span, text)
|
|
195
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
196
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
197
|
+
import ElmSsr.Page as Page
|
|
198
|
+
import ElmSsr.Route exposing (Request)
|
|
199
|
+
import ${namespace}.View.Shared as Shared
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
page : Request -> Loader (Document Never)
|
|
203
|
+
page _ =
|
|
204
|
+
Loader.session Shared.sessionDecoder
|
|
205
|
+
|> Loader.map (Maybe.andThen identity)
|
|
206
|
+
|> Loader.map view
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
action : Request -> Action (Document Never)
|
|
210
|
+
action _ =
|
|
211
|
+
Action.fail 405 "Method not allowed"
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
view : Maybe Shared.User -> Document Never
|
|
215
|
+
view maybeUser =
|
|
216
|
+
Page.page
|
|
217
|
+
{ title = "elm-ssr"
|
|
218
|
+
, head = Shared.head
|
|
219
|
+
, body =
|
|
220
|
+
[ Shared.layoutFor "Home"
|
|
221
|
+
maybeUser
|
|
222
|
+
[ section [ class "hero" ]
|
|
223
|
+
[ h1 [ class "hero-title" ] [ text "Ship fast." ]
|
|
224
|
+
, p [ class "hero-subtitle" ]
|
|
225
|
+
[ text "Type-safe server-side rendering with interactive islands. Runs on Cloudflare Workers and Bun." ]
|
|
226
|
+
, div [ class "hero-actions" ]
|
|
227
|
+
([ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ] ]
|
|
228
|
+
++ (case maybeUser of
|
|
229
|
+
Just _ ->
|
|
230
|
+
[ a [ class "btn btn-secondary", href "/profile" ] [ text "Your profile" ] ]
|
|
231
|
+
|
|
232
|
+
Nothing ->
|
|
233
|
+
[ a [ class "btn btn-secondary", href "/login" ] [ text "Sign in" ] ]
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
]
|
|
237
|
+
, section [ class "features" ]
|
|
238
|
+
[ featureCard "⚡" "Edge-first" "Renders in milliseconds at the edge. No cold starts."
|
|
239
|
+
, featureCard "🦺" "Fully typed" "End-to-end Elm types from DB to HTML. No runtime surprises."
|
|
240
|
+
, featureCard "🏝️" "Islands" "Add interactivity exactly where you need it. Zero JS elsewhere."
|
|
241
|
+
]
|
|
242
|
+
]
|
|
243
|
+
]
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
featureCard : String -> String -> String -> ElmSsr.Html.Node msg
|
|
248
|
+
featureCard icon title_ body =
|
|
249
|
+
div [ class "feature-card" ]
|
|
250
|
+
[ span [ class "feature-icon" ] [ text icon ]
|
|
251
|
+
, h2 [ class "feature-title" ] [ text title_ ]
|
|
252
|
+
, p [ class "feature-body" ] [ text body ]
|
|
253
|
+
]
|
|
254
|
+
` : `module ${namespace}.Routes.Index exposing (page, action)
|
|
64
255
|
|
|
65
256
|
import ElmSsr.Action as Action exposing (Action)
|
|
66
257
|
import ElmSsr.Document exposing (Document)
|
|
@@ -94,9 +285,7 @@ view =
|
|
|
94
285
|
, p [ class "hero-subtitle" ]
|
|
95
286
|
[ text "Type-safe server-side rendering with interactive islands. Runs on Cloudflare Workers and Bun." ]
|
|
96
287
|
, div [ class "hero-actions" ]
|
|
97
|
-
[ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ]
|
|
98
|
-
, a [ class "btn btn-secondary", href "/login" ] [ text "Sign in" ]` : ""}
|
|
99
|
-
]
|
|
288
|
+
[ a [ class "btn btn-primary", href "/counter" ] [ text "Try the counter" ] ]
|
|
100
289
|
]
|
|
101
290
|
, section [ class "features" ]
|
|
102
291
|
[ featureCard "⚡" "Edge-first" "Renders in milliseconds at the edge. No cold starts."
|
|
@@ -117,7 +306,50 @@ featureCard icon title_ body =
|
|
|
117
306
|
]
|
|
118
307
|
`;
|
|
119
308
|
|
|
120
|
-
export const counterRouteTemplate = (namespace) => `module ${namespace}.Routes.Counter exposing (page, action)
|
|
309
|
+
export const counterRouteTemplate = (namespace, auth) => auth ? `module ${namespace}.Routes.Counter exposing (page, action)
|
|
310
|
+
|
|
311
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
312
|
+
import ElmSsr.Document exposing (Document)
|
|
313
|
+
import ElmSsr.Html exposing (div, h1, p, text)
|
|
314
|
+
import ElmSsr.Html.Attributes exposing (class)
|
|
315
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
316
|
+
import ElmSsr.Page as Page
|
|
317
|
+
import ElmSsr.Route exposing (Request)
|
|
318
|
+
import ${namespace}.Islands.Counter as Counter
|
|
319
|
+
import ${namespace}.View.Shared as Shared
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
page : Request -> Loader (Document Never)
|
|
323
|
+
page _ =
|
|
324
|
+
Loader.session Shared.sessionDecoder
|
|
325
|
+
|> Loader.map (Maybe.andThen identity)
|
|
326
|
+
|> Loader.map view
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
action : Request -> Action (Document Never)
|
|
330
|
+
action _ =
|
|
331
|
+
Action.fail 405 "Method not allowed"
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
view : Maybe Shared.User -> Document Never
|
|
335
|
+
view maybeUser =
|
|
336
|
+
Page.page
|
|
337
|
+
{ title = "Counter | elm-ssr"
|
|
338
|
+
, head = Shared.head
|
|
339
|
+
, body =
|
|
340
|
+
[ Shared.layoutFor "Counter"
|
|
341
|
+
maybeUser
|
|
342
|
+
[ div [ class "page-header" ]
|
|
343
|
+
[ h1 [] [ text "Interactive Counter" ]
|
|
344
|
+
, p [ class "page-subtitle" ]
|
|
345
|
+
[ text "This page is server-rendered. Only the counter widget below is a client-side island — zero JS elsewhere." ]
|
|
346
|
+
]
|
|
347
|
+
, div [ class "card" ]
|
|
348
|
+
[ Counter.embed { start = 0 } ]
|
|
349
|
+
]
|
|
350
|
+
]
|
|
351
|
+
}
|
|
352
|
+
` : `module ${namespace}.Routes.Counter exposing (page, action)
|
|
121
353
|
|
|
122
354
|
import ElmSsr.Action as Action exposing (Action)
|
|
123
355
|
import ElmSsr.Document exposing (Document)
|
|
@@ -252,7 +484,47 @@ view model =
|
|
|
252
484
|
]
|
|
253
485
|
`;
|
|
254
486
|
|
|
255
|
-
export const notFoundRouteTemplate = (namespace) => `module ${namespace}.Routes.NotFound exposing (page, action)
|
|
487
|
+
export const notFoundRouteTemplate = (namespace, auth) => auth ? `module ${namespace}.Routes.NotFound exposing (page, action)
|
|
488
|
+
|
|
489
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
490
|
+
import ElmSsr.Document exposing (Document)
|
|
491
|
+
import ElmSsr.Html exposing (a, div, h1, p, text)
|
|
492
|
+
import ElmSsr.Html.Attributes exposing (class, href)
|
|
493
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
494
|
+
import ElmSsr.Page as Page
|
|
495
|
+
import ElmSsr.Route exposing (Request)
|
|
496
|
+
import ${namespace}.View.Shared as Shared
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
page : Request -> Loader (Document Never)
|
|
500
|
+
page _ =
|
|
501
|
+
Loader.session Shared.sessionDecoder
|
|
502
|
+
|> Loader.map (Maybe.andThen identity)
|
|
503
|
+
|> Loader.map view
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
action : Request -> Action (Document Never)
|
|
507
|
+
action _ =
|
|
508
|
+
Action.fail 405 "Method not allowed"
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
view : Maybe Shared.User -> Document Never
|
|
512
|
+
view maybeUser =
|
|
513
|
+
Page.notFound
|
|
514
|
+
{ title = "Not Found | elm-ssr"
|
|
515
|
+
, head = Shared.head
|
|
516
|
+
, body =
|
|
517
|
+
[ Shared.layoutFor "Not Found"
|
|
518
|
+
maybeUser
|
|
519
|
+
[ div [ class "error-page" ]
|
|
520
|
+
[ h1 [ class "error-code" ] [ text "404" ]
|
|
521
|
+
, p [ class "error-message" ] [ text "This page doesn't exist." ]
|
|
522
|
+
, a [ class "btn btn-primary", href "/" ] [ text "Go home" ]
|
|
523
|
+
]
|
|
524
|
+
]
|
|
525
|
+
]
|
|
526
|
+
}
|
|
527
|
+
` : `module ${namespace}.Routes.NotFound exposing (page, action)
|
|
256
528
|
|
|
257
529
|
import ElmSsr.Action as Action exposing (Action)
|
|
258
530
|
import ElmSsr.Document exposing (Document)
|
|
@@ -3,14 +3,14 @@ import { dirname, resolve } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
betterAuthProviderCode,
|
|
5
5
|
auth0ProviderCode,
|
|
6
|
-
betterAuthEndpointTemplate,
|
|
7
|
-
auth0EndpointTemplate,
|
|
8
6
|
loginRouteTemplateBetterAuth,
|
|
9
7
|
loginRouteTemplateAuth0,
|
|
10
8
|
profileRouteTemplate,
|
|
11
9
|
loginIslandTemplate,
|
|
12
10
|
betterAuthMigrationTemplate
|
|
13
11
|
} from "./auth-templates.mjs";
|
|
12
|
+
import { sharedAuthAddition } from "./app-templates.mjs";
|
|
13
|
+
import { authPeerDependencies } from "./auth-deps.mjs";
|
|
14
14
|
|
|
15
15
|
const PROVIDER_NAMES = { "better-auth": "betterAuth", auth0: "auth0" };
|
|
16
16
|
|
|
@@ -28,59 +28,42 @@ const detectProviders = (runtimeContent) => {
|
|
|
28
28
|
return found;
|
|
29
29
|
};
|
|
30
30
|
|
|
31
|
-
// Add auth imports after the effects import line.
|
|
31
|
+
// Add auth imports after the effects import line. composeAuthProviders comes
|
|
32
|
+
// from the elm-ssr library itself; only the concrete provider (configured by
|
|
33
|
+
// the user's app-specific env shape) lives in the generated Auth.ts.
|
|
32
34
|
const addAuthImports = (content, provider) => {
|
|
33
35
|
const providerName = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
|
|
34
|
-
const sessionImport = `import { memorySessionStore } from "elm-ssr/sessions";`;
|
|
35
36
|
const anchor = `import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`;
|
|
36
37
|
let next = content;
|
|
37
38
|
|
|
38
|
-
if (!next.includes(
|
|
39
|
-
next = next.replace(anchor, `${anchor}\
|
|
39
|
+
if (!next.includes(`import { memorySessionStore } from "elm-ssr/sessions";`)) {
|
|
40
|
+
next = next.replace(anchor, `${anchor}\nimport { memorySessionStore } from "elm-ssr/sessions";`);
|
|
41
|
+
}
|
|
42
|
+
if (!next.includes(`import { composeAuthProviders } from "elm-ssr/auth";`)) {
|
|
43
|
+
next = next.replace(anchor, `${anchor}\nimport { composeAuthProviders } from "elm-ssr/auth";`);
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
const endpointImport = next.match(/import \{ ([^}]+) \} from "\.\/src\/Endpoints\/Auth";/);
|
|
43
47
|
if (endpointImport) {
|
|
44
48
|
const names = endpointImport[1].split(",").map((name) => name.trim()).filter(Boolean);
|
|
45
|
-
|
|
46
|
-
if (!names.includes(name)) names.push(name);
|
|
47
|
-
}
|
|
49
|
+
if (!names.includes(providerName)) names.push(providerName);
|
|
48
50
|
return next.replace(endpointImport[0], `import { ${names.join(", ")} } from "./src/Endpoints/Auth";`);
|
|
49
51
|
}
|
|
50
52
|
|
|
51
|
-
return next.replace(anchor, `${anchor}\nimport { ${providerName}
|
|
53
|
+
return next.replace(anchor, `${anchor}\nimport { ${providerName} } from "./src/Endpoints/Auth";`);
|
|
52
54
|
};
|
|
53
55
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
const getAuthEnv = (env: any): any =>
|
|
62
|
-
bunAuthDb && !env?.DB ? { ...(env ?? {}), DB: bunAuthDb } : env;
|
|
63
|
-
|
|
64
|
-
`;
|
|
65
|
-
|
|
66
|
-
// Build the auth init block that goes before createWorkerApp.
|
|
67
|
-
const buildAuthInitBlock = (provider, hasDb) => {
|
|
68
|
-
if (provider === "better-auth") {
|
|
69
|
-
return `
|
|
70
|
-
// elm-ssr-auth:start
|
|
71
|
-
${betterAuthRuntimeEnvBlock}
|
|
72
|
-
export const sessionStore = memorySessionStore();
|
|
73
|
-
|
|
74
|
-
const authMiddleware = composeAuthProviders([
|
|
75
|
-
betterAuthProvider({ getEnv: getAuthEnv }),
|
|
76
|
-
]);`;
|
|
77
|
-
}
|
|
56
|
+
// Build the auth init block that goes before createWorkerApp. The provider
|
|
57
|
+
// itself (sourced from Auth.ts) is already fully configured — no per-runtime
|
|
58
|
+
// env-resolution glue needed here.
|
|
59
|
+
const buildAuthInitBlock = (provider) => {
|
|
60
|
+
const providerCall = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
|
|
78
61
|
return `
|
|
79
62
|
// elm-ssr-auth:start
|
|
80
63
|
export const sessionStore = memorySessionStore();
|
|
81
64
|
|
|
82
65
|
const authMiddleware = composeAuthProviders([
|
|
83
|
-
|
|
66
|
+
${providerCall},
|
|
84
67
|
]);`;
|
|
85
68
|
};
|
|
86
69
|
|
|
@@ -122,19 +105,19 @@ ${effectsLine}
|
|
|
122
105
|
// elm-ssr-auth:end\n`;
|
|
123
106
|
|
|
124
107
|
const withImports = addAuthImports(base, provider);
|
|
125
|
-
return withImports + buildAuthInitBlock(provider
|
|
108
|
+
return withImports + buildAuthInitBlock(provider) + newWorker;
|
|
126
109
|
};
|
|
127
110
|
|
|
128
111
|
// Adds a second provider to an already-auth runtime.ts.
|
|
129
112
|
const addProviderToRuntime = (content, provider) => {
|
|
130
113
|
let next = addAuthImports(content, provider);
|
|
131
|
-
const providerCall = provider === "better-auth"
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
114
|
+
const providerCall = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
|
|
115
|
+
// Scope the idempotency check to the composeAuthProviders array body —
|
|
116
|
+
// providerCall is now a bare identifier that also appears in the import
|
|
117
|
+
// line addAuthImports just added, so a whole-file `.includes` would always
|
|
118
|
+
// (wrongly) short-circuit here.
|
|
119
|
+
const arrayMatch = next.match(/composeAuthProviders\(\[\n([\s\S]*?)\]\)/);
|
|
120
|
+
if (arrayMatch && arrayMatch[1].includes(providerCall)) return next;
|
|
138
121
|
return next.replace(
|
|
139
122
|
/composeAuthProviders\(\[\n([\s\S]*?)\]\)/,
|
|
140
123
|
(_, inner) => `composeAuthProviders([\n${inner} ${providerCall},\n])`
|
|
@@ -152,19 +135,14 @@ const patchRuntimeForAuth = (content, provider, hasDb) => {
|
|
|
152
135
|
return injectAuthIntoRuntime(content, provider, hasDb);
|
|
153
136
|
};
|
|
154
137
|
|
|
155
|
-
// Adds a provider to Auth.ts (creates
|
|
138
|
+
// Adds a provider to Auth.ts (creates the file if missing, appends otherwise).
|
|
139
|
+
// Each provider's glue is self-contained (no shared contract preamble to
|
|
140
|
+
// manage), so a fresh file and an append are the same operation.
|
|
156
141
|
const updateAuthTs = (existingContent, provider) => {
|
|
157
142
|
const providerFn = provider === "better-auth" ? "betterAuthProvider" : "auth0Provider";
|
|
158
143
|
if (existingContent.includes(providerFn)) return null; // already present
|
|
159
144
|
const snippet = provider === "better-auth" ? betterAuthProviderCode : auth0ProviderCode;
|
|
160
|
-
|
|
161
|
-
if (existingContent.includes("composeAuthProviders")) {
|
|
162
|
-
return existingContent.trimEnd() + "\n" + snippet + "\n";
|
|
163
|
-
}
|
|
164
|
-
// No contract yet — generate full file.
|
|
165
|
-
return provider === "better-auth"
|
|
166
|
-
? betterAuthEndpointTemplate()
|
|
167
|
-
: auth0EndpointTemplate();
|
|
145
|
+
return existingContent ? existingContent.trimEnd() + "\n" + snippet + "\n" : snippet;
|
|
168
146
|
};
|
|
169
147
|
|
|
170
148
|
// Returns provider-specific env vars to add to .dev.vars / .env.
|
|
@@ -184,6 +162,44 @@ const missingEnvVars = (existingContent, provider) => {
|
|
|
184
162
|
return vars.filter(([key]) => !existingContent.includes(`${key}=`));
|
|
185
163
|
};
|
|
186
164
|
|
|
165
|
+
// Adds `User` / `sessionDecoder` / `layoutFor` to an existing (non-auth)
|
|
166
|
+
// View/Shared.elm so the new Login/Profile pages have something to import.
|
|
167
|
+
// This is purely additive: the existing `layout` function and its call sites
|
|
168
|
+
// in Index/Counter/NotFound (or any hand-written page) are left untouched, so
|
|
169
|
+
// the app keeps compiling exactly as it did before — those pages just don't
|
|
170
|
+
// get session-aware nav until migrated to `layoutFor` by hand (a warning
|
|
171
|
+
// points at the new function so that's a deliberate, easy follow-up).
|
|
172
|
+
const upgradeSharedForAuth = async (appRoot, namespace) => {
|
|
173
|
+
const sharedPath = resolve(appRoot, `src/${namespace.replace(/\./g, "/")}/View/Shared.elm`);
|
|
174
|
+
let existing = "";
|
|
175
|
+
try { existing = await readFile(sharedPath, "utf8"); } catch { return []; }
|
|
176
|
+
if (existing.includes("sessionDecoder")) return []; // already upgraded
|
|
177
|
+
|
|
178
|
+
let next = existing;
|
|
179
|
+
|
|
180
|
+
const exposingMatch = next.match(/^module ([\w.]+) exposing \(([^)]*)\)/m);
|
|
181
|
+
if (exposingMatch) {
|
|
182
|
+
const [fullMatch, moduleName, exposed] = exposingMatch;
|
|
183
|
+
const names = exposed.split(",").map((n) => n.trim()).filter(Boolean);
|
|
184
|
+
for (const name of ["layoutFor", "User", "sessionDecoder"]) {
|
|
185
|
+
if (!names.includes(name)) names.push(name);
|
|
186
|
+
}
|
|
187
|
+
next = next.replace(fullMatch, `module ${moduleName} exposing (${names.join(", ")})`);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (!next.includes("import Json.Decode")) {
|
|
191
|
+
next = next.replace(/^import ElmSsr\.Page as Page$/m, (line) => `${line}\nimport Json.Decode as Decode`);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
next = next.trimEnd() + "\n" + sharedAuthAddition();
|
|
195
|
+
|
|
196
|
+
await writeFile(sharedPath, next, "utf8");
|
|
197
|
+
|
|
198
|
+
return [
|
|
199
|
+
`View/Shared.elm gained User/sessionDecoder/layoutFor for session-aware nav. The original "layout" function (used by your existing pages) is untouched and still shows a static nav — switch a page to "Shared.layoutFor pageTitle maybeUser body" (reading the session like Routes/Profile.elm does) when you want it to reflect signed-in state.`
|
|
200
|
+
];
|
|
201
|
+
};
|
|
202
|
+
|
|
187
203
|
/**
|
|
188
204
|
* Add an auth provider to an existing elm-ssr app.
|
|
189
205
|
* Idempotent: running it twice has no effect.
|
|
@@ -192,6 +208,7 @@ export const addAuthProvider = async (rootPath, appConfig, rawProvider) => {
|
|
|
192
208
|
const provider = normaliseProvider(rawProvider);
|
|
193
209
|
const appRoot = resolve(rootPath, appConfig.root);
|
|
194
210
|
const namespace = appConfig.module;
|
|
211
|
+
const warnings = await upgradeSharedForAuth(appRoot, namespace);
|
|
195
212
|
|
|
196
213
|
// ── Auth.ts ───────────────────────────────────────────────────────────────
|
|
197
214
|
const authTsPath = resolve(appRoot, "src/Endpoints/Auth.ts");
|
|
@@ -270,8 +287,12 @@ export const addAuthProvider = async (rootPath, appConfig, rawProvider) => {
|
|
|
270
287
|
const pkgPath = resolve(rootPath, "package.json");
|
|
271
288
|
try {
|
|
272
289
|
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
273
|
-
|
|
274
|
-
|
|
290
|
+
const peerDeps = await authPeerDependencies();
|
|
291
|
+
const addDeps = {};
|
|
292
|
+
if (!pkg.devDependencies?.["better-auth"]) addDeps["better-auth"] = peerDeps["better-auth"] ?? "latest";
|
|
293
|
+
if (!pkg.devDependencies?.["@better-auth/infra"]) addDeps["@better-auth/infra"] = peerDeps["@better-auth/infra"] ?? "latest";
|
|
294
|
+
if (Object.keys(addDeps).length > 0) {
|
|
295
|
+
pkg.devDependencies = { ...pkg.devDependencies, ...addDeps };
|
|
275
296
|
await writeFile(pkgPath, JSON.stringify(pkg, null, 2) + "\n", "utf8");
|
|
276
297
|
}
|
|
277
298
|
} catch {}
|
|
@@ -288,7 +309,7 @@ export const addAuthProvider = async (rootPath, appConfig, rawProvider) => {
|
|
|
288
309
|
}
|
|
289
310
|
}
|
|
290
311
|
|
|
291
|
-
return { provider, name: PROVIDER_NAMES[provider] };
|
|
312
|
+
return { provider, name: PROVIDER_NAMES[provider], warnings };
|
|
292
313
|
};
|
|
293
314
|
|
|
294
315
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
// elm-ssr's own package.json declares better-auth / @better-auth/infra as
|
|
5
|
+
// peerDependencies — the single source of truth for what version
|
|
6
|
+
// elm-ssr/auth/better-auth was built and tested against. The scaffold reads
|
|
7
|
+
// from there instead of keeping its own separate hardcoded version strings
|
|
8
|
+
// that could silently drift out of sync.
|
|
9
|
+
let cached = null;
|
|
10
|
+
export const authPeerDependencies = async () => {
|
|
11
|
+
if (cached) return cached;
|
|
12
|
+
const pkgPath = resolve(new URL(".", import.meta.url).pathname, "../../package.json");
|
|
13
|
+
const pkg = JSON.parse(await readFile(pkgPath, "utf8"));
|
|
14
|
+
cached = pkg.peerDependencies ?? {};
|
|
15
|
+
return cached;
|
|
16
|
+
};
|