elm-ssr 0.90.1 → 0.91.1
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 +53 -15
- package/bin/elm-ssr.mjs +30 -6
- package/lib/scaffold.mjs +289 -30
- package/package.json +1 -1
- package/src/app.ts +1 -1
- package/src/client-runtime/islands.ts +20 -1
- package/src/debugger.ts +54 -2
- package/src/http.ts +1 -0
- package/src/request-handler.ts +2 -1
- package/src/sessions/middleware.ts +12 -3
package/README.md
CHANGED
|
@@ -26,21 +26,59 @@ bunx elm-ssr build
|
|
|
26
26
|
|
|
27
27
|
## CLI commands
|
|
28
28
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
29
|
+
Run the CLI using `bunx elm-ssr <command>`.
|
|
30
|
+
|
|
31
|
+
### `build`
|
|
32
|
+
Generates `.elm-ssr/Main.elm` (router) and the islands manifest for all configured apps, syncs the Elm authoring modules into `.elm-ssr/src/ElmSsr/`, and compiles pages and island bundles.
|
|
33
|
+
|
|
34
|
+
### `compress`
|
|
35
|
+
Runs the `build` pipeline and additionally pre-compresses generated JS/CSS assets with Gzip for faster delivery on edge networks.
|
|
36
|
+
|
|
37
|
+
### `dev`
|
|
38
|
+
Starts the local development loop. Compiles the Elm code and runs `wrangler dev` (monitoring `.elm` and `.css` files for hot-reloads).
|
|
39
|
+
|
|
40
|
+
### `init <name> [--db] [--auth betterAuth|auth0]`
|
|
41
|
+
Initialises a self-contained, single-app project in the current directory.
|
|
42
|
+
- `--db`: Configures local SQLite database support (generates initial migration and configures `inMemoryEffects`).
|
|
43
|
+
- `--auth <betterAuth|auth0>`: Scaffolds basic route modules (`Login.elm`, `Profile.elm`), signed cookie/CSRF middleware, and callback intercepts. (Automatically implies `--db`).
|
|
44
|
+
|
|
45
|
+
### `new <name> [--in <subdir>] [--db] [--auth betterAuth|auth0]`
|
|
46
|
+
Creates a new app under `<workspace>/<name>/` (or `<workspace>/<subdir>/<name>/` if `--in <subdir>` is provided) and registers it in `elm-ssr.config.json`.
|
|
47
|
+
- `--in <subdir>`: Target subdirectory group (e.g. `--in apps` places it under `apps/<name>/`).
|
|
48
|
+
- `--db`: Configures SQLite database support and an initial migration.
|
|
49
|
+
- `--auth <betterAuth|auth0>`: Scaffolds authentication views, cookies, and callback handlers.
|
|
50
|
+
|
|
51
|
+
### `route <path> [--app <name>] [--api] [--ws] [--sse]`
|
|
52
|
+
Scaffolds a new route or endpoint:
|
|
53
|
+
- `--app <app-name>`: Required if multiple apps are configured in the workspace.
|
|
54
|
+
- `--api`: Scaffolds a JSON API page/action route instead of an HTML page.
|
|
55
|
+
- `--ws` or `--websocket`: Scaffolds a TypeScript WebSocket handler in `src/Endpoints/<route>.ts`.
|
|
56
|
+
- `--sse`: Scaffolds a TypeScript Server-Sent Events (SSE) stream handler in `src/Endpoints/<route>.ts`.
|
|
57
|
+
|
|
58
|
+
### `query [--app <name>] [--dir <path>] [--output <path>]`
|
|
59
|
+
Generates type-safe Elm database schema and query helpers directly from raw SQL files in the migrations directory.
|
|
60
|
+
- `--app <app-name>`: Required if multiple apps exist.
|
|
61
|
+
- `--dir <path>`: Directory containing migrations (default: `<app_root>/migrations`).
|
|
62
|
+
- `--output <path>`: Directory where Elm Db modules will be generated (default: `<app_root>/src/<Module>/Db`).
|
|
63
|
+
|
|
64
|
+
### `migrate <up|down|status> [--db <conn>] [--dir <path>] [--count <n>] [--table <name>]`
|
|
65
|
+
SQL migration runner.
|
|
66
|
+
- `up`: Applies all pending migrations.
|
|
67
|
+
- `down`: Reverts the last migration (or `n` migrations via `--count`).
|
|
68
|
+
- `status`: Lists all applied and pending migrations.
|
|
69
|
+
- `--db <conn>`: Database connection string (Postgres URL, SQLite URL, or plain file path). Reads `DATABASE_URL` if omitted.
|
|
70
|
+
- `--dir <path>`: Path to the SQL migrations directory (default: `./migrations`).
|
|
71
|
+
- `--count <n>`: The number of migrations to revert when running `down` (default: 1).
|
|
72
|
+
- `--table <name>`: Tracking database table name (default: `__elm_ssr_migrations`).
|
|
73
|
+
|
|
74
|
+
### `routes`
|
|
75
|
+
Prints a list of all configured apps, their root directories, and their route source directories.
|
|
76
|
+
|
|
77
|
+
### `info`
|
|
78
|
+
Prints the workspace name and registered app names.
|
|
79
|
+
|
|
80
|
+
## Global options
|
|
81
|
+
- `--root <path>`: Overrides where the CLI looks for `elm-ssr.config.json` (defaults to current directory).
|
|
44
82
|
|
|
45
83
|
Configuration lives in `elm-ssr.config.json` at the repo root:
|
|
46
84
|
|
package/bin/elm-ssr.mjs
CHANGED
|
@@ -77,8 +77,9 @@ const printHelp = () => {
|
|
|
77
77
|
compress Pre-compress island and app bundles using Gzip for faster edge delivery
|
|
78
78
|
dev Build and start wrangler dev using the current workspace config
|
|
79
79
|
init <name> Initialize a self-contained single-app project in the current directory
|
|
80
|
-
|
|
81
|
-
|
|
80
|
+
(use --db to wire SQLite/migrations, --auth betterAuth|auth0 for auth guards)
|
|
81
|
+
new <name> Create a new app at <workspace>/<name>/ and register it in elm-ssr.config.json
|
|
82
|
+
(use --in <subdir> to group, --db for SQLite, --auth betterAuth|auth0 for auth)
|
|
82
83
|
routes Print configured apps and their public modules
|
|
83
84
|
route <path> Scaffold a new route (standard HTML, --api JSON, --ws WebSocket, or --sse EventSource)
|
|
84
85
|
query Generate type-safe Elm Db modules from SQL table definitions
|
|
@@ -195,11 +196,22 @@ switch (command) {
|
|
|
195
196
|
const name = args[1];
|
|
196
197
|
|
|
197
198
|
if (!name) {
|
|
198
|
-
console.error("Usage: elm-ssr init <name>");
|
|
199
|
+
console.error("Usage: elm-ssr init <name> [--db] [--auth betterAuth|auth0]");
|
|
199
200
|
process.exit(1);
|
|
200
201
|
}
|
|
201
202
|
|
|
202
|
-
const
|
|
203
|
+
const db = args.includes("--db");
|
|
204
|
+
let auth = findFlagValue("--auth");
|
|
205
|
+
if (auth) {
|
|
206
|
+
if (auth === "better-auth" || auth === "betterAuth") {
|
|
207
|
+
auth = "better-auth";
|
|
208
|
+
} else if (auth !== "auth0") {
|
|
209
|
+
console.error("Error: --auth only supports 'betterAuth' or 'auth0'");
|
|
210
|
+
process.exit(1);
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const created = await createAppScaffold(rootPath, name, { root: ".", db, auth });
|
|
203
215
|
console.log(`Initialized ${created.name} in current directory`);
|
|
204
216
|
break;
|
|
205
217
|
}
|
|
@@ -208,7 +220,7 @@ switch (command) {
|
|
|
208
220
|
const name = args[1];
|
|
209
221
|
|
|
210
222
|
if (!name) {
|
|
211
|
-
console.error("Usage: elm-ssr new <name> [--in <subdir>]");
|
|
223
|
+
console.error("Usage: elm-ssr new <name> [--in <subdir>] [--db] [--auth betterAuth|auth0]");
|
|
212
224
|
console.error(" Default location: <workspace>/<name>/");
|
|
213
225
|
console.error(" Use --in apps to place it under <workspace>/apps/<name>/, etc.");
|
|
214
226
|
process.exit(1);
|
|
@@ -216,7 +228,19 @@ switch (command) {
|
|
|
216
228
|
|
|
217
229
|
const subdir = findFlagValue("--in");
|
|
218
230
|
const appRoot = subdir ? `${subdir.replace(/\/+$/, "")}/${name}` : name;
|
|
219
|
-
|
|
231
|
+
|
|
232
|
+
const db = args.includes("--db");
|
|
233
|
+
let auth = findFlagValue("--auth");
|
|
234
|
+
if (auth) {
|
|
235
|
+
if (auth === "better-auth" || auth === "betterAuth") {
|
|
236
|
+
auth = "better-auth";
|
|
237
|
+
} else if (auth !== "auth0") {
|
|
238
|
+
console.error("Error: --auth only supports 'betterAuth' or 'auth0'");
|
|
239
|
+
process.exit(1);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const created = await createAppScaffold(rootPath, name, { root: appRoot, db, auth });
|
|
220
244
|
console.log(`Created ${created.name} at ${created.root}`);
|
|
221
245
|
break;
|
|
222
246
|
}
|
package/lib/scaffold.mjs
CHANGED
|
@@ -85,7 +85,10 @@ import ${namespace}.View.Shared as Shared
|
|
|
85
85
|
|
|
86
86
|
page : Request -> Loader (Document Never)
|
|
87
87
|
page _ =
|
|
88
|
-
Loader.
|
|
88
|
+
Loader.env "GREETING"
|
|
89
|
+
|> Loader.map (\\maybeGreeting ->
|
|
90
|
+
view (Maybe.withDefault "Hello from default env!" maybeGreeting)
|
|
91
|
+
)
|
|
89
92
|
|
|
90
93
|
|
|
91
94
|
action : Request -> Action (Document Never)
|
|
@@ -93,14 +96,15 @@ action _ =
|
|
|
93
96
|
Action.fail 405 "Method not allowed"
|
|
94
97
|
|
|
95
98
|
|
|
96
|
-
view : Document Never
|
|
97
|
-
view =
|
|
99
|
+
view : String -> Document Never
|
|
100
|
+
view greeting =
|
|
98
101
|
Page.page
|
|
99
102
|
{ title = "Starter | elm-ssr"
|
|
100
103
|
, head = Shared.head
|
|
101
104
|
, body =
|
|
102
105
|
[ Shared.shell "elm-ssr starter"
|
|
103
|
-
[ p [] [ text "
|
|
106
|
+
[ p [] [ text ("Greeting from environment: " ++ greeting) ]
|
|
107
|
+
, p [] [ text "This page is stateless and renders on the edge with no client runtime." ]
|
|
104
108
|
, p [] [ a [ class "link", href "/counter" ] [ text "Open the interactive counter" ] ]
|
|
105
109
|
]
|
|
106
110
|
]
|
|
@@ -272,19 +276,211 @@ view =
|
|
|
272
276
|
}
|
|
273
277
|
`;
|
|
274
278
|
|
|
275
|
-
const
|
|
279
|
+
const loginRouteTemplate = (namespace, authProvider) => `module ${namespace}.Routes.Login exposing (page, action)
|
|
280
|
+
|
|
281
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
282
|
+
import ElmSsr.Document exposing (Document)
|
|
283
|
+
import ElmSsr.Html exposing (a, div, p, text)
|
|
284
|
+
import ElmSsr.Html.Attributes as Attr
|
|
285
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
286
|
+
import ElmSsr.Page as Page
|
|
287
|
+
import ElmSsr.Route exposing (Request)
|
|
288
|
+
import ${namespace}.View.Shared as Shared
|
|
289
|
+
|
|
290
|
+
page : Request -> Loader (Document Never)
|
|
291
|
+
page _ =
|
|
292
|
+
Loader.succeed view
|
|
293
|
+
|
|
294
|
+
action : Request -> Action (Document Never)
|
|
295
|
+
action _ =
|
|
296
|
+
Action.fail 405 "Method not allowed"
|
|
297
|
+
|
|
298
|
+
view : Document Never
|
|
299
|
+
view =
|
|
300
|
+
Page.page
|
|
301
|
+
{ title = "Login | elm-ssr"
|
|
302
|
+
, head = Shared.head
|
|
303
|
+
, body =
|
|
304
|
+
[ Shared.shell "Sign In"
|
|
305
|
+
[ div [ Attr.class "login-container" ]
|
|
306
|
+
[ p [] [ text "Access user authentication example (${authProvider})." ]
|
|
307
|
+
, div [ Attr.style "margin-top" "20px" ]
|
|
308
|
+
[ a
|
|
309
|
+
[ Attr.class "button primary"
|
|
310
|
+
, Attr.href "/api/auth/login"
|
|
311
|
+
]
|
|
312
|
+
[ text "Sign In with Auth Provider" ]
|
|
313
|
+
]
|
|
314
|
+
]
|
|
315
|
+
]
|
|
316
|
+
]
|
|
317
|
+
}
|
|
318
|
+
`;
|
|
319
|
+
|
|
320
|
+
const profileRouteTemplate = (namespace) => `module ${namespace}.Routes.Profile exposing (page, action)
|
|
321
|
+
|
|
322
|
+
import ElmSsr.Action as Action exposing (Action)
|
|
323
|
+
import ElmSsr.Document exposing (Document)
|
|
324
|
+
import ElmSsr.Html exposing (a, div, h2, p, text)
|
|
325
|
+
import ElmSsr.Html.Attributes as Attr
|
|
326
|
+
import ElmSsr.Loader as Loader exposing (Loader)
|
|
327
|
+
import ElmSsr.Page as Page
|
|
328
|
+
import ElmSsr.Route as Route exposing (Request)
|
|
329
|
+
import ${namespace}.View.Shared as Shared
|
|
330
|
+
import Json.Decode as Decode
|
|
331
|
+
|
|
332
|
+
type alias UserProfile =
|
|
333
|
+
{ email : String
|
|
334
|
+
, name : Maybe String
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
userDecoder : Decode.Decoder UserProfile
|
|
338
|
+
userDecoder =
|
|
339
|
+
Decode.map2 UserProfile
|
|
340
|
+
(Decode.field "email" Decode.string)
|
|
341
|
+
(Decode.maybe (Decode.field "name" Decode.string))
|
|
342
|
+
|
|
343
|
+
page : Request -> Loader (Document Never)
|
|
344
|
+
page _ =
|
|
345
|
+
Loader.requireUser userDecoder "/login" (\\user ->
|
|
346
|
+
Loader.succeed (view user)
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
action : Request -> Action (Document Never)
|
|
350
|
+
action _ =
|
|
351
|
+
Action.fail 405 "Method not allowed"
|
|
352
|
+
|
|
353
|
+
view : UserProfile -> Document Never
|
|
354
|
+
view user =
|
|
355
|
+
Page.page
|
|
356
|
+
{ title = "Profile | elm-ssr"
|
|
357
|
+
, head = Shared.head
|
|
358
|
+
, body =
|
|
359
|
+
[ Shared.shell "User Profile"
|
|
360
|
+
[ div []
|
|
361
|
+
[ h2 [] [ text ("Welcome, " ++ (Maybe.withDefault user.email user.name)) ]
|
|
362
|
+
, p [] [ text ("Email: " ++ user.email) ]
|
|
363
|
+
, p [ Attr.style "margin-top" "20px" ]
|
|
364
|
+
[ a [ Attr.class "button", Attr.href "/api/auth/logout" ] [ text "Sign Out" ] ]
|
|
365
|
+
]
|
|
366
|
+
]
|
|
367
|
+
]
|
|
368
|
+
}
|
|
369
|
+
`;
|
|
370
|
+
|
|
371
|
+
const authEndpointTemplate = (authProvider) => `export const handleAuth = async (request: Request, env: any): Promise<Response> => {
|
|
372
|
+
const url = new URL(request.url);
|
|
373
|
+
|
|
374
|
+
if (url.pathname === "/api/auth/login") {
|
|
375
|
+
// Mock login session update for ${authProvider}
|
|
376
|
+
return new Response(null, {
|
|
377
|
+
status: 302,
|
|
378
|
+
headers: {
|
|
379
|
+
"Location": "/profile",
|
|
380
|
+
"Set-Cookie": "__elm_ssr_session=" + encodeURIComponent(JSON.stringify({
|
|
381
|
+
user: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" }
|
|
382
|
+
})) + "; Path=/; HttpOnly; SameSite=Lax"
|
|
383
|
+
}
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
if (url.pathname === "/api/auth/logout") {
|
|
388
|
+
return new Response(null, {
|
|
389
|
+
status: 302,
|
|
390
|
+
headers: {
|
|
391
|
+
"Location": "/login",
|
|
392
|
+
"Set-Cookie": "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly"
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
return new Response("${authProvider} Endpoint API Mock", { status: 200 });
|
|
398
|
+
};
|
|
399
|
+
`;
|
|
400
|
+
|
|
401
|
+
const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
|
|
276
402
|
// appRoot is a slash-separated path like "my-app" or "apps/my-app".
|
|
277
403
|
// The generated bundles live at <workspaceRoot>/generated/<appRoot>/. From
|
|
278
404
|
// <workspaceRoot>/<appRoot>/runtime.ts, we climb out by one ".." per segment.
|
|
279
405
|
const upToRoot = appRoot === "." ? "." : appRoot.split("/").map(() => "..").join("/");
|
|
280
406
|
const generatedPrefix = `${upToRoot}/generated/${appRoot === "." ? "" : appRoot}`.replace(/\/+$/, "");
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
import
|
|
284
|
-
import {
|
|
285
|
-
import {
|
|
286
|
-
|
|
287
|
-
import
|
|
407
|
+
|
|
408
|
+
const imports = [
|
|
409
|
+
`import { createWorkerApp } from "elm-ssr";`,
|
|
410
|
+
`import { renderApp, type CompiledElmModule } from "elm-ssr/render";`,
|
|
411
|
+
`import type { RouteCatalog } from "elm-ssr/http";`,
|
|
412
|
+
`import { islands, bundleSource } from "${generatedPrefix}/islands-manifest";`,
|
|
413
|
+
`import { stylesheet } from "./styles";`,
|
|
414
|
+
`// @ts-expect-error Generated at build time.`,
|
|
415
|
+
`import ElmRuntime from "${generatedPrefix}/app.mjs";`
|
|
416
|
+
];
|
|
417
|
+
|
|
418
|
+
if (db) {
|
|
419
|
+
imports.push(`import { Database } from "bun:sqlite";`);
|
|
420
|
+
imports.push(`import { inMemoryEffects } from "elm-ssr/effects";`);
|
|
421
|
+
}
|
|
422
|
+
if (auth) {
|
|
423
|
+
imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
let dbInit = '';
|
|
427
|
+
if (db) {
|
|
428
|
+
dbInit = `\nconst db = new Database("app.db");\n`;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
let routeAuthAdditions = '';
|
|
432
|
+
if (auth) {
|
|
433
|
+
routeAuthAdditions = `
|
|
434
|
+
{
|
|
435
|
+
path: "/login",
|
|
436
|
+
methods: ["GET"],
|
|
437
|
+
description: "User authentication login page."
|
|
438
|
+
},
|
|
439
|
+
{
|
|
440
|
+
path: "/profile",
|
|
441
|
+
methods: ["GET"],
|
|
442
|
+
description: "Authenticated user profile."
|
|
443
|
+
},`;
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let effectsConfig = '';
|
|
447
|
+
if (db) {
|
|
448
|
+
effectsConfig = `,
|
|
449
|
+
effects: inMemoryEffects({
|
|
450
|
+
env: process.env as any,
|
|
451
|
+
sql: (sql, params) => {
|
|
452
|
+
const query = db.prepare(sql);
|
|
453
|
+
return query.all(...params);
|
|
454
|
+
}
|
|
455
|
+
})`;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
let sessionsConfig = '';
|
|
459
|
+
if (auth) {
|
|
460
|
+
sessionsConfig = `,
|
|
461
|
+
sessions: {
|
|
462
|
+
secret: (env) => (env?.SESSION_SECRET as string) || "change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars",
|
|
463
|
+
secure: false
|
|
464
|
+
},
|
|
465
|
+
csrf: true`;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
let authIntercept = '';
|
|
469
|
+
if (auth) {
|
|
470
|
+
authIntercept = `
|
|
471
|
+
// Intercept Auth Provider requests
|
|
472
|
+
const baseWorkerFetch = worker.fetch;
|
|
473
|
+
worker.fetch = async (request, env, ctx) => {
|
|
474
|
+
const url = new URL(request.url);
|
|
475
|
+
if (url.pathname.startsWith("/api/auth/")) {
|
|
476
|
+
return handleAuth(request, env);
|
|
477
|
+
}
|
|
478
|
+
return baseWorkerFetch(request, env, ctx);
|
|
479
|
+
};
|
|
480
|
+
`;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
return `${imports.join("\n")}
|
|
288
484
|
|
|
289
485
|
const elmModule = ElmRuntime as CompiledElmModule;
|
|
290
486
|
|
|
@@ -299,7 +495,7 @@ export const routes: RouteCatalog = {
|
|
|
299
495
|
path: "/counter",
|
|
300
496
|
methods: ["GET", "HEAD"],
|
|
301
497
|
description: "Interactive counter route rendered from Elm."
|
|
302
|
-
}
|
|
498
|
+
},${routeAuthAdditions}
|
|
303
499
|
],
|
|
304
500
|
assets: [
|
|
305
501
|
{
|
|
@@ -344,28 +540,39 @@ export const routes: RouteCatalog = {
|
|
|
344
540
|
]
|
|
345
541
|
};
|
|
346
542
|
|
|
347
|
-
export const createFlags = ({ request, path, formData }: { request?: Request; url?: URL; path: string; formData?: Record<string, string> }) => {
|
|
543
|
+
export const createFlags = ({ request, path, formData, env }: { request?: Request; url?: URL; path: string; formData?: Record<string, string>; env?: Record<string, unknown> }) => {
|
|
348
544
|
const [pathname, search = ""] = path.split("?");
|
|
349
545
|
|
|
546
|
+
const envVars: Record<string, string | number | boolean> = {};
|
|
547
|
+
if (env) {
|
|
548
|
+
for (const [key, value] of Object.entries(env)) {
|
|
549
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
550
|
+
envVars[key] = value;
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
|
|
350
555
|
return {
|
|
351
556
|
method: request?.method ?? "GET",
|
|
352
557
|
path: pathname,
|
|
353
558
|
query: Object.fromEntries(new URLSearchParams(search)),
|
|
354
|
-
formData: formData ?? {}
|
|
559
|
+
formData: formData ?? {},
|
|
560
|
+
env: envVars
|
|
355
561
|
};
|
|
356
562
|
};
|
|
357
563
|
|
|
358
564
|
export const renderPath = async (path: string) =>
|
|
359
565
|
renderApp(elmModule, createFlags({ path }));
|
|
360
|
-
|
|
566
|
+
${dbInit}
|
|
361
567
|
export const worker = createWorkerApp({
|
|
362
568
|
elmModule,
|
|
363
569
|
islands,
|
|
364
570
|
islandsBundle: bundleSource,
|
|
365
571
|
stylesheet,
|
|
366
572
|
routes,
|
|
367
|
-
createFlags
|
|
573
|
+
createFlags${sessionsConfig}${effectsConfig}
|
|
368
574
|
});
|
|
575
|
+
${authIntercept}
|
|
369
576
|
`;
|
|
370
577
|
};
|
|
371
578
|
|
|
@@ -433,8 +640,56 @@ body {
|
|
|
433
640
|
\`;
|
|
434
641
|
`;
|
|
435
642
|
|
|
436
|
-
const filesForApp = (name, appRoot) => {
|
|
643
|
+
const filesForApp = (name, appRoot, options = {}) => {
|
|
437
644
|
const namespace = toPascalCase(name);
|
|
645
|
+
const db = options.db || !!options.auth;
|
|
646
|
+
const auth = options.auth;
|
|
647
|
+
|
|
648
|
+
const files = [
|
|
649
|
+
{ path: `${appRoot}/elm.json`, content: JSON.stringify(elmJsonTemplate(), null, 2) + "\n" },
|
|
650
|
+
{ path: `${appRoot}/runtime.ts`, content: runtimeTemplate(appRoot, db, auth) },
|
|
651
|
+
{ path: `${appRoot}/worker.ts`, content: workerTemplate() },
|
|
652
|
+
{ path: `${appRoot}/styles.ts`, content: stylesTemplate() },
|
|
653
|
+
{ path: `${appRoot}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace) },
|
|
654
|
+
{ path: `${appRoot}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace) },
|
|
655
|
+
{ path: `${appRoot}/src/${namespace}/Routes/Counter.elm`, content: counterRouteTemplate(namespace) },
|
|
656
|
+
{ path: `${appRoot}/src/${namespace}/Routes/NotFound.elm`, content: notFoundRouteTemplate(namespace) },
|
|
657
|
+
{ path: `${appRoot}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) },
|
|
658
|
+
{
|
|
659
|
+
path: `${appRoot}/.dev.vars`,
|
|
660
|
+
content: `GREETING="Hello from your local .dev.vars file!"
|
|
661
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
662
|
+
`
|
|
663
|
+
}
|
|
664
|
+
];
|
|
665
|
+
|
|
666
|
+
if (db) {
|
|
667
|
+
files.push({
|
|
668
|
+
path: `${appRoot}/migrations/0001_init.sql`,
|
|
669
|
+
content: `CREATE TABLE IF NOT EXISTS users (
|
|
670
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
671
|
+
email TEXT NOT NULL UNIQUE,
|
|
672
|
+
name TEXT,
|
|
673
|
+
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
674
|
+
);
|
|
675
|
+
`
|
|
676
|
+
});
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
if (auth) {
|
|
680
|
+
files.push({
|
|
681
|
+
path: `${appRoot}/src/${namespace}/Routes/Login.elm`,
|
|
682
|
+
content: loginRouteTemplate(namespace, auth)
|
|
683
|
+
});
|
|
684
|
+
files.push({
|
|
685
|
+
path: `${appRoot}/src/${namespace}/Routes/Profile.elm`,
|
|
686
|
+
content: profileRouteTemplate(namespace)
|
|
687
|
+
});
|
|
688
|
+
files.push({
|
|
689
|
+
path: `${appRoot}/src/Endpoints/Auth.ts`,
|
|
690
|
+
content: authEndpointTemplate(auth)
|
|
691
|
+
});
|
|
692
|
+
}
|
|
438
693
|
|
|
439
694
|
return {
|
|
440
695
|
configEntry: {
|
|
@@ -442,17 +697,7 @@ const filesForApp = (name, appRoot) => {
|
|
|
442
697
|
root: appRoot,
|
|
443
698
|
module: namespace
|
|
444
699
|
},
|
|
445
|
-
files
|
|
446
|
-
{ path: `${appRoot}/elm.json`, content: JSON.stringify(elmJsonTemplate(), null, 2) + "\n" },
|
|
447
|
-
{ path: `${appRoot}/runtime.ts`, content: runtimeTemplate(appRoot) },
|
|
448
|
-
{ path: `${appRoot}/worker.ts`, content: workerTemplate() },
|
|
449
|
-
{ path: `${appRoot}/styles.ts`, content: stylesTemplate() },
|
|
450
|
-
{ path: `${appRoot}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace) },
|
|
451
|
-
{ path: `${appRoot}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace) },
|
|
452
|
-
{ path: `${appRoot}/src/${namespace}/Routes/Counter.elm`, content: counterRouteTemplate(namespace) },
|
|
453
|
-
{ path: `${appRoot}/src/${namespace}/Routes/NotFound.elm`, content: notFoundRouteTemplate(namespace) },
|
|
454
|
-
{ path: `${appRoot}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) }
|
|
455
|
-
]
|
|
700
|
+
files
|
|
456
701
|
};
|
|
457
702
|
};
|
|
458
703
|
|
|
@@ -521,7 +766,7 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
|
|
|
521
766
|
|
|
522
767
|
const appRoot = normalizeAppRoot(options.root, name);
|
|
523
768
|
|
|
524
|
-
const { configEntry, files } = filesForApp(name, appRoot);
|
|
769
|
+
const { configEntry, files } = filesForApp(name, appRoot, options);
|
|
525
770
|
|
|
526
771
|
for (const file of files) {
|
|
527
772
|
const filePath = resolve(rootPath, file.path);
|
|
@@ -529,6 +774,20 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
|
|
|
529
774
|
await writeFile(filePath, file.content, "utf8");
|
|
530
775
|
}
|
|
531
776
|
|
|
777
|
+
// Create .env at workspace root if it doesn't exist
|
|
778
|
+
const envPath = resolve(rootPath, ".env");
|
|
779
|
+
let envExists = false;
|
|
780
|
+
try {
|
|
781
|
+
await stat(envPath);
|
|
782
|
+
envExists = true;
|
|
783
|
+
} catch {}
|
|
784
|
+
|
|
785
|
+
if (!envExists) {
|
|
786
|
+
await writeFile(envPath, `GREETING="Hello from your local .env file!"
|
|
787
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
788
|
+
`, "utf8");
|
|
789
|
+
}
|
|
790
|
+
|
|
532
791
|
await writeWorkspaceConfig(rootPath, {
|
|
533
792
|
...config,
|
|
534
793
|
apps: [...config.apps, configEntry]
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elm-ssr",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.91.1",
|
|
4
4
|
"description": "Elm-first SSR library and framework for Cloudflare Workers (and Bun): file-based routes/islands, backend-neutral effect adapters (KV/D1/Redis/Postgres), background tasks (waitUntil/Queues), SQL-file migrations, CLI scaffold + build.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Michał Majchrzak <michmajchrzak@gmail.com>",
|
package/src/app.ts
CHANGED
|
@@ -107,7 +107,7 @@ export const createWorkerApp = ({
|
|
|
107
107
|
requestId: "",
|
|
108
108
|
startedAt: performance.now(),
|
|
109
109
|
executionCtx,
|
|
110
|
-
env: (env ?? undefined) as Record<string, unknown> | undefined
|
|
110
|
+
env: (env ?? (typeof process !== "undefined" ? process.env : undefined)) as Record<string, unknown> | undefined
|
|
111
111
|
});
|
|
112
112
|
}
|
|
113
113
|
};
|
|
@@ -27,7 +27,26 @@ function createIslandsRuntime(deps) {
|
|
|
27
27
|
|
|
28
28
|
if (app.ports.broadcastOut) {
|
|
29
29
|
app.ports.broadcastOut.subscribe((event) => {
|
|
30
|
-
|
|
30
|
+
const detail = {
|
|
31
|
+
...event,
|
|
32
|
+
__elmssr_origin: {
|
|
33
|
+
name: marker.getAttribute("data-elmssr-island"),
|
|
34
|
+
id: marker.getAttribute("data-elmssr-id")
|
|
35
|
+
}
|
|
36
|
+
};
|
|
37
|
+
window.dispatchEvent(new window.CustomEvent("elm-ssr-broadcast", { detail }));
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (app.ports.stateOut) {
|
|
42
|
+
app.ports.stateOut.subscribe((state) => {
|
|
43
|
+
window.dispatchEvent(new window.CustomEvent("elm-ssr-state-update", {
|
|
44
|
+
detail: {
|
|
45
|
+
name: marker.getAttribute("data-elmssr-island"),
|
|
46
|
+
id: marker.getAttribute("data-elmssr-id"),
|
|
47
|
+
state
|
|
48
|
+
}
|
|
49
|
+
}));
|
|
31
50
|
});
|
|
32
51
|
}
|
|
33
52
|
|
package/src/debugger.ts
CHANGED
|
@@ -396,8 +396,8 @@ export const injectDebugger = (html: string, data: unknown): string => {
|
|
|
396
396
|
highlight.style.display = 'none';
|
|
397
397
|
};
|
|
398
398
|
|
|
399
|
-
// Track island mutations to detect client-side state updates
|
|
400
399
|
const islandMutations = new Map();
|
|
400
|
+
const islandActiveStates = new Map();
|
|
401
401
|
const activeObservers = new Map();
|
|
402
402
|
|
|
403
403
|
const setupMutationObservers = () => {
|
|
@@ -428,6 +428,16 @@ export const injectDebugger = (html: string, data: unknown): string => {
|
|
|
428
428
|
});
|
|
429
429
|
};
|
|
430
430
|
|
|
431
|
+
const findMarkerByOrigin = (origin) => {
|
|
432
|
+
if (!origin) return null;
|
|
433
|
+
const markers = Array.from(document.getElementsByTagName('elm-ssr-island'));
|
|
434
|
+
return markers.find(marker => {
|
|
435
|
+
const name = marker.getAttribute('data-elmssr-island');
|
|
436
|
+
const id = marker.getAttribute('data-elmssr-id');
|
|
437
|
+
return name === origin.name && (id === origin.id || (!id && !origin.id));
|
|
438
|
+
});
|
|
439
|
+
};
|
|
440
|
+
|
|
431
441
|
const scanIslands = () => {
|
|
432
442
|
const list = panel.querySelector('#debug-islands-list');
|
|
433
443
|
const markers = Array.from(document.getElementsByTagName('elm-ssr-island'));
|
|
@@ -449,6 +459,22 @@ export const injectDebugger = (html: string, data: unknown): string => {
|
|
|
449
459
|
? \`Last active DOM mutation: \${stats.lastUpdate.toLocaleTimeString()}\`
|
|
450
460
|
: 'No client state changes recorded';
|
|
451
461
|
|
|
462
|
+
const activeState = islandActiveStates.get(marker);
|
|
463
|
+
const domTextPreview = marker.textContent.trim().replace(/\\s+/g, ' ');
|
|
464
|
+
|
|
465
|
+
let stateSnippet = '';
|
|
466
|
+
if (activeState) {
|
|
467
|
+
stateSnippet = \`
|
|
468
|
+
<div style="font-size: 0.85em; margin-top: 6px; color: #a5b4fc;"><strong>Active Model State:</strong></div>
|
|
469
|
+
<pre class="debug-code" style="font-size: 0.85em; max-height: 120px; overflow-y: auto; color: #818cf8;">\${JSON.stringify(activeState, null, 2)}</pre>
|
|
470
|
+
\`;
|
|
471
|
+
} else if (domTextPreview) {
|
|
472
|
+
stateSnippet = \`
|
|
473
|
+
<div style="font-size: 0.85em; margin-top: 6px; color: #9ca3af;"><strong>DOM Text Preview (Live):</strong></div>
|
|
474
|
+
<div class="debug-code" style="font-size: 0.85em; color: #9ca3af; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">\${domTextPreview}</div>
|
|
475
|
+
\`;
|
|
476
|
+
}
|
|
477
|
+
|
|
452
478
|
const li = document.createElement('li');
|
|
453
479
|
li.className = 'debug-item';
|
|
454
480
|
li.style.cursor = 'pointer';
|
|
@@ -465,6 +491,7 @@ export const injectDebugger = (html: string, data: unknown): string => {
|
|
|
465
491
|
<span style="color: #34d399;"><strong>DOM Mutations:</strong> \${stats.count}</span>
|
|
466
492
|
</div>
|
|
467
493
|
<div style="font-size: 0.75em; color: #9ca3af; margin-top: 2px;">\${lastUpdateStr}</div>
|
|
494
|
+
\${stateSnippet}
|
|
468
495
|
<div style="font-size: 0.8em; margin-top: 6px; color: #e5e7eb;"><strong>Initial Props:</strong></div>
|
|
469
496
|
<pre class="debug-code" style="font-size: 0.85em; max-height: 80px; overflow-y: auto;">\${JSON.stringify(JSON.parse(props), null, 2)}</pre>
|
|
470
497
|
</div>
|
|
@@ -479,6 +506,20 @@ export const injectDebugger = (html: string, data: unknown): string => {
|
|
|
479
506
|
panel.querySelector('[data-tab="islands"]').addEventListener('click', scanIslands);
|
|
480
507
|
setupMutationObservers();
|
|
481
508
|
|
|
509
|
+
// Listen to explicit state update ports
|
|
510
|
+
window.addEventListener('elm-ssr-state-update', (event) => {
|
|
511
|
+
if (event.detail) {
|
|
512
|
+
const marker = findMarkerByOrigin(event.detail);
|
|
513
|
+
if (marker) {
|
|
514
|
+
islandActiveStates.set(marker, event.detail.state);
|
|
515
|
+
const activeTab = panel.querySelector('.debug-tab.active')?.getAttribute('data-tab');
|
|
516
|
+
if (activeTab === 'islands') {
|
|
517
|
+
scanIslands();
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
});
|
|
522
|
+
|
|
482
523
|
const bridgeLog = panel.querySelector('#debug-bridges-log');
|
|
483
524
|
let firstBridge = true;
|
|
484
525
|
window.addEventListener('elm-ssr-broadcast', (event) => {
|
|
@@ -491,7 +532,18 @@ export const injectDebugger = (html: string, data: unknown): string => {
|
|
|
491
532
|
const payload = event.detail && event.detail.payload;
|
|
492
533
|
const time = new Date().toLocaleTimeString();
|
|
493
534
|
|
|
494
|
-
|
|
535
|
+
// Check if it is a state update event
|
|
536
|
+
if (tag === '__elmssr_state__' && event.detail.__elmssr_origin) {
|
|
537
|
+
const marker = findMarkerByOrigin(event.detail.__elmssr_origin);
|
|
538
|
+
if (marker) {
|
|
539
|
+
islandActiveStates.set(marker, payload);
|
|
540
|
+
const activeTab = panel.querySelector('.debug-tab.active')?.getAttribute('data-tab');
|
|
541
|
+
if (activeTab === 'islands') {
|
|
542
|
+
scanIslands();
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
const li = document.createElement('li');
|
|
495
547
|
li.className = 'debug-item';
|
|
496
548
|
li.innerHTML = \`
|
|
497
549
|
<div class="debug-item-meta" style="flex: 1;">
|
package/src/http.ts
CHANGED
package/src/request-handler.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type { RequestSession, SessionStore } from "./types";
|
|
|
6
6
|
|
|
7
7
|
export interface SessionMiddlewareOptions {
|
|
8
8
|
/** HMAC-SHA256 secret used to sign the session cookie. Must not leak. */
|
|
9
|
-
secret: string;
|
|
9
|
+
secret: string | ((env: any) => string);
|
|
10
10
|
/** Where to persist sessions. Use `memorySessionStore()` in dev, `cacheStore(...)` in prod. */
|
|
11
11
|
store: SessionStore;
|
|
12
12
|
/** Cookie name. Default `"session"`. */
|
|
@@ -87,9 +87,18 @@ export const sessionMiddleware = (options: SessionMiddlewareOptions): Middleware
|
|
|
87
87
|
sameSite: options.sameSite ?? "lax" as const
|
|
88
88
|
};
|
|
89
89
|
|
|
90
|
+
const resolveSecret = (env: any): string => {
|
|
91
|
+
if (typeof options.secret === "function") {
|
|
92
|
+
return options.secret(env);
|
|
93
|
+
}
|
|
94
|
+
return options.secret;
|
|
95
|
+
};
|
|
96
|
+
|
|
90
97
|
return async (context, next) => {
|
|
98
|
+
const env = context.env ?? {};
|
|
99
|
+
const secret = resolveSecret(env);
|
|
91
100
|
const cookieHeader = context.request.headers.get("cookie");
|
|
92
|
-
const sessionId = await readSignedCookie(cookieHeader, cookieName,
|
|
101
|
+
const sessionId = await readSignedCookie(cookieHeader, cookieName, secret);
|
|
93
102
|
|
|
94
103
|
let session: RequestSession;
|
|
95
104
|
if (sessionId) {
|
|
@@ -125,7 +134,7 @@ export const sessionMiddleware = (options: SessionMiddlewareOptions): Middleware
|
|
|
125
134
|
csrf: session.csrf,
|
|
126
135
|
expiresAt: Date.now() + maxAge * 1000
|
|
127
136
|
});
|
|
128
|
-
const signed = await signValue(
|
|
137
|
+
const signed = await signValue(secret, session.id);
|
|
129
138
|
return appendSetCookie(response, buildSetCookie(cookieName, signed, maxAge, cookieOptions));
|
|
130
139
|
}
|
|
131
140
|
|