elm-ssr 1.0.1 → 1.0.4
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 +305 -51
- package/package.json +1 -1
- package/src/debugger.ts +34 -25
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,47 @@ view user =
|
|
|
433
494
|
}
|
|
434
495
|
`;
|
|
435
496
|
|
|
436
|
-
const
|
|
497
|
+
const betterAuthEndpointTemplate = () => `import { betterAuth } from "better-auth";
|
|
498
|
+
|
|
499
|
+
// BetterAuth is initialised per-request to pick up the right database binding.
|
|
500
|
+
// On Cloudflare Workers env.DB is the D1 binding; on Bun (local dev) the
|
|
501
|
+
// runtime injects a bun:sqlite Database as env.DB — see runtime.ts.
|
|
502
|
+
// Pass env.DB directly so BetterAuth's adapter auto-detects the dialect
|
|
503
|
+
// (D1 via "batch"/"exec"/"prepare", bun:sqlite via "fileControl").
|
|
504
|
+
export const createAuth = (env: any) =>
|
|
505
|
+
betterAuth({
|
|
506
|
+
baseURL: (env?.BETTER_AUTH_URL as string) ?? "http://localhost:8787",
|
|
507
|
+
secret: (env?.BETTER_AUTH_SECRET as string) ?? "change-me-in-production",
|
|
508
|
+
database: env?.DB,
|
|
509
|
+
emailAndPassword: { enabled: true },
|
|
510
|
+
// Uncomment to add social providers (add matching env vars in .dev.vars):
|
|
511
|
+
// socialProviders: {
|
|
512
|
+
// github: {
|
|
513
|
+
// clientId: env?.GITHUB_CLIENT_ID as string,
|
|
514
|
+
// clientSecret: env?.GITHUB_CLIENT_SECRET as string,
|
|
515
|
+
// },
|
|
516
|
+
// },
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
export const handleAuth = (request: Request, env: any): Promise<Response> =>
|
|
520
|
+
createAuth(env).handler(request);
|
|
521
|
+
`;
|
|
522
|
+
|
|
523
|
+
const auth0EndpointTemplate = () => `import { generateSessionId, signValue, generateCsrfToken, readSignedCookie } from "elm-ssr/sessions";
|
|
524
|
+
|
|
525
|
+
interface Auth0Config {
|
|
526
|
+
domain: string;
|
|
527
|
+
clientId: string;
|
|
528
|
+
clientSecret: string;
|
|
529
|
+
callbackUrl: string;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
const getConfig = (env: any): Auth0Config => ({
|
|
533
|
+
domain: (env?.AUTH0_DOMAIN as string) ?? "",
|
|
534
|
+
clientId: (env?.AUTH0_CLIENT_ID as string) ?? "",
|
|
535
|
+
clientSecret: (env?.AUTH0_CLIENT_SECRET as string) ?? "",
|
|
536
|
+
callbackUrl: (env?.AUTH0_CALLBACK_URL as string) ?? "http://localhost:8787/api/auth/callback",
|
|
537
|
+
});
|
|
437
538
|
|
|
438
539
|
export const handleAuth = async (
|
|
439
540
|
request: Request,
|
|
@@ -441,54 +542,88 @@ export const handleAuth = async (
|
|
|
441
542
|
options?: { store?: any; secret?: string }
|
|
442
543
|
): Promise<Response> => {
|
|
443
544
|
const url = new URL(request.url);
|
|
545
|
+
const config = getConfig(env);
|
|
444
546
|
|
|
445
547
|
if (url.pathname === "/api/auth/login") {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
548
|
+
if (!config.domain || !config.clientId) {
|
|
549
|
+
return new Response(
|
|
550
|
+
"Auth0 not configured — set AUTH0_DOMAIN and AUTH0_CLIENT_ID in .dev.vars",
|
|
551
|
+
{ status: 500 }
|
|
552
|
+
);
|
|
553
|
+
}
|
|
554
|
+
const params = new URLSearchParams({
|
|
555
|
+
response_type: "code",
|
|
556
|
+
client_id: config.clientId,
|
|
557
|
+
redirect_uri: config.callbackUrl,
|
|
558
|
+
scope: "openid profile email",
|
|
559
|
+
});
|
|
560
|
+
return Response.redirect(\`https://\${config.domain}/authorize?\${params}\`, 302);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
if (url.pathname === "/api/auth/callback") {
|
|
564
|
+
const code = url.searchParams.get("code");
|
|
565
|
+
if (!code) return new Response("Missing code", { status: 400 });
|
|
566
|
+
|
|
567
|
+
const tokenRes = await fetch(\`https://\${config.domain}/oauth/token\`, {
|
|
568
|
+
method: "POST",
|
|
569
|
+
headers: { "content-type": "application/json" },
|
|
570
|
+
body: JSON.stringify({
|
|
571
|
+
grant_type: "authorization_code",
|
|
572
|
+
client_id: config.clientId,
|
|
573
|
+
client_secret: config.clientSecret,
|
|
574
|
+
code,
|
|
575
|
+
redirect_uri: config.callbackUrl,
|
|
576
|
+
}),
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
if (!tokenRes.ok) {
|
|
580
|
+
return new Response("Token exchange with Auth0 failed", { status: 502 });
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const tokens = await tokenRes.json() as { id_token: string };
|
|
584
|
+
const [, payloadB64] = tokens.id_token.split(".");
|
|
585
|
+
const user = JSON.parse(atob(payloadB64)) as { email: string; name?: string; sub: string };
|
|
450
586
|
|
|
451
587
|
if (options?.store && options?.secret) {
|
|
452
588
|
const sessionId = generateSessionId();
|
|
453
589
|
await options.store.set(sessionId, {
|
|
454
|
-
data: { email:
|
|
455
|
-
csrf: generateCsrfToken()
|
|
590
|
+
data: { email: user.email, name: user.name ?? user.email },
|
|
591
|
+
csrf: generateCsrfToken(),
|
|
456
592
|
});
|
|
457
593
|
const signed = await signValue(options.secret, sessionId);
|
|
458
|
-
|
|
594
|
+
return new Response(null, {
|
|
595
|
+
status: 302,
|
|
596
|
+
headers: {
|
|
597
|
+
Location: "/profile",
|
|
598
|
+
"Set-Cookie": \`session=\${signed}; Path=/; HttpOnly; SameSite=Lax\`,
|
|
599
|
+
},
|
|
600
|
+
});
|
|
459
601
|
}
|
|
460
|
-
|
|
461
|
-
return new Response(null, {
|
|
462
|
-
status: 302,
|
|
463
|
-
headers: {
|
|
464
|
-
"Location": "/profile",
|
|
465
|
-
"Set-Cookie": cookieVal
|
|
466
|
-
}
|
|
467
|
-
});
|
|
602
|
+
return new Response(null, { status: 302, headers: { Location: "/profile" } });
|
|
468
603
|
}
|
|
469
604
|
|
|
470
605
|
if (url.pathname === "/api/auth/logout") {
|
|
471
|
-
let cookieVal = "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly";
|
|
472
606
|
if (options?.store && options?.secret) {
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
if (sessionId) {
|
|
478
|
-
await options.store.delete(sessionId);
|
|
479
|
-
}
|
|
607
|
+
const incoming = request.headers.get("cookie");
|
|
608
|
+
if (incoming) {
|
|
609
|
+
const sessionId = await readSignedCookie(incoming, "session", options.secret);
|
|
610
|
+
if (sessionId) await options.store.delete(sessionId);
|
|
480
611
|
}
|
|
481
612
|
}
|
|
613
|
+
const params = new URLSearchParams({
|
|
614
|
+
client_id: config.clientId,
|
|
615
|
+
returnTo: new URL(request.url).origin,
|
|
616
|
+
});
|
|
482
617
|
return new Response(null, {
|
|
483
618
|
status: 302,
|
|
484
619
|
headers: {
|
|
485
|
-
"
|
|
486
|
-
|
|
487
|
-
}
|
|
620
|
+
"Set-Cookie": "session=; Path=/; Max-Age=0; HttpOnly",
|
|
621
|
+
Location: \`https://\${config.domain}/oidc/logout?\${params}\`,
|
|
622
|
+
},
|
|
488
623
|
});
|
|
489
624
|
}
|
|
490
625
|
|
|
491
|
-
return new Response("
|
|
626
|
+
return new Response("Not found", { status: 404 });
|
|
492
627
|
};
|
|
493
628
|
`;
|
|
494
629
|
|
|
@@ -499,6 +634,9 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
|
|
|
499
634
|
const upToRoot = appRoot === "." ? "." : appRoot.split("/").map(() => "..").join("/");
|
|
500
635
|
const generatedPrefix = `${upToRoot}/generated/${appRoot === "." ? "" : appRoot}`.replace(/\/+$/, "");
|
|
501
636
|
|
|
637
|
+
const isBetterAuth = auth === "better-auth";
|
|
638
|
+
const isAuth0 = auth === "auth0";
|
|
639
|
+
|
|
502
640
|
const imports = [
|
|
503
641
|
`import { createWorkerApp } from "elm-ssr";`,
|
|
504
642
|
`import { renderApp, type CompiledElmModule } from "elm-ssr/render";`,
|
|
@@ -506,13 +644,18 @@ const runtimeTemplate = (appRoot, db = false, auth = undefined) => {
|
|
|
506
644
|
`import { islands, bundleSource } from "${generatedPrefix}/islands-manifest";`,
|
|
507
645
|
`import { stylesheet } from "./styles";`,
|
|
508
646
|
`// @ts-expect-error Generated at build time.`,
|
|
509
|
-
`import ElmRuntime from "${generatedPrefix}/app.mjs"
|
|
647
|
+
`import ElmRuntime from "${generatedPrefix}/app.mjs";`,
|
|
648
|
+
`import { inMemoryEffects, cloudflareEffects } from "elm-ssr/effects";`
|
|
510
649
|
];
|
|
511
650
|
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
imports.push(`import {
|
|
651
|
+
if (isBetterAuth) {
|
|
652
|
+
imports.push(`import { sessionEffects } from "elm-ssr/sessions";`);
|
|
653
|
+
imports.push(`import type { Middleware } from "elm-ssr/http";`);
|
|
654
|
+
imports.push(`import type { RequestSession } from "elm-ssr/sessions";`);
|
|
655
|
+
imports.push(`import { createAuth, handleAuth } from "./src/Endpoints/Auth";`);
|
|
656
|
+
} else if (isAuth0) {
|
|
515
657
|
imports.push(`import { memorySessionStore } from "elm-ssr/sessions";`);
|
|
658
|
+
imports.push(`import { handleAuth } from "./src/Endpoints/Auth";`);
|
|
516
659
|
}
|
|
517
660
|
|
|
518
661
|
let dbInit = '';
|
|
@@ -557,8 +700,9 @@ if (typeof Bun !== "undefined") {
|
|
|
557
700
|
},`;
|
|
558
701
|
}
|
|
559
702
|
|
|
560
|
-
|
|
561
|
-
|
|
703
|
+
// BetterAuth: manually wrap effects with sessionEffects (no elm-ssr session cookie).
|
|
704
|
+
// Auth0 / plain: use the effects callback directly.
|
|
705
|
+
const baseEffectsBody = `(effect, context) => {
|
|
562
706
|
if (context.env) {
|
|
563
707
|
return cloudflareEffects(${db ? '{ dbBinding: "DB" }' : ''})(effect, context);
|
|
564
708
|
}
|
|
@@ -567,9 +711,53 @@ if (typeof Bun !== "undefined") {
|
|
|
567
711
|
})(effect, context);
|
|
568
712
|
}`;
|
|
569
713
|
|
|
714
|
+
const effectsConfig = isBetterAuth
|
|
715
|
+
? `,\n effects: sessionEffects(${baseEffectsBody}),\n middlewares: [betterAuthBridge]`
|
|
716
|
+
: `,\n effects: ${baseEffectsBody}`;
|
|
717
|
+
|
|
718
|
+
// Auth0 keeps elm-ssr session middleware; BetterAuth manages sessions itself.
|
|
570
719
|
let sessionsConfig = '';
|
|
571
720
|
let authInit = '';
|
|
572
|
-
if (
|
|
721
|
+
if (isBetterAuth) {
|
|
722
|
+
authInit = `
|
|
723
|
+
// Local dev: open a SQLite database so BetterAuth works without a Cloudflare D1 binding.
|
|
724
|
+
// On Cloudflare Workers this block is eliminated by esbuild (typeof Bun is statically "undefined").
|
|
725
|
+
let bunAuthDb: any = undefined;
|
|
726
|
+
if (typeof (globalThis as any).Bun !== "undefined") {
|
|
727
|
+
const sqliteModule = "bun" + ":sqlite";
|
|
728
|
+
const { Database } = require(sqliteModule);
|
|
729
|
+
bunAuthDb = new Database(import.meta.dir + "/app.db");
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const getAuthEnv = (env: any): any =>
|
|
733
|
+
bunAuthDb && !env?.DB ? { ...(env ?? {}), DB: bunAuthDb } : env;
|
|
734
|
+
|
|
735
|
+
// Bridge: reads BetterAuth session on every request so Loader.requireUser works.
|
|
736
|
+
const betterAuthBridge: Middleware = async (context, next) => {
|
|
737
|
+
try {
|
|
738
|
+
const session = await createAuth(getAuthEnv(context.env)).api.getSession({
|
|
739
|
+
headers: context.request.headers,
|
|
740
|
+
});
|
|
741
|
+
const user = session?.user
|
|
742
|
+
? { email: session.user.email, name: session.user.name ?? session.user.email }
|
|
743
|
+
: null;
|
|
744
|
+
context.session = {
|
|
745
|
+
id: user ? \`ba:\${user.email}\` : "",
|
|
746
|
+
data: user,
|
|
747
|
+
csrf: "",
|
|
748
|
+
dirty: false,
|
|
749
|
+
destroyed: false,
|
|
750
|
+
isNew: false,
|
|
751
|
+
} as RequestSession;
|
|
752
|
+
} catch {
|
|
753
|
+
context.session = {
|
|
754
|
+
id: "", data: null, csrf: "", dirty: false, destroyed: false, isNew: false,
|
|
755
|
+
} as RequestSession;
|
|
756
|
+
}
|
|
757
|
+
return next(context);
|
|
758
|
+
};
|
|
759
|
+
`;
|
|
760
|
+
} else if (isAuth0) {
|
|
573
761
|
authInit = `\nexport const sessionStore = memorySessionStore();\n`;
|
|
574
762
|
sessionsConfig = `,
|
|
575
763
|
sessions: {
|
|
@@ -581,9 +769,21 @@ if (typeof Bun !== "undefined") {
|
|
|
581
769
|
}
|
|
582
770
|
|
|
583
771
|
let authIntercept = '';
|
|
584
|
-
if (
|
|
772
|
+
if (isBetterAuth) {
|
|
773
|
+
authIntercept = `
|
|
774
|
+
// Delegate all /api/auth/* requests to BetterAuth, injecting the local db when needed.
|
|
775
|
+
const baseWorkerFetch = worker.fetch;
|
|
776
|
+
worker.fetch = async (request, env, ctx) => {
|
|
777
|
+
const url = new URL(request.url);
|
|
778
|
+
if (url.pathname.startsWith("/api/auth/")) {
|
|
779
|
+
return handleAuth(request, getAuthEnv(env));
|
|
780
|
+
}
|
|
781
|
+
return baseWorkerFetch(request, env, ctx);
|
|
782
|
+
};
|
|
783
|
+
`;
|
|
784
|
+
} else if (isAuth0) {
|
|
585
785
|
authIntercept = `
|
|
586
|
-
//
|
|
786
|
+
// Delegate all /api/auth/* requests to the Auth0 handler.
|
|
587
787
|
const baseWorkerFetch = worker.fetch;
|
|
588
788
|
worker.fetch = async (request, env, ctx) => {
|
|
589
789
|
const url = new URL(request.url);
|
|
@@ -986,6 +1186,51 @@ p { line-height: 1.65; color: var(--text-muted); }
|
|
|
986
1186
|
\`;
|
|
987
1187
|
`;
|
|
988
1188
|
|
|
1189
|
+
const devVarsContent = (auth) => {
|
|
1190
|
+
if (auth === "better-auth") {
|
|
1191
|
+
return `GREETING="Hello from your local .dev.vars file!"
|
|
1192
|
+
BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1193
|
+
BETTER_AUTH_URL="http://localhost:8787"
|
|
1194
|
+
# Uncomment to enable social providers in src/Endpoints/Auth.ts:
|
|
1195
|
+
# GITHUB_CLIENT_ID="your-github-client-id"
|
|
1196
|
+
# GITHUB_CLIENT_SECRET="your-github-client-secret"
|
|
1197
|
+
`;
|
|
1198
|
+
}
|
|
1199
|
+
if (auth === "auth0") {
|
|
1200
|
+
return `GREETING="Hello from your local .dev.vars file!"
|
|
1201
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1202
|
+
AUTH0_DOMAIN="your-tenant.auth0.com"
|
|
1203
|
+
AUTH0_CLIENT_ID="your-client-id"
|
|
1204
|
+
AUTH0_CLIENT_SECRET="your-client-secret"
|
|
1205
|
+
AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
|
|
1206
|
+
`;
|
|
1207
|
+
}
|
|
1208
|
+
return `GREETING="Hello from your local .dev.vars file!"
|
|
1209
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1210
|
+
`;
|
|
1211
|
+
};
|
|
1212
|
+
|
|
1213
|
+
const rootEnvContent = (auth) => {
|
|
1214
|
+
if (auth === "better-auth") {
|
|
1215
|
+
return `GREETING="Hello from your local .env file!"
|
|
1216
|
+
BETTER_AUTH_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1217
|
+
BETTER_AUTH_URL="http://localhost:8787"
|
|
1218
|
+
`;
|
|
1219
|
+
}
|
|
1220
|
+
if (auth === "auth0") {
|
|
1221
|
+
return `GREETING="Hello from your local .env file!"
|
|
1222
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1223
|
+
AUTH0_DOMAIN="your-tenant.auth0.com"
|
|
1224
|
+
AUTH0_CLIENT_ID="your-client-id"
|
|
1225
|
+
AUTH0_CLIENT_SECRET="your-client-secret"
|
|
1226
|
+
AUTH0_CALLBACK_URL="http://localhost:8787/api/auth/callback"
|
|
1227
|
+
`;
|
|
1228
|
+
}
|
|
1229
|
+
return `GREETING="Hello from your local .env file!"
|
|
1230
|
+
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1231
|
+
`;
|
|
1232
|
+
};
|
|
1233
|
+
|
|
989
1234
|
const filesForApp = (name, appRoot, options = {}) => {
|
|
990
1235
|
const namespace = toPascalCase(name);
|
|
991
1236
|
const db = options.db || !!options.auth;
|
|
@@ -1004,22 +1249,27 @@ const filesForApp = (name, appRoot, options = {}) => {
|
|
|
1004
1249
|
{ path: `${appRoot}/src/${namespace}/Islands/Counter.elm`, content: counterIslandTemplate(namespace) },
|
|
1005
1250
|
{
|
|
1006
1251
|
path: `${appRoot}/.dev.vars`,
|
|
1007
|
-
content:
|
|
1008
|
-
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1009
|
-
`
|
|
1252
|
+
content: devVarsContent(auth)
|
|
1010
1253
|
}
|
|
1011
1254
|
];
|
|
1012
1255
|
|
|
1013
1256
|
if (db) {
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1257
|
+
let migrationContent;
|
|
1258
|
+
if (auth === "better-auth") {
|
|
1259
|
+
migrationContent = betterAuthMigrationTemplate();
|
|
1260
|
+
} else if (auth === "auth0") {
|
|
1261
|
+
migrationContent = auth0MigrationTemplate();
|
|
1262
|
+
} else {
|
|
1263
|
+
migrationContent = `CREATE TABLE IF NOT EXISTS users (
|
|
1017
1264
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
1018
1265
|
email TEXT NOT NULL UNIQUE,
|
|
1019
1266
|
name TEXT,
|
|
1020
1267
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
|
1021
|
-
)
|
|
1022
|
-
|
|
1268
|
+
);\n`;
|
|
1269
|
+
}
|
|
1270
|
+
files.push({
|
|
1271
|
+
path: `${appRoot}/migrations/0001_init.sql`,
|
|
1272
|
+
content: migrationContent
|
|
1023
1273
|
});
|
|
1024
1274
|
}
|
|
1025
1275
|
|
|
@@ -1034,7 +1284,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
|
|
|
1034
1284
|
});
|
|
1035
1285
|
files.push({
|
|
1036
1286
|
path: `${appRoot}/src/Endpoints/Auth.ts`,
|
|
1037
|
-
content:
|
|
1287
|
+
content: auth === "better-auth" ? betterAuthEndpointTemplate() : auth0EndpointTemplate()
|
|
1038
1288
|
});
|
|
1039
1289
|
}
|
|
1040
1290
|
|
|
@@ -1135,7 +1385,7 @@ const normalizeAppRoot = (rawRoot, name) => {
|
|
|
1135
1385
|
return candidate;
|
|
1136
1386
|
};
|
|
1137
1387
|
|
|
1138
|
-
const ensurePackageJson = async (rootPath, appName) => {
|
|
1388
|
+
const ensurePackageJson = async (rootPath, appName, options = {}) => {
|
|
1139
1389
|
const packageJsonPath = resolve(rootPath, "package.json");
|
|
1140
1390
|
let packageJson = {};
|
|
1141
1391
|
try {
|
|
@@ -1158,9 +1408,15 @@ const ensurePackageJson = async (rootPath, appName) => {
|
|
|
1158
1408
|
...packageJson.scripts
|
|
1159
1409
|
};
|
|
1160
1410
|
|
|
1411
|
+
const extraDeps = {};
|
|
1412
|
+
if (options.auth === "better-auth") {
|
|
1413
|
+
extraDeps["better-auth"] = "latest";
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1161
1416
|
packageJson.devDependencies = {
|
|
1162
1417
|
"elm-ssr": "latest",
|
|
1163
1418
|
wrangler: "^4.0.0",
|
|
1419
|
+
...extraDeps,
|
|
1164
1420
|
...packageJson.devDependencies
|
|
1165
1421
|
};
|
|
1166
1422
|
|
|
@@ -1206,9 +1462,7 @@ export const createAppScaffold = async (rootPath, name, options = {}) => {
|
|
|
1206
1462
|
} catch {}
|
|
1207
1463
|
|
|
1208
1464
|
if (!envExists) {
|
|
1209
|
-
await writeFile(envPath,
|
|
1210
|
-
SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars"
|
|
1211
|
-
`, "utf8");
|
|
1465
|
+
await writeFile(envPath, rootEnvContent(options.auth), "utf8");
|
|
1212
1466
|
}
|
|
1213
1467
|
|
|
1214
1468
|
await writeWorkspaceConfig(rootPath, {
|
|
@@ -1216,7 +1470,7 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
|
|
|
1216
1470
|
apps: [...config.apps, configEntry]
|
|
1217
1471
|
});
|
|
1218
1472
|
|
|
1219
|
-
await ensurePackageJson(rootPath, name);
|
|
1473
|
+
await ensurePackageJson(rootPath, name, options);
|
|
1220
1474
|
|
|
1221
1475
|
return configEntry;
|
|
1222
1476
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "elm-ssr",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
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
|
-
|
|
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>
|
|
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
|
-
}
|
|
472
|
-
|
|
473
|
-
|
|
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
|
-
|
|
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) => {
|