elm-ssr 1.0.1 → 1.0.3

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/lib/scaffold.mjs CHANGED
@@ -316,6 +316,67 @@ view =
316
316
  }
317
317
  `;
318
318
 
319
+ const betterAuthMigrationTemplate = () => `-- BetterAuth requires these 4 tables. Do not rename or remove columns.
320
+ -- Generated by elm-ssr. See https://www.better-auth.com/docs/concepts/database
321
+ CREATE TABLE IF NOT EXISTS "user" (
322
+ id TEXT NOT NULL PRIMARY KEY,
323
+ name TEXT NOT NULL,
324
+ email TEXT NOT NULL UNIQUE,
325
+ "emailVerified" INTEGER NOT NULL DEFAULT 0,
326
+ image TEXT,
327
+ "createdAt" INTEGER NOT NULL,
328
+ "updatedAt" INTEGER NOT NULL
329
+ );
330
+
331
+ CREATE TABLE IF NOT EXISTS session (
332
+ id TEXT NOT NULL PRIMARY KEY,
333
+ "expiresAt" INTEGER NOT NULL,
334
+ token TEXT NOT NULL UNIQUE,
335
+ "createdAt" INTEGER NOT NULL,
336
+ "updatedAt" INTEGER NOT NULL,
337
+ "ipAddress" TEXT,
338
+ "userAgent" TEXT,
339
+ "userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE
340
+ );
341
+
342
+ CREATE TABLE IF NOT EXISTS account (
343
+ id TEXT NOT NULL PRIMARY KEY,
344
+ "accountId" TEXT NOT NULL,
345
+ "providerId" TEXT NOT NULL,
346
+ "userId" TEXT NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
347
+ "accessToken" TEXT,
348
+ "refreshToken" TEXT,
349
+ "idToken" TEXT,
350
+ "accessTokenExpiresAt" INTEGER,
351
+ "refreshTokenExpiresAt" INTEGER,
352
+ scope TEXT,
353
+ password TEXT,
354
+ "createdAt" INTEGER NOT NULL,
355
+ "updatedAt" INTEGER NOT NULL
356
+ );
357
+
358
+ CREATE TABLE IF NOT EXISTS verification (
359
+ id TEXT NOT NULL PRIMARY KEY,
360
+ identifier TEXT NOT NULL,
361
+ value TEXT NOT NULL,
362
+ "expiresAt" INTEGER NOT NULL,
363
+ "createdAt" INTEGER,
364
+ "updatedAt" INTEGER
365
+ );
366
+ `;
367
+
368
+ const auth0MigrationTemplate = () => `-- Local user cache for Auth0 authenticated users.
369
+ -- Auth0 is the canonical identity source; this table stores profile data after first login.
370
+ CREATE TABLE IF NOT EXISTS users (
371
+ id TEXT NOT NULL PRIMARY KEY,
372
+ email TEXT NOT NULL UNIQUE,
373
+ name TEXT,
374
+ picture TEXT,
375
+ "createdAt" INTEGER NOT NULL,
376
+ "updatedAt" INTEGER NOT NULL
377
+ );
378
+ `;
379
+
319
380
  const authDisplayName = (authProvider) =>
320
381
  authProvider === "auth0" ? "Auth0" : "BetterAuth";
321
382
 
@@ -433,7 +494,83 @@ view user =
433
494
  }
434
495
  `;
435
496
 
436
- const authEndpointTemplate = (authProvider) => `import { generateSessionId, signValue, generateCsrfToken, readSignedCookie } from "elm-ssr/sessions";
497
+ const betterAuthEndpointTemplate = () => `import { betterAuth } from "better-auth";
498
+ import type { Middleware } from "elm-ssr/http";
499
+ import type { RequestSession } from "elm-ssr/sessions";
500
+
501
+ // BetterAuth is initialised per-request to pick up the right DB binding.
502
+ // Production (Cloudflare Workers): env.DB is the D1 binding.
503
+ // Local dev (Bun): falls back to bun:sqlite using app.db.
504
+ const createAuth = (env: any) => {
505
+ const db = env?.DB ?? (() => {
506
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
507
+ const { Database } = require("bun:sqlite");
508
+ return new Database("app.db");
509
+ })();
510
+
511
+ return betterAuth({
512
+ baseURL: (env?.BETTER_AUTH_URL as string) ?? "http://localhost:8787",
513
+ secret: (env?.BETTER_AUTH_SECRET as string) ?? "change-me-in-production",
514
+ database: { type: "sqlite", db },
515
+ emailAndPassword: { enabled: true },
516
+ // Uncomment to add social providers (add matching env vars in .dev.vars):
517
+ // socialProviders: {
518
+ // github: {
519
+ // clientId: env?.GITHUB_CLIENT_ID as string,
520
+ // clientSecret: env?.GITHUB_CLIENT_SECRET as string,
521
+ // },
522
+ // },
523
+ });
524
+ };
525
+
526
+ export const handleAuth = (request: Request, env: any): Promise<Response> =>
527
+ createAuth(env).handler(request);
528
+
529
+ // Reads the BetterAuth session from the request and returns the user.
530
+ const getSessionUser = async (
531
+ request: Request,
532
+ env: any
533
+ ): Promise<{ email: string; name: string } | null> => {
534
+ try {
535
+ const session = await createAuth(env).api.getSession({ headers: request.headers });
536
+ if (!session?.user) return null;
537
+ return { email: session.user.email, name: session.user.name ?? session.user.email };
538
+ } catch {
539
+ return null;
540
+ }
541
+ };
542
+
543
+ // elm-ssr middleware: reads BetterAuth's session and injects the user into
544
+ // context.session so Loader.session / Loader.requireUser work on every request.
545
+ export const betterAuthMiddleware: Middleware = async (context, next) => {
546
+ const user = await getSessionUser(context.request, context.env);
547
+ context.session = {
548
+ id: user ? \`ba:\${user.email}\` : "",
549
+ data: user,
550
+ csrf: "",
551
+ dirty: false,
552
+ destroyed: false,
553
+ isNew: false,
554
+ } as RequestSession;
555
+ return next(context);
556
+ };
557
+ `;
558
+
559
+ const auth0EndpointTemplate = () => `import { generateSessionId, signValue, generateCsrfToken, readSignedCookie } from "elm-ssr/sessions";
560
+
561
+ interface Auth0Config {
562
+ domain: string;
563
+ clientId: string;
564
+ clientSecret: string;
565
+ callbackUrl: string;
566
+ }
567
+
568
+ const getConfig = (env: any): Auth0Config => ({
569
+ domain: (env?.AUTH0_DOMAIN as string) ?? "",
570
+ clientId: (env?.AUTH0_CLIENT_ID as string) ?? "",
571
+ clientSecret: (env?.AUTH0_CLIENT_SECRET as string) ?? "",
572
+ callbackUrl: (env?.AUTH0_CALLBACK_URL as string) ?? "http://localhost:8787/api/auth/callback",
573
+ });
437
574
 
438
575
  export const handleAuth = async (
439
576
  request: Request,
@@ -441,54 +578,88 @@ export const handleAuth = async (
441
578
  options?: { store?: any; secret?: string }
442
579
  ): Promise<Response> => {
443
580
  const url = new URL(request.url);
581
+ const config = getConfig(env);
444
582
 
445
583
  if (url.pathname === "/api/auth/login") {
446
- // Mock login session update for ${authProvider}
447
- let cookieVal = "__elm_ssr_session=" + encodeURIComponent(JSON.stringify({
448
- user: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" }
449
- })) + "; Path=/; HttpOnly; SameSite=Lax";
584
+ if (!config.domain || !config.clientId) {
585
+ return new Response(
586
+ "Auth0 not configured set AUTH0_DOMAIN and AUTH0_CLIENT_ID in .dev.vars",
587
+ { status: 500 }
588
+ );
589
+ }
590
+ const params = new URLSearchParams({
591
+ response_type: "code",
592
+ client_id: config.clientId,
593
+ redirect_uri: config.callbackUrl,
594
+ scope: "openid profile email",
595
+ });
596
+ return Response.redirect(\`https://\${config.domain}/authorize?\${params}\`, 302);
597
+ }
598
+
599
+ if (url.pathname === "/api/auth/callback") {
600
+ const code = url.searchParams.get("code");
601
+ if (!code) return new Response("Missing code", { status: 400 });
602
+
603
+ const tokenRes = await fetch(\`https://\${config.domain}/oauth/token\`, {
604
+ method: "POST",
605
+ headers: { "content-type": "application/json" },
606
+ body: JSON.stringify({
607
+ grant_type: "authorization_code",
608
+ client_id: config.clientId,
609
+ client_secret: config.clientSecret,
610
+ code,
611
+ redirect_uri: config.callbackUrl,
612
+ }),
613
+ });
614
+
615
+ if (!tokenRes.ok) {
616
+ return new Response("Token exchange with Auth0 failed", { status: 502 });
617
+ }
618
+
619
+ const tokens = await tokenRes.json() as { id_token: string };
620
+ const [, payloadB64] = tokens.id_token.split(".");
621
+ const user = JSON.parse(atob(payloadB64)) as { email: string; name?: string; sub: string };
450
622
 
451
623
  if (options?.store && options?.secret) {
452
624
  const sessionId = generateSessionId();
453
625
  await options.store.set(sessionId, {
454
- data: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" },
455
- csrf: generateCsrfToken()
626
+ data: { email: user.email, name: user.name ?? user.email },
627
+ csrf: generateCsrfToken(),
456
628
  });
457
629
  const signed = await signValue(options.secret, sessionId);
458
- cookieVal = "session=" + signed + "; Path=/; HttpOnly; SameSite=Lax";
630
+ return new Response(null, {
631
+ status: 302,
632
+ headers: {
633
+ Location: "/profile",
634
+ "Set-Cookie": \`session=\${signed}; Path=/; HttpOnly; SameSite=Lax\`,
635
+ },
636
+ });
459
637
  }
460
-
461
- return new Response(null, {
462
- status: 302,
463
- headers: {
464
- "Location": "/profile",
465
- "Set-Cookie": cookieVal
466
- }
467
- });
638
+ return new Response(null, { status: 302, headers: { Location: "/profile" } });
468
639
  }
469
640
 
470
641
  if (url.pathname === "/api/auth/logout") {
471
- let cookieVal = "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly";
472
642
  if (options?.store && options?.secret) {
473
- cookieVal = "session=; Path=/; Max-Age=0; HttpOnly";
474
- const cookieHeader = request.headers.get("cookie");
475
- if (cookieHeader) {
476
- const sessionId = await readSignedCookie(cookieHeader, "session", options.secret);
477
- if (sessionId) {
478
- await options.store.delete(sessionId);
479
- }
643
+ const incoming = request.headers.get("cookie");
644
+ if (incoming) {
645
+ const sessionId = await readSignedCookie(incoming, "session", options.secret);
646
+ if (sessionId) await options.store.delete(sessionId);
480
647
  }
481
648
  }
649
+ const params = new URLSearchParams({
650
+ client_id: config.clientId,
651
+ returnTo: new URL(request.url).origin,
652
+ });
482
653
  return new Response(null, {
483
654
  status: 302,
484
655
  headers: {
485
- "Location": "/login",
486
- "Set-Cookie": cookieVal
487
- }
656
+ "Set-Cookie": "session=; Path=/; Max-Age=0; HttpOnly",
657
+ Location: \`https://\${config.domain}/oidc/logout?\${params}\`,
658
+ },
488
659
  });
489
660
  }
490
661
 
491
- return new Response("${authProvider} Endpoint API Mock", { status: 200 });
662
+ return new Response("Not found", { status: 404 });
492
663
  };
493
664
  `;
494
665
 
@@ -499,6 +670,9 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
499
670
  const upToRoot = appRoot === "." ? "." : appRoot.split("/").map(() => "..").join("/");
500
671
  const generatedPrefix = `${upToRoot}/generated/${appRoot === "." ? "" : appRoot}`.replace(/\/+$/, "");
501
672
 
673
+ const isBetterAuth = auth === "better-auth";
674
+ const isAuth0 = auth === "auth0";
675
+
502
676
  const imports = [
503
677
  `import { createWorkerApp } from "elm-ssr";`,
504
678
  `import { renderApp, type CompiledElmModule } from "elm-ssr/render";`,
@@ -506,13 +680,16 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
506
680
  `import { islands, bundleSource } from "${generatedPrefix}/islands-manifest";`,
507
681
  `import { stylesheet } from "./styles";`,
508
682
  `// @ts-expect-error Generated at build time.`,
509
- `import ElmRuntime from "${generatedPrefix}/app.mjs";`
683
+ `import ElmRuntime from "${generatedPrefix}/app.mjs";`,
684
+ `import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`
510
685
  ];
511
686
 
512
- imports.push(`import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`);
513
- if (auth) {
514
- imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
687
+ if (isBetterAuth) {
688
+ imports.push(`import { sessionEffects } from "elm-ssr/sessions";`);
689
+ imports.push(`import { handleAuth, betterAuthMiddleware } from "./src/Endpoints/Auth";`);
690
+ } else if (isAuth0) {
515
691
  imports.push(`import { memorySessionStore } from "elm-ssr/sessions";`);
692
+ imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
516
693
  }
517
694
 
518
695
  let dbInit = '';
@@ -557,8 +734,9 @@ if (typeof Bun !== "undefined") {
557
734
  },`;
558
735
  }
559
736
 
560
- const effectsConfig = `,
561
- effects: (effect, context) => {
737
+ // BetterAuth: manually wrap effects with sessionEffects (no elm-ssr session cookie).
738
+ // Auth0 / plain: use the effects callback directly.
739
+ const baseEffectsBody = `(effect, context) => {
562
740
  if (context.env) {
563
741
  return cloudflareEffects(${db ? '{ dbBinding: "DB" }' : ''})(effect, context);
564
742
  }
@@ -567,9 +745,14 @@ if (typeof Bun !== "undefined") {
567
745
  })(effect, context);
568
746
  }`;
569
747
 
748
+ const effectsConfig = isBetterAuth
749
+ ? `,\n effects: sessionEffects(${baseEffectsBody}),\n middlewares: [betterAuthMiddleware]`
750
+ : `,\n effects: ${baseEffectsBody}`;
751
+
752
+ // Auth0 keeps elm-ssr session middleware; BetterAuth manages sessions itself.
570
753
  let sessionsConfig = '';
571
754
  let authInit = '';
572
- if (auth) {
755
+ if (isAuth0) {
573
756
  authInit = `\nexport const sessionStore = memorySessionStore();\n`;
574
757
  sessionsConfig = `,
575
758
  sessions: {
@@ -581,9 +764,21 @@ if (typeof Bun !== "undefined") {
581
764
  }
582
765
 
583
766
  let authIntercept = '';
584
- if (auth) {
767
+ if (isBetterAuth) {
768
+ authIntercept = `
769
+ // Delegate all /api/auth/* requests to BetterAuth.
770
+ const baseWorkerFetch = worker.fetch;
771
+ worker.fetch = async (request, env, ctx) => {
772
+ const url = new URL(request.url);
773
+ if (url.pathname.startsWith("/api/auth/")) {
774
+ return handleAuth(request, env);
775
+ }
776
+ return baseWorkerFetch(request, env, ctx);
777
+ };
778
+ `;
779
+ } else if (isAuth0) {
585
780
  authIntercept = `
586
- // Intercept Auth Provider requests
781
+ // Delegate all /api/auth/* requests to the Auth0 handler.
587
782
  const baseWorkerFetch = worker.fetch;
588
783
  worker.fetch = async (request, env, ctx) => {
589
784
  const url = new URL(request.url);
@@ -986,6 +1181,51 @@ p { line-height: 1.65; color: var(--text-muted); }
986
1181
  \`;
987
1182
  `;
988
1183
 
1184
+ const devVarsContent = (auth) => {
1185
+ if (auth === "better-auth") {
1186
+ return `GREETING="Hello from your local .dev.vars file!"
1187
+ BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1188
+ BETTER_AUTH_URL="http://localhost:8787"
1189
+ # Uncomment to enable social providers in src/Endpoints/Auth.ts:
1190
+ # GITHUB_CLIENT_ID="your-github-client-id"
1191
+ # GITHUB_CLIENT_SECRET="your-github-client-secret"
1192
+ `;
1193
+ }
1194
+ if (auth === "auth0") {
1195
+ return `GREETING="Hello from your local .dev.vars file!"
1196
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1197
+ AUTH0_DOMAIN="your-tenant.auth0.com"
1198
+ AUTH0_CLIENT_ID="your-client-id"
1199
+ AUTH0_CLIENT_SECRET="your-client-secret"
1200
+ AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
1201
+ `;
1202
+ }
1203
+ return `GREETING="Hello from your local .dev.vars file!"
1204
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1205
+ `;
1206
+ };
1207
+
1208
+ const rootEnvContent = (auth) => {
1209
+ if (auth === "better-auth") {
1210
+ return `GREETING="Hello from your local .env file!"
1211
+ BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1212
+ BETTER_AUTH_URL="http://localhost:8787"
1213
+ `;
1214
+ }
1215
+ if (auth === "auth0") {
1216
+ return `GREETING="Hello from your local .env file!"
1217
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1218
+ AUTH0_DOMAIN="your-tenant.auth0.com"
1219
+ AUTH0_CLIENT_ID="your-client-id"
1220
+ AUTH0_CLIENT_SECRET="your-client-secret"
1221
+ AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
1222
+ `;
1223
+ }
1224
+ return `GREETING="Hello from your local .env file!"
1225
+ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1226
+ `;
1227
+ };
1228
+
989
1229
  const filesForApp = (name, appRoot, options = {}) => {
990
1230
  const namespace = toPascalCase(name);
991
1231
  const db = options.db || !!options.auth;
@@ -1004,22 +1244,27 @@ const filesForApp = (name, appRoot, options = {}) => {
1004
1244
  { path: `${appRoot}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) },
1005
1245
  {
1006
1246
  path: `${appRoot}/.dev.vars`,
1007
- content: `GREETING="Hello from your local .dev.vars file!"
1008
- SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1009
- `
1247
+ content: devVarsContent(auth)
1010
1248
  }
1011
1249
  ];
1012
1250
 
1013
1251
  if (db) {
1014
- files.push({
1015
- path: `${appRoot}/migrations/0001_init.sql`,
1016
- content: `CREATE TABLE IF NOT EXISTS users (
1252
+ let migrationContent;
1253
+ if (auth === "better-auth") {
1254
+ migrationContent = betterAuthMigrationTemplate();
1255
+ } else if (auth === "auth0") {
1256
+ migrationContent = auth0MigrationTemplate();
1257
+ } else {
1258
+ migrationContent = `CREATE TABLE IF NOT EXISTS users (
1017
1259
  id INTEGER PRIMARY KEY AUTOINCREMENT,
1018
1260
  email TEXT NOT NULL UNIQUE,
1019
1261
  name TEXT,
1020
1262
  created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
1021
- );
1022
- `
1263
+ );\n`;
1264
+ }
1265
+ files.push({
1266
+ path: `${appRoot}/migrations/0001_init.sql`,
1267
+ content: migrationContent
1023
1268
  });
1024
1269
  }
1025
1270
 
@@ -1034,7 +1279,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
1034
1279
  });
1035
1280
  files.push({
1036
1281
  path: `${appRoot}/src/Endpoints/Auth.ts`,
1037
- content: authEndpointTemplate(auth)
1282
+ content: auth === "better-auth" ? betterAuthEndpointTemplate() : auth0EndpointTemplate()
1038
1283
  });
1039
1284
  }
1040
1285
 
@@ -1135,7 +1380,7 @@ const normalizeAppRoot = (rawRoot, name) => {
1135
1380
  return candidate;
1136
1381
  };
1137
1382
 
1138
- const ensurePackageJson = async (rootPath, appName) => {
1383
+ const ensurePackageJson = async (rootPath, appName, options = {}) => {
1139
1384
  const packageJsonPath = resolve(rootPath, "package.json");
1140
1385
  let packageJson = {};
1141
1386
  try {
@@ -1158,9 +1403,15 @@ const ensurePackageJson = async (rootPath, appName) => {
1158
1403
  ...packageJson.scripts
1159
1404
  };
1160
1405
 
1406
+ const extraDeps = {};
1407
+ if (options.auth === "better-auth") {
1408
+ extraDeps["better-auth"] = "latest";
1409
+ }
1410
+
1161
1411
  packageJson.devDependencies = {
1162
1412
  "elm-ssr": "latest",
1163
1413
  wrangler: "^4.0.0",
1414
+ ...extraDeps,
1164
1415
  ...packageJson.devDependencies
1165
1416
  };
1166
1417
 
@@ -1206,9 +1457,7 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
1206
1457
  } catch {}
1207
1458
 
1208
1459
  if (!envExists) {
1209
- await writeFile(envPath, `GREETING="Hello from your local .env file!"
1210
- SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
1211
- `, "utf8");
1460
+ await writeFile(envPath, rootEnvContent(options.auth), "utf8");
1212
1461
  }
1213
1462
 
1214
1463
  await writeWorkspaceConfig(rootPath, {
@@ -1216,7 +1465,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
1216
1465
  apps: [...config.apps, configEntry]
1217
1466
  });
1218
1467
 
1219
- await ensurePackageJson(rootPath, name);
1468
+ await ensurePackageJson(rootPath, name, options);
1220
1469
 
1221
1470
  return configEntry;
1222
1471
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elm-ssr",
3
- "version": "1.0.1",
3
+ "version": "1.0.3",
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/debugger.ts CHANGED
@@ -399,30 +399,42 @@ export const injectDebugger = (html: string, data: unknown): string => {
399
399
  const islandMutations = new Map();
400
400
  const islandActiveStates = new Map();
401
401
  const activeObservers = new Map();
402
-
402
+ let scanPending = false;
403
+
404
+ // Debounced, rAF-deferred scan so we always read the fully-committed DOM
405
+ // and any synchronous port messages (stateOut → elm-ssr-state-update) have
406
+ // had a chance to fire before we render the panel.
407
+ const scheduleScan = () => {
408
+ if (scanPending) return;
409
+ scanPending = true;
410
+ requestAnimationFrame(() => {
411
+ scanPending = false;
412
+ const activeTab = panel.querySelector('.debug-tab.active')?.getAttribute('data-tab');
413
+ if (activeTab === 'islands') {
414
+ scanIslands();
415
+ }
416
+ });
417
+ };
418
+
403
419
  const setupMutationObservers = () => {
404
420
  for (const obs of activeObservers.values()) {
405
421
  obs.disconnect();
406
422
  }
407
423
  activeObservers.clear();
408
-
424
+
409
425
  const markers = Array.from(document.getElementsByTagName('elm-ssr-island'));
410
426
  markers.forEach(marker => {
411
427
  if (!islandMutations.has(marker)) {
412
428
  islandMutations.set(marker, { count: 0, lastUpdate: null });
413
429
  }
414
-
430
+
415
431
  const observer = new MutationObserver(() => {
416
432
  const stats = islandMutations.get(marker);
417
433
  stats.count += 1;
418
434
  stats.lastUpdate = new Date();
419
-
420
- const activeTab = panel.querySelector('.debug-tab.active')?.getAttribute('data-tab');
421
- if (activeTab === 'islands') {
422
- scanIslands();
423
- }
435
+ scheduleScan();
424
436
  });
425
-
437
+
426
438
  observer.observe(marker, { childList: true, subtree: true, characterData: true, attributes: true });
427
439
  activeObservers.set(marker, observer);
428
440
  });
@@ -460,17 +472,21 @@ export const injectDebugger = (html: string, data: unknown): string => {
460
472
  : 'No client state changes recorded';
461
473
 
462
474
  const activeState = islandActiveStates.get(marker);
463
- const domTextPreview = marker.textContent.trim().replace(/\\s+/g, ' ');
464
-
475
+ // Always read live DOM text — this is current regardless of port timing.
476
+ const domTextPreview = marker.textContent.trim().replace(/\\s+/g, ' ').slice(0, 200);
477
+
465
478
  let stateSnippet = '';
466
479
  if (activeState) {
480
+ // Model state from stateOut port (may lag one frame behind DOM).
467
481
  stateSnippet = \`
468
- <div style="font-size: 0.85em; margin-top: 6px; color: #a5b4fc;"><strong>Active Model State:</strong></div>
482
+ <div style="font-size: 0.85em; margin-top: 6px; color: #a5b4fc;"><strong>Model State:</strong></div>
469
483
  <pre class="debug-code" style="font-size: 0.85em; max-height: 120px; overflow-y: auto; color: #818cf8;">\${JSON.stringify(activeState, null, 2)}</pre>
470
484
  \`;
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>
485
+ }
486
+ if (domTextPreview) {
487
+ // DOM text is always live show it so the panel reflects every render.
488
+ stateSnippet += \`
489
+ <div style="font-size: 0.85em; margin-top: 6px; color: #9ca3af;"><strong>Live DOM:</strong></div>
474
490
  <div class="debug-code" style="font-size: 0.85em; color: #9ca3af; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">\${domTextPreview}</div>
475
491
  \`;
476
492
  }
@@ -506,16 +522,13 @@ export const injectDebugger = (html: string, data: unknown): string => {
506
522
  panel.querySelector('[data-tab="islands"]').addEventListener('click', scanIslands);
507
523
  setupMutationObservers();
508
524
 
509
- // Listen to explicit state update ports
525
+ // Listen to explicit state update ports (stateOut)
510
526
  window.addEventListener('elm-ssr-state-update', (event) => {
511
527
  if (event.detail) {
512
528
  const marker = findMarkerByOrigin(event.detail);
513
529
  if (marker) {
514
530
  islandActiveStates.set(marker, event.detail.state);
515
- const activeTab = panel.querySelector('.debug-tab.active')?.getAttribute('data-tab');
516
- if (activeTab === 'islands') {
517
- scanIslands();
518
- }
531
+ scheduleScan();
519
532
  }
520
533
  }
521
534
  });
@@ -599,11 +612,7 @@ export const injectDebugger = (html: string, data: unknown): string => {
599
612
  \`;
600
613
 
601
614
  setupMutationObservers(); // Observe any newly mounted islands
602
-
603
- const activeTab = panel.querySelector('.debug-tab.active').getAttribute('data-tab');
604
- if (activeTab === 'islands') {
605
- scanIslands();
606
- }
615
+ scheduleScan();
607
616
  };
608
617
 
609
618
  window.addEventListener('elm-ssr-debug-update', (event) => {