elm-ssr 0.90.0 → 0.91.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bin/elm-ssr.mjs 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,210 @@ 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 request =
345
+ Loader.requireUser userDecoder "/login" request
346
+ |> Loader.map view
347
+
348
+ action : Request -> Action (Document Never)
349
+ action _ =
350
+ Action.fail 405 "Method not allowed"
351
+
352
+ view : UserProfile -> Document Never
353
+ view user =
354
+ Page.page
355
+ { title = "Profile | elm-ssr"
356
+ , head = Shared.head
357
+ , body =
358
+ [ Shared.shell "User Profile"
359
+ [ div []
360
+ [ h2 [] [ text ("Welcome, " ++ (Maybe.withDefault user.email user.name)) ]
361
+ , p [] [ text ("Email: " ++ user.email) ]
362
+ , p [ Attr.style "margin-top" "20px" ]
363
+ [ a [ Attr.class "button", Attr.href "/api/auth/logout" ] [ text "Sign Out" ] ]
364
+ ]
365
+ ]
366
+ ]
367
+ }
368
+ `;
369
+
370
+ const authEndpointTemplate = (authProvider) => `export const handleAuth = async (request: Request, env: any): Promise<Response> => {
371
+ const url = new URL(request.url);
372
+
373
+ if (url.pathname === "/api/auth/login") {
374
+ // Mock login session update for ${authProvider}
375
+ return new Response(null, {
376
+ status: 302,
377
+ headers: {
378
+ "Location": "/profile",
379
+ "Set-Cookie": "__elm_ssr_session=" + encodeURIComponent(JSON.stringify({
380
+ user: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" }
381
+ })) + "; Path=/; HttpOnly; SameSite=Lax"
382
+ }
383
+ });
384
+ }
385
+
386
+ if (url.pathname === "/api/auth/logout") {
387
+ return new Response(null, {
388
+ status: 302,
389
+ headers: {
390
+ "Location": "/login",
391
+ "Set-Cookie": "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly"
392
+ }
393
+ });
394
+ }
395
+
396
+ return new Response("${authProvider} Endpoint API Mock", { status: 200 });
397
+ };
398
+ `;
399
+
400
+ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
276
401
  // appRoot is a slash-separated path like "my-app" or "apps/my-app".
277
402
  // The generated bundles live at <workspaceRoot>/generated/<appRoot>/. From
278
403
  // <workspaceRoot>/<appRoot>/runtime.ts, we climb out by one ".." per segment.
279
404
  const upToRoot = appRoot === "." ? "." : appRoot.split("/").map(() => "..").join("/");
280
405
  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";
406
+
407
+ const imports = [
408
+ `import { createWorkerApp } from "elm-ssr";`,
409
+ `import { renderApp, type CompiledElmModule } from "elm-ssr/render";`,
410
+ `import type { RouteCatalog } from "elm-ssr/http";`,
411
+ `import { islands, bundleSource } from "${generatedPrefix}/islands-manifest";`,
412
+ `import { stylesheet } from "./styles";`,
413
+ `// @ts-expect-error Generated at build time.`,
414
+ `import ElmRuntime from "${generatedPrefix}/app.mjs";`
415
+ ];
416
+
417
+ if (db) {
418
+ imports.push(`import { Database } from "bun:sqlite";`);
419
+ imports.push(`import { inMemoryEffects } from "elm-ssr/effects";`);
420
+ }
421
+ if (auth) {
422
+ imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
423
+ }
424
+
425
+ let dbInit = '';
426
+ if (db) {
427
+ dbInit = `\nconst db = new Database("app.db");\n`;
428
+ }
429
+
430
+ let routeAuthAdditions = '';
431
+ if (auth) {
432
+ routeAuthAdditions = `
433
+ {
434
+ path: "/login",
435
+ methods: ["GET"],
436
+ description: "User authentication login page."
437
+ },
438
+ {
439
+ path: "/profile",
440
+ methods: ["GET"],
441
+ description: "Authenticated user profile."
442
+ },`;
443
+ }
444
+
445
+ let effectsConfig = '';
446
+ if (db) {
447
+ effectsConfig = `,
448
+ effects: inMemoryEffects({
449
+ env: process.env as any,
450
+ sql: (sql, params) => {
451
+ const query = db.prepare(sql);
452
+ return query.all(...params);
453
+ }
454
+ })`;
455
+ }
456
+
457
+ let sessionsConfig = '';
458
+ if (auth) {
459
+ sessionsConfig = `,
460
+ sessions: {
461
+ secret: (env) => (env?.SESSION_SECRET as string) || "change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars",
462
+ secure: false
463
+ },
464
+ csrf: true`;
465
+ }
466
+
467
+ let authIntercept = '';
468
+ if (auth) {
469
+ authIntercept = `
470
+ // Intercept Auth Provider requests
471
+ const baseWorkerFetch = worker.fetch;
472
+ worker.fetch = async (request, env, ctx) => {
473
+ const url = new URL(request.url);
474
+ if (url.pathname.startsWith("/api/auth/")) {
475
+ return handleAuth(request, env);
476
+ }
477
+ return baseWorkerFetch(request, env, ctx);
478
+ };
479
+ `;
480
+ }
481
+
482
+ return `${imports.join("\n")}
288
483
 
289
484
  const elmModule = ElmRuntime as CompiledElmModule;
290
485
 
@@ -299,7 +494,7 @@ export const routes: RouteCatalog = {
299
494
  path: "/counter",
300
495
  methods: ["GET", "HEAD"],
301
496
  description: "Interactive counter route rendered from Elm."
302
- }
497
+ },${routeAuthAdditions}
303
498
  ],
304
499
  assets: [
305
500
  {
@@ -344,28 +539,39 @@ export const routes: RouteCatalog = {
344
539
  ]
345
540
  };
346
541
 
347
- export const createFlags = ({ request, path, formData }: { request?: Request; url?: URL; path: string; formData?: Record<string, string> }) => {
542
+ export const createFlags = ({ request, path, formData, env }: { request?: Request; url?: URL; path: string; formData?: Record<string, string>; env?: Record<string, unknown> }) => {
348
543
  const [pathname, search = ""] = path.split("?");
349
544
 
545
+ const envVars: Record<string, string | number | boolean> = {};
546
+ if (env) {
547
+ for (const [key, value] of Object.entries(env)) {
548
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
549
+ envVars[key] = value;
550
+ }
551
+ }
552
+ }
553
+
350
554
  return {
351
555
  method: request?.method ?? "GET",
352
556
  path: pathname,
353
557
  query: Object.fromEntries(new URLSearchParams(search)),
354
- formData: formData ?? {}
558
+ formData: formData ?? {},
559
+ env: envVars
355
560
  };
356
561
  };
357
562
 
358
563
  export const renderPath = async (path: string) =>
359
564
  renderApp(elmModule, createFlags({ path }));
360
-
565
+ ${dbInit}
361
566
  export const worker = createWorkerApp({
362
567
  elmModule,
363
568
  islands,
364
569
  islandsBundle: bundleSource,
365
570
  stylesheet,
366
571
  routes,
367
- createFlags
572
+ createFlags${sessionsConfig}${effectsConfig}
368
573
  });
574
+ ${authIntercept}
369
575
  `;
370
576
  };
371
577
 
@@ -433,8 +639,56 @@ body {
433
639
  \`;
434
640
  `;
435
641
 
436
- const filesForApp = (name, appRoot) => {
642
+ const filesForApp = (name, appRoot, options = {}) => {
437
643
  const namespace = toPascalCase(name);
644
+ const db = options.db || !!options.auth;
645
+ const auth = options.auth;
646
+
647
+ const files = [
648
+ { path: `${appRoot}/elm.json`, content: JSON.stringify(elmJsonTemplate(), null, 2) + "\n" },
649
+ { path: `${appRoot}/runtime.ts`, content: runtimeTemplate(appRoot, db, auth) },
650
+ { path: `${appRoot}/worker.ts`, content: workerTemplate() },
651
+ { path: `${appRoot}/styles.ts`, content: stylesTemplate() },
652
+ { path: `${appRoot}/src/${namespace}/View/Shared.elm`, content: sharedTemplate(namespace) },
653
+ { path: `${appRoot}/src/${namespace}/Routes/Index.elm`, content: indexRouteTemplate(namespace) },
654
+ { path: `${appRoot}/src/${namespace}/Routes/Counter.elm`, content: counterRouteTemplate(namespace) },
655
+ { path: `${appRoot}/src/${namespace}/Routes/NotFound.elm`, content: notFoundRouteTemplate(namespace) },
656
+ { path: `${appRoot}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) },
657
+ {
658
+ path: `${appRoot}/.dev.vars`,
659
+ content: `GREETING="Hello from your local .dev.vars file!"
660
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
661
+ `
662
+ }
663
+ ];
664
+
665
+ if (db) {
666
+ files.push({
667
+ path: `${appRoot}/migrations/0001_init.sql`,
668
+ content: `CREATE TABLE IF NOT EXISTS users (
669
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
670
+ email TEXT NOT NULL UNIQUE,
671
+ name TEXT,
672
+ created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
673
+ );
674
+ `
675
+ });
676
+ }
677
+
678
+ if (auth) {
679
+ files.push({
680
+ path: `${appRoot}/src/${namespace}/Routes/Login.elm`,
681
+ content: loginRouteTemplate(namespace, auth)
682
+ });
683
+ files.push({
684
+ path: `${appRoot}/src/${namespace}/Routes/Profile.elm`,
685
+ content: profileRouteTemplate(namespace)
686
+ });
687
+ files.push({
688
+ path: `${appRoot}/src/Endpoints/Auth.ts`,
689
+ content: authEndpointTemplate(auth)
690
+ });
691
+ }
438
692
 
439
693
  return {
440
694
  configEntry: {
@@ -442,17 +696,7 @@ const filesForApp = (name, appRoot) => {
442
696
  root: appRoot,
443
697
  module: namespace
444
698
  },
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
- ]
699
+ files
456
700
  };
457
701
  };
458
702
 
@@ -521,7 +765,7 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
521
765
 
522
766
  const appRoot = normalizeAppRoot(options.root, name);
523
767
 
524
- const { configEntry, files } = filesForApp(name, appRoot);
768
+ const { configEntry, files } = filesForApp(name, appRoot, options);
525
769
 
526
770
  for (const file of files) {
527
771
  const filePath = resolve(rootPath, file.path);
@@ -529,6 +773,20 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
529
773
  await writeFile(filePath, file.content, "utf8");
530
774
  }
531
775
 
776
+ // Create .env at workspace root if it doesn't exist
777
+ const envPath = resolve(rootPath, ".env");
778
+ let envExists = false;
779
+ try {
780
+ await stat(envPath);
781
+ envExists = true;
782
+ } catch {}
783
+
784
+ if (!envExists) {
785
+ await writeFile(envPath, `GREETING="Hello from your local .env file!"
786
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
787
+ `, "utf8");
788
+ }
789
+
532
790
  await writeWorkspaceConfig(rootPath, {
533
791
  ...config,
534
792
  apps: [...config.apps, configEntry]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elm-ssr",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
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
@@ -235,38 +235,51 @@ export const injectDebugger = (html: string, data: unknown): string => {
235
235
  toggleBtn.id = 'elm-ssr-debug-toggle';
236
236
  toggleBtn.innerHTML = '⚡ Debug';
237
237
  document.body.appendChild(toggleBtn);
238
-
238
+
239
239
  // 4. Create Panel DOM
240
240
  const panel = document.createElement('div');
241
241
  panel.id = 'elm-ssr-debug-panel';
242
242
 
243
- let effectsHtml = '';
244
- if (data.effects && data.effects.length > 0) {
245
- effectsHtml = data.effects.map(eff => {
246
- const isSql = ['query', 'queryOne', 'execute'].includes(eff.kind);
247
- const sqlSnippet = isSql ? \`<pre class="debug-code">\${eff.payload.sql}</pre>\` : '';
248
- const paramsSnippet = isSql && eff.payload.params && eff.payload.params.length > 0 ?
249
- \`<div><strong>Params:</strong> \${JSON.stringify(eff.payload.params)}</div>\` : '';
250
- const outcome = eff.ok ? '<span class="status-ok">SUCCESS</span>' : '<span class="status-error">FAILED</span>';
251
- const detailError = eff.error ? \`<div class="status-error">\${eff.error}</div>\` : '';
252
- return \`
253
- <li class="debug-item">
254
- <div class="debug-item-meta" style="flex: 1;">
255
- <div style="display: flex; gap: 8px; align-items: center;">
256
- <span class="debug-badge badge-primary">\${eff.kind}</span>
257
- \${outcome}
258
- <span style="color: #9ca3af; font-size: 0.85em;">\${eff.durationMs.toFixed(1)}ms</span>
259
- </div>
260
- \${sqlSnippet}
261
- \${paramsSnippet}
262
- \${detailError}
243
+ const isSqlKind = (kind) => ['query', 'queryOne', 'execute'].includes(kind);
244
+
245
+ const generateEffectItemHtml = (eff) => {
246
+ const isSql = isSqlKind(eff.kind);
247
+ const sqlSnippet = isSql ? \`<pre class="debug-code">\${eff.payload.sql}</pre>\` : '';
248
+ const paramsSnippet = isSql && eff.payload.params && eff.payload.params.length > 0 ?
249
+ \`<div><strong>Params:</strong> \${JSON.stringify(eff.payload.params)}</div>\` : '';
250
+ const outcome = eff.ok ? '<span class="status-ok">SUCCESS</span>' : '<span class="status-error">FAILED</span>';
251
+ const detailError = eff.error ? \`<div class="status-error">\${eff.error}</div>\` : '';
252
+ return \`
253
+ <li class="debug-item">
254
+ <div class="debug-item-meta" style="flex: 1;">
255
+ <div style="display: flex; gap: 8px; align-items: center;">
256
+ <span class="debug-badge badge-primary">\${eff.kind}</span>
257
+ \${outcome}
258
+ <span style="color: #9ca3af; font-size: 0.85em;">\${eff.durationMs.toFixed(1)}ms</span>
263
259
  </div>
264
- </li>
265
- \`;
266
- }).join('');
267
- } else {
268
- effectsHtml = '<div style="color: #9ca3af; text-align: center; padding: 20px;">No server-side effects executed for this request.</div>';
269
- }
260
+ \${sqlSnippet}
261
+ \${paramsSnippet}
262
+ \${detailError}
263
+ </div>
264
+ </li>
265
+ \`;
266
+ };
267
+
268
+ const getDbHtml = (effects) => {
269
+ const dbEffects = (effects || []).filter(eff => isSqlKind(eff.kind));
270
+ if (dbEffects.length === 0) {
271
+ return '<div style="color: #9ca3af; text-align: center; padding: 20px;">No SQL database queries executed for this request.</div>';
272
+ }
273
+ return \`<ul class="debug-list">\${dbEffects.map(generateEffectItemHtml).join('')}</ul>\`;
274
+ };
275
+
276
+ const getEffectsHtml = (effects) => {
277
+ const nonDbEffects = (effects || []).filter(eff => !isSqlKind(eff.kind));
278
+ if (nonDbEffects.length === 0) {
279
+ return '<div style="color: #9ca3af; text-align: center; padding: 20px;">No other server-side effects executed for this request.</div>';
280
+ }
281
+ return \`<ul class="debug-list">\${nonDbEffects.map(generateEffectItemHtml).join('')}</ul>\`;
282
+ };
270
283
 
271
284
  panel.innerHTML = \`
272
285
  <div class="debug-header">
@@ -276,6 +289,7 @@ export const injectDebugger = (html: string, data: unknown): string => {
276
289
  <div class="debug-tabs">
277
290
  <button class="debug-tab active" data-tab="overview">Overview</button>
278
291
  <button class="debug-tab" data-tab="islands">Islands</button>
292
+ <button class="debug-tab" data-tab="database">Database</button>
279
293
  <button class="debug-tab" data-tab="effects">Effects</button>
280
294
  <button class="debug-tab" data-tab="session">Session</button>
281
295
  <button class="debug-tab" data-tab="bridges">Bridges</button>
@@ -313,11 +327,14 @@ export const injectDebugger = (html: string, data: unknown): string => {
313
327
  </ul>
314
328
  </div>
315
329
 
330
+ <!-- DATABASE TAB -->
331
+ <div class="debug-pane" id="pane-database">
332
+ \${getDbHtml(data.effects)}
333
+ </div>
334
+
316
335
  <!-- EFFECTS TAB -->
317
336
  <div class="debug-pane" id="pane-effects">
318
- <ul class="debug-list">
319
- \${effectsHtml}
320
- </ul>
337
+ \${getEffectsHtml(data.effects)}
321
338
  </div>
322
339
 
323
340
  <!-- SESSION TAB -->
@@ -379,6 +396,48 @@ export const injectDebugger = (html: string, data: unknown): string => {
379
396
  highlight.style.display = 'none';
380
397
  };
381
398
 
399
+ const islandMutations = new Map();
400
+ const islandActiveStates = new Map();
401
+ const activeObservers = new Map();
402
+
403
+ const setupMutationObservers = () => {
404
+ for (const obs of activeObservers.values()) {
405
+ obs.disconnect();
406
+ }
407
+ activeObservers.clear();
408
+
409
+ const markers = Array.from(document.getElementsByTagName('elm-ssr-island'));
410
+ markers.forEach(marker => {
411
+ if (!islandMutations.has(marker)) {
412
+ islandMutations.set(marker, { count: 0, lastUpdate: null });
413
+ }
414
+
415
+ const observer = new MutationObserver(() => {
416
+ const stats = islandMutations.get(marker);
417
+ stats.count += 1;
418
+ stats.lastUpdate = new Date();
419
+
420
+ const activeTab = panel.querySelector('.debug-tab.active')?.getAttribute('data-tab');
421
+ if (activeTab === 'islands') {
422
+ scanIslands();
423
+ }
424
+ });
425
+
426
+ observer.observe(marker, { childList: true, subtree: true, characterData: true, attributes: true });
427
+ activeObservers.set(marker, observer);
428
+ });
429
+ };
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
+
382
441
  const scanIslands = () => {
383
442
  const list = panel.querySelector('#debug-islands-list');
384
443
  const markers = Array.from(document.getElementsByTagName('elm-ssr-island'));
@@ -395,6 +454,27 @@ export const injectDebugger = (html: string, data: unknown): string => {
395
454
  const isBooted = marker.getAttribute('data-elmssr-booted') === 'true';
396
455
  const id = marker.getAttribute('data-elmssr-id') || 'none';
397
456
 
457
+ const stats = islandMutations.get(marker) || { count: 0, lastUpdate: null };
458
+ const lastUpdateStr = stats.lastUpdate
459
+ ? \`Last active DOM mutation: \${stats.lastUpdate.toLocaleTimeString()}\`
460
+ : 'No client state changes recorded';
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
+
398
478
  const li = document.createElement('li');
399
479
  li.className = 'debug-item';
400
480
  li.style.cursor = 'pointer';
@@ -406,9 +486,13 @@ export const injectDebugger = (html: string, data: unknown): string => {
406
486
  \${isBooted ? 'BOOTED' : 'INITIALIZING'}
407
487
  </span>
408
488
  </div>
409
- <div style="font-size: 0.8em; margin-top: 4px; color: #9ca3af;">
489
+ <div style="font-size: 0.8em; margin-top: 4px; color: #9ca3af; display: flex; justify-content: space-between;">
410
490
  <span><strong>ID:</strong> \${id}</span>
491
+ <span style="color: #34d399;"><strong>DOM Mutations:</strong> \${stats.count}</span>
411
492
  </div>
493
+ <div style="font-size: 0.75em; color: #9ca3af; margin-top: 2px;">\${lastUpdateStr}</div>
494
+ \${stateSnippet}
495
+ <div style="font-size: 0.8em; margin-top: 6px; color: #e5e7eb;"><strong>Initial Props:</strong></div>
412
496
  <pre class="debug-code" style="font-size: 0.85em; max-height: 80px; overflow-y: auto;">\${JSON.stringify(JSON.parse(props), null, 2)}</pre>
413
497
  </div>
414
498
  \`;
@@ -420,6 +504,21 @@ export const injectDebugger = (html: string, data: unknown): string => {
420
504
  };
421
505
 
422
506
  panel.querySelector('[data-tab="islands"]').addEventListener('click', scanIslands);
507
+ setupMutationObservers();
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
+ });
423
522
 
424
523
  const bridgeLog = panel.querySelector('#debug-bridges-log');
425
524
  let firstBridge = true;
@@ -433,7 +532,18 @@ export const injectDebugger = (html: string, data: unknown): string => {
433
532
  const payload = event.detail && event.detail.payload;
434
533
  const time = new Date().toLocaleTimeString();
435
534
 
436
- 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');
437
547
  li.className = 'debug-item';
438
548
  li.innerHTML = \`
439
549
  <div class="debug-item-meta" style="flex: 1;">
@@ -472,35 +582,11 @@ export const injectDebugger = (html: string, data: unknown): string => {
472
582
  </div>
473
583
  \`;
474
584
 
585
+ const dbPane = panel.querySelector('#pane-database');
586
+ dbPane.innerHTML = getDbHtml(newData.effects);
587
+
475
588
  const effectsPane = panel.querySelector('#pane-effects');
476
- let newEffectsHtml = '';
477
- if (newData.effects && newData.effects.length > 0) {
478
- newEffectsHtml = newData.effects.map(eff => {
479
- const isSql = ['query', 'queryOne', 'execute'].includes(eff.kind);
480
- const sqlSnippet = isSql ? \`<pre class="debug-code">\${eff.payload.sql}</pre>\` : '';
481
- const paramsSnippet = isSql && eff.payload.params && eff.payload.params.length > 0 ?
482
- \`<div><strong>Params:</strong> \${JSON.stringify(eff.payload.params)}</div>\` : '';
483
- const outcome = eff.ok ? '<span class="status-ok">SUCCESS</span>' : '<span class="status-error">FAILED</span>';
484
- const detailError = eff.error ? \`<div class="status-error">\${eff.error}</div>\` : '';
485
- return \`
486
- <li class="debug-item">
487
- <div class="debug-item-meta" style="flex: 1;">
488
- <div style="display: flex; gap: 8px; align-items: center;">
489
- <span class="debug-badge badge-primary">\${eff.kind}</span>
490
- \${outcome}
491
- <span style="color: #9ca3af; font-size: 0.85em;">\${eff.durationMs.toFixed(1)}ms</span>
492
- </div>
493
- \${sqlSnippet}
494
- \${paramsSnippet}
495
- \${detailError}
496
- </div>
497
- </li>
498
- \`;
499
- }).join('');
500
- effectsPane.innerHTML = \`<ul class="debug-list">\${newEffectsHtml}</ul>\`;
501
- } else {
502
- effectsPane.innerHTML = '<div style="color: #9ca3af; text-align: center; padding: 20px;">No server-side effects executed for this request.</div>';
503
- }
589
+ effectsPane.innerHTML = getEffectsHtml(newData.effects);
504
590
 
505
591
  const sessionPane = panel.querySelector('#pane-session');
506
592
  sessionPane.innerHTML = \`
@@ -512,6 +598,8 @@ export const injectDebugger = (html: string, data: unknown): string => {
512
598
  </div>
513
599
  \`;
514
600
 
601
+ setupMutationObservers(); // Observe any newly mounted islands
602
+
515
603
  const activeTab = panel.querySelector('.debug-tab.active').getAttribute('data-tab');
516
604
  if (activeTab === 'islands') {
517
605
  scanIslands();
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