elm-ssr 0.91.3 → 0.91.6

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,9 +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
- (use --db to wire SQLite/migrations, --auth betterAuth|auth0 for auth guards)
80
+ (use --db to wire SQLite/migrations, --auth betterAuth|auth0 for auth guards, --tailwind for Tailwind CSS)
81
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
+ (use --in <subdir> to group, --db for SQLite, --auth betterAuth|auth0 for auth, --tailwind for Tailwind)
83
83
  routes Print configured apps and their public modules
84
84
  route <path> Scaffold a new route (standard HTML, --api JSON, --ws WebSocket, or --sse EventSource)
85
85
  query Generate type-safe Elm Db modules from SQL table definitions
@@ -196,11 +196,12 @@ switch (command) {
196
196
  const name = args[1];
197
197
 
198
198
  if (!name) {
199
- console.error("Usage: elm-ssr init <name> [--db] [--auth betterAuth|auth0]");
199
+ console.error("Usage: elm-ssr init <name> [--db] [--auth betterAuth|auth0] [--tailwind]");
200
200
  process.exit(1);
201
201
  }
202
202
 
203
203
  const db = args.includes("--db");
204
+ const tailwind = args.includes("--tailwind");
204
205
  let auth = findFlagValue("--auth");
205
206
  if (auth) {
206
207
  if (auth === "better-auth" || auth === "betterAuth") {
@@ -211,7 +212,7 @@ switch (command) {
211
212
  }
212
213
  }
213
214
 
214
- const created = await createAppScaffold(rootPath, name, { root: ".", db, auth });
215
+ const created = await createAppScaffold(rootPath, name, { root: ".", db, auth, tailwind });
215
216
  console.log(`Initialized ${created.name} in current directory`);
216
217
  break;
217
218
  }
@@ -220,7 +221,7 @@ switch (command) {
220
221
  const name = args[1];
221
222
 
222
223
  if (!name) {
223
- console.error("Usage: elm-ssr new <name> [--in <subdir>] [--db] [--auth betterAuth|auth0]");
224
+ console.error("Usage: elm-ssr new <name> [--in <subdir>] [--db] [--auth betterAuth|auth0] [--tailwind]");
224
225
  console.error(" Default location: <workspace>/<name>/");
225
226
  console.error(" Use --in apps to place it under <workspace>/apps/<name>/, etc.");
226
227
  process.exit(1);
@@ -230,6 +231,7 @@ switch (command) {
230
231
  const appRoot = subdir ? `${subdir.replace(/\/+$/, "")}/${name}` : name;
231
232
 
232
233
  const db = args.includes("--db");
234
+ const tailwind = args.includes("--tailwind");
233
235
  let auth = findFlagValue("--auth");
234
236
  if (auth) {
235
237
  if (auth === "better-auth" || auth === "betterAuth") {
@@ -240,7 +242,7 @@ switch (command) {
240
242
  }
241
243
  }
242
244
 
243
- const created = await createAppScaffold(rootPath, name, { root: appRoot, db, auth });
245
+ const created = await createAppScaffold(rootPath, name, { root: appRoot, db, auth, tailwind });
244
246
  console.log(`Created ${created.name} at ${created.root}`);
245
247
  break;
246
248
  }
package/lib/build.mjs CHANGED
@@ -1,10 +1,6 @@
1
1
  import { mkdir, readdir, readFile, rm, writeFile, stat } from "node:fs/promises";
2
2
  import { dirname, relative, resolve } from "node:path";
3
3
  import { gzipSync } from "node:zlib";
4
- import { exec } from "node:child_process";
5
- import { promisify } from "node:util";
6
-
7
- const execAsync = promisify(exec);
8
4
 
9
5
  // This lib is part of elm-ssr. It handles generating Main.elm and
10
6
  // compiling the route apps and island bundles.
@@ -203,21 +199,50 @@ main =
203
199
  await rm(rawOutputPath, { force: true });
204
200
  };
205
201
 
206
- const findLocalTailwind = async (startDir) => {
207
- let current = resolve(startDir);
208
- while (true) {
209
- const candidate = resolve(current, "node_modules", ".bin", "tailwindcss");
210
- try {
211
- await stat(candidate);
212
- return candidate;
213
- } catch {
214
- // ignore
202
+ const findLocalTailwindCli = async (startDir) => {
203
+ const candidate = resolve(startDir, "node_modules", "tailwindcss", "lib", "cli.js");
204
+ try {
205
+ await stat(candidate);
206
+ return candidate;
207
+ } catch {
208
+ return null;
209
+ }
210
+ };
211
+
212
+ const runTailwind = async ({ rootPath, cliRoot, appRoot, inputPath, contentGlob }) => {
213
+ const tailwindSearchRoots = [
214
+ rootPath,
215
+ cliRoot,
216
+ resolve(cliRoot, "..", ".."),
217
+ process.cwd()
218
+ ];
219
+ let tailwindCli = null;
220
+ for (const searchRoot of [...new Set(tailwindSearchRoots)]) {
221
+ tailwindCli = await findLocalTailwindCli(searchRoot);
222
+ if (tailwindCli) {
223
+ break;
215
224
  }
216
- const parent = dirname(current);
217
- if (parent === current) break;
218
- current = parent;
219
225
  }
220
- return null;
226
+
227
+ const command = tailwindCli
228
+ ? [process.execPath, tailwindCli]
229
+ : ["npx", "--yes", "tailwindcss"];
230
+
231
+ const child = Bun.spawn(
232
+ [...command, "-i", inputPath, "--content", contentGlob, "--minify"],
233
+ { cwd: appRoot, stdout: "pipe", stderr: "pipe" }
234
+ );
235
+ const [stdout, stderr, exitCode] = await Promise.all([
236
+ new Response(child.stdout).text(),
237
+ new Response(child.stderr).text(),
238
+ child.exited
239
+ ]);
240
+
241
+ if (exitCode !== 0) {
242
+ throw new Error((stderr || stdout || `Tailwind exited with code ${exitCode}`).trim());
243
+ }
244
+
245
+ return stdout.toString().trim();
221
246
  };
222
247
 
223
248
  const compileStylesheet = async ({ inputPath, outputPath, appConfig, appRoot }) => {
@@ -234,17 +259,14 @@ const findLocalTailwind = async (startDir) => {
234
259
  try {
235
260
  console.log(`[elm-ssr] Compiling Tailwind CSS for app "${appConfig.name}"...`);
236
261
  const contentGlob = "src/**/*.elm,src/**/*.ts,src/**/*.js";
237
-
238
- let tailwindBin = await findLocalTailwind(rootPath);
239
- if (!tailwindBin) {
240
- tailwindBin = await findLocalTailwind(cliRoot);
241
- }
242
- const binCommand = tailwindBin ? tailwindBin : "npx --yes tailwindcss";
243
-
244
- const command = `${binCommand} -i ${inputPath} --content "${contentGlob}" --minify`;
245
- const { stdout } = await execAsync(command, { cwd: appRoot });
246
- css = stdout.toString().trim();
262
+ css = await runTailwind({ rootPath, cliRoot, appRoot, inputPath, contentGlob });
247
263
  } catch (err) {
264
+ if (appConfig.tailwindFallback !== true) {
265
+ throw new Error(
266
+ `[elm-ssr] Tailwind CSS compilation failed for app "${appConfig.name}". ` +
267
+ `Install tailwindcss in the workspace or set "tailwindFallback": true for development-only fallback. ${err.message}`
268
+ );
269
+ }
248
270
  console.warn(`[elm-ssr] Tailwind CSS compilation failed. Falling back to plain CSS read. Error:`, err.message);
249
271
  const raw = await readFile(inputPath, "utf8");
250
272
  css = raw.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\s+/g, " ").trim();
package/lib/scaffold.mjs CHANGED
@@ -368,28 +368,57 @@ view user =
368
368
  }
369
369
  `;
370
370
 
371
- const authEndpointTemplate = (authProvider) => `export const handleAuth = async (request: Request, env: any): Promise<Response> => {
371
+ const authEndpointTemplate = (authProvider) => `import { generateSessionId, signValue, generateCsrfToken, readSignedCookie } from "elm-ssr/sessions";
372
+
373
+ export const handleAuth = async (
374
+ request: Request,
375
+ env: any,
376
+ options?: { store?: any; secret?: string }
377
+ ): Promise<Response> => {
372
378
  const url = new URL(request.url);
373
379
 
374
380
  if (url.pathname === "/api/auth/login") {
375
381
  // Mock login session update for ${authProvider}
382
+ let cookieVal = "__elm_ssr_session=" + encodeURIComponent(JSON.stringify({
383
+ user: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" }
384
+ })) + "; Path=/; HttpOnly; SameSite=Lax";
385
+
386
+ if (options?.store && options?.secret) {
387
+ const sessionId = generateSessionId();
388
+ await options.store.set(sessionId, {
389
+ data: { email: "user@example.com", name: "${authProvider === "better-auth" ? "BetterAuth User" : "Auth0 User"}" },
390
+ csrf: generateCsrfToken()
391
+ });
392
+ const signed = await signValue(options.secret, sessionId);
393
+ cookieVal = "session=" + signed + "; Path=/; HttpOnly; SameSite=Lax";
394
+ }
395
+
376
396
  return new Response(null, {
377
397
  status: 302,
378
398
  headers: {
379
399
  "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"
400
+ "Set-Cookie": cookieVal
383
401
  }
384
402
  });
385
403
  }
386
404
 
387
405
  if (url.pathname === "/api/auth/logout") {
406
+ let cookieVal = "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly";
407
+ if (options?.store && options?.secret) {
408
+ cookieVal = "session=; Path=/; Max-Age=0; HttpOnly";
409
+ const cookieHeader = request.headers.get("cookie");
410
+ if (cookieHeader) {
411
+ const sessionId = await readSignedCookie(cookieHeader, "session", options.secret);
412
+ if (sessionId) {
413
+ await options.store.delete(sessionId);
414
+ }
415
+ }
416
+ }
388
417
  return new Response(null, {
389
418
  status: 302,
390
419
  headers: {
391
420
  "Location": "/login",
392
- "Set-Cookie": "__elm_ssr_session=; Path=/; Max-Age=0; HttpOnly"
421
+ "Set-Cookie": cookieVal
393
422
  }
394
423
  });
395
424
  }
@@ -474,11 +503,13 @@ if (typeof Bun !== "undefined") {
474
503
  }`;
475
504
 
476
505
  let sessionsConfig = '';
506
+ let authInit = '';
477
507
  if (auth) {
508
+ authInit = `\nexport const sessionStore = memorySessionStore();\n`;
478
509
  sessionsConfig = `,
479
510
  sessions: {
480
511
  secret: (env) => (env?.SESSION_SECRET as string) || "change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars",
481
- store: memorySessionStore(),
512
+ store: sessionStore,
482
513
  secure: false
483
514
  },
484
515
  csrf: true`;
@@ -492,7 +523,8 @@ const baseWorkerFetch = worker.fetch;
492
523
  worker.fetch = async (request, env, ctx) => {
493
524
  const url = new URL(request.url);
494
525
  if (url.pathname.startsWith("/api/auth/")) {
495
- return handleAuth(request, env);
526
+ const secret = (env?.SESSION_SECRET as string) || "change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32-chars";
527
+ return handleAuth(request, env, { store: sessionStore, secret });
496
528
  }
497
529
  return baseWorkerFetch(request, env, ctx);
498
530
  };
@@ -582,7 +614,7 @@ export const createFlags = ({ request, path, formData, env }: { request?: Reques
582
614
 
583
615
  export const renderPath = async (path: string) =>
584
616
  renderApp(elmModule, createFlags({ path }));
585
- ${dbInit}
617
+ ${dbInit}${authInit}
586
618
  export const worker = createWorkerApp({
587
619
  elmModule,
588
620
  islands,
@@ -663,6 +695,7 @@ const filesForApp = (name, appRoot, options = {}) => {
663
695
  const namespace = toPascalCase(name);
664
696
  const db = options.db || !!options.auth;
665
697
  const auth = options.auth;
698
+ const tailwind = options.tailwind;
666
699
 
667
700
  const files = [
668
701
  { path: `${appRoot}/elm.json`, content: JSON.stringify(elmJsonTemplate(), null, 2) + "\n" },
@@ -710,12 +743,27 @@ SESSION_SECRET="change-me-to-a-secure-random-hmac-secret-key-that-is-at-least-32
710
743
  });
711
744
  }
712
745
 
746
+ if (tailwind) {
747
+ files.push({
748
+ path: `${appRoot}/src/app.css`,
749
+ content: `@tailwind base;
750
+ @tailwind components;
751
+ @tailwind utilities;
752
+ `
753
+ });
754
+ }
755
+
756
+ const configEntry = {
757
+ name,
758
+ root: appRoot,
759
+ module: namespace
760
+ };
761
+ if (tailwind) {
762
+ configEntry.tailwind = true;
763
+ }
764
+
713
765
  return {
714
- configEntry: {
715
- name,
716
- root: appRoot,
717
- module: namespace
718
- },
766
+ configEntry,
719
767
  files
720
768
  };
721
769
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "elm-ssr",
3
- "version": "0.91.3",
3
+ "version": "0.91.6",
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>",
@@ -58,6 +58,7 @@
58
58
  "bun": ">=1.3"
59
59
  },
60
60
  "dependencies": {
61
- "cookie": "^1.0.1"
61
+ "cookie": "^1.0.1",
62
+ "tailwindcss": "3.4.17"
62
63
  }
63
64
  }
package/src/app.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ Middleware,
2
3
  RenderFlagsFactory,
3
4
  RouteCatalog,
4
5
  WorkerExecutionContext,
@@ -50,6 +51,7 @@ export interface WorkerAppOptions {
50
51
  * skip paths.
51
52
  */
52
53
  csrf?: CsrfMiddlewareOptions | boolean;
54
+ middlewares?: Middleware[];
53
55
  debug?: boolean;
54
56
  }
55
57
 
@@ -64,6 +66,7 @@ export const createWorkerApp = ({
64
66
  log,
65
67
  sessions,
66
68
  csrf,
69
+ middlewares,
67
70
  debug
68
71
  }: WorkerAppOptions): WorkerHandler => {
69
72
  const isDev = typeof process !== "undefined" && process.env ? (process.env.NODE_ENV === "development" || process.env.NODE_ENV === "test") : false;
@@ -83,21 +86,24 @@ export const createWorkerApp = ({
83
86
  debug: enableDebug
84
87
  });
85
88
 
86
- const middlewares = [
89
+ const middlewaresList = [
87
90
  errorMiddleware,
88
91
  requestIdMiddleware,
89
92
  timingMiddleware,
90
93
  loggingMiddleware(log)
91
94
  ];
92
95
  if (sessions) {
93
- middlewares.push(sessionMiddleware(sessions));
96
+ middlewaresList.push(sessionMiddleware(sessions));
94
97
  if (csrf) {
95
- middlewares.push(csrfMiddleware(csrf === true ? {} : csrf));
98
+ middlewaresList.push(csrfMiddleware(csrf === true ? {} : csrf));
96
99
  }
97
100
  }
98
- middlewares.push(headMiddleware);
101
+ if (middlewares) {
102
+ middlewaresList.push(...middlewares);
103
+ }
104
+ middlewaresList.push(headMiddleware);
99
105
 
100
- const appHandler = composeMiddleware(handler, middlewares);
106
+ const appHandler = composeMiddleware(handler, middlewaresList);
101
107
 
102
108
  return {
103
109
  fetch(request: Request, env?: unknown, executionCtx?: WorkerExecutionContext) {
@@ -6,6 +6,7 @@ export { memorySessionStore, cacheStore, type CacheStoreOptions } from "./store"
6
6
  export {
7
7
  sessionMiddleware,
8
8
  csrfMiddleware,
9
+ readSignedCookie,
9
10
  type SessionMiddlewareOptions,
10
11
  type CsrfMiddlewareOptions
11
12
  } from "./middleware";
@@ -50,7 +50,7 @@ const appendSetCookie = (response: Response, header: string): Response => {
50
50
  });
51
51
  };
52
52
 
53
- const readSignedCookie = async (header: string | null, name: string, secret: string): Promise<string | null> => {
53
+ export const readSignedCookie = async (header: string | null, name: string, secret: string): Promise<string | null> => {
54
54
  if (!header) {
55
55
  return null;
56
56
  }
@@ -104,14 +104,21 @@ export const sessionMiddleware = (options: SessionMiddlewareOptions): Middleware
104
104
  if (sessionId) {
105
105
  const existing = await options.store.get(sessionId);
106
106
  if (existing) {
107
- session = {
108
- id: sessionId,
109
- data: existing.data,
110
- csrf: existing.csrf,
111
- dirty: false,
112
- destroyed: false,
113
- isNew: false
114
- };
107
+ if (existing.expiresAt && Date.now() > existing.expiresAt) {
108
+ // Session expired
109
+ await options.store.delete(sessionId);
110
+ session = mintSession();
111
+ session.destroyed = true; // Ensure the cookie gets cleared as well
112
+ } else {
113
+ session = {
114
+ id: sessionId,
115
+ data: existing.data,
116
+ csrf: existing.csrf,
117
+ dirty: false,
118
+ destroyed: false,
119
+ isNew: false
120
+ };
121
+ }
115
122
  } else {
116
123
  session = mintSession();
117
124
  }
@@ -2,9 +2,10 @@ import type { CacheBackend } from "../backends";
2
2
  import type { SessionRecord, SessionStore } from "./types";
3
3
 
4
4
  /** In-memory session store. Survives only as long as the process. */
5
- export const memorySessionStore = (initial?: Map<string, SessionRecord>): SessionStore => {
5
+ export const memorySessionStore = (initial?: Map<string, SessionRecord>): SessionStore & { store: Map<string, SessionRecord> } => {
6
6
  const store = initial ?? new Map<string, SessionRecord>();
7
7
  return {
8
+ store,
8
9
  get: async (id) => {
9
10
  const record = store.get(id);
10
11
  if (!record) {