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 CHANGED
@@ -26,21 +26,59 @@ bunx elm-ssr build
26
26
 
27
27
  ## CLI commands
28
28
 
29
- - **`elm-ssr build`** scans each configured app's `src/<Namespace>/Routes/`
30
- and `Islands/`, generates `.elm-ssr/Main.elm` (the router with file-based
31
- routes + dynamic segments) and the islands manifest, syncs the Elm authoring
32
- modules into the app's `.elm-ssr/src/ElmSsr/`, and compiles via `elm make`
33
- (one combined island bundle exposing `Elm.<App>.Islands.<Name>`).
34
- - **`elm-ssr new <name>`** — scaffold a new app and register it in
35
- `elm-ssr.config.json`.
36
- - **`elm-ssr dev`** — `build` then `wrangler dev` for Cloudflare-like local
37
- development. For other hosts, build with `elm-ssr build` and adapt
38
- `worker.fetch` in your own server/entrypoint.
39
- - **`elm-ssr compress`** — pre-compress generated bundles with gzip.
40
- - **`elm-ssr migrate <up|down|status>`** apply / revert / inspect SQL-file
41
- migrations. `--db postgres://…`, `sqlite://path`, or a plain SQLite file path;
42
- `--dir <path>` (default `./migrations`); `--count N` (for `down`, default 1).
43
- Reads `DATABASE_URL` if `--db` is omitted.
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
- new <name> Create a new app at <workspace>/<name>/ (or <workspace>/<subdir>/<name>/
81
- with --in <subdir>) and register it in elm-ssr.config.json
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 created = await createAppScaffold(rootPath, name, { root: "." });
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
- const created = await createAppScaffold(rootPath, name, { root: appRoot });
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.succeed view
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 "This page is stateless and renders on the edge with no client runtime." ]
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 runtimeTemplate = (appRoot) => {
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
- return `import { createWorkerApp } from "elm-ssr";
282
- import { renderApp, type CompiledElmModule } from "elm-ssr/render";
283
- import type { RouteCatalog } from "elm-ssr/http";
284
- import { islands, bundleSource } from "${generatedPrefix}/islands-manifest";
285
- import { stylesheet } from "./styles";
286
- // @ts-expect-error Generated at build time.
287
- import ElmRuntime from "${generatedPrefix}/app.mjs";
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.90.1",
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
- window.dispatchEvent(new window.CustomEvent("elm-ssr-broadcast", { detail: event }));
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
- const li = document.createElement('li');
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
@@ -3,6 +3,7 @@ export interface RenderFlagsContext {
3
3
  url: URL;
4
4
  path: string;
5
5
  formData?: Record<string, string>;
6
+ env?: Record<string, unknown>;
6
7
  }
7
8
 
8
9
  export interface WorkerExecutionContext {
@@ -97,7 +97,8 @@ const createFlagsFromContext = async (
97
97
  request: context.request,
98
98
  url: context.url,
99
99
  path,
100
- formData
100
+ formData,
101
+ env: context.env
101
102
  });
102
103
  };
103
104
 
@@ -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, options.secret);
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(options.secret, session.id);
137
+ const signed = await signValue(secret, session.id);
129
138
  return appendSetCookie(response, buildSetCookie(cookieName, signed, maxAge, cookieOptions));
130
139
  }
131
140