create-lacspace-app 2.3.0 → 2.5.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/dist/lib.js CHANGED
@@ -37,7 +37,8 @@ function resolveContext(options = {}) {
37
37
  const seg = raw.split(/[\\/]/).filter(Boolean).pop() ?? "my-app";
38
38
  const name = seg.toLowerCase().replace(/[^a-z0-9-_]/g, "-").replace(/^-+|-+$/g, "") || "my-app";
39
39
  const features = normalizeFeatures(options.features);
40
- return { name, template, features };
40
+ const mode = options.mode === "dynamic" ? "dynamic" : "static";
41
+ return { name, template, features, mode };
41
42
  }
42
43
  function normalizeFeatures(requested) {
43
44
  if (!requested || requested.length === 0) return [];
@@ -1910,6 +1911,10 @@ Because posts are just Markdown files, you can:
1910
1911
  Check the [first post](/blog/welcome) for the full Markdown reference.
1911
1912
  `;
1912
1913
  function buildFiles(ctx) {
1914
+ if (ctx.mode === "dynamic") return buildFullStack(ctx);
1915
+ return buildApp(ctx);
1916
+ }
1917
+ function buildApp(ctx) {
1913
1918
  const isBlog = ctx.template.key === "blog";
1914
1919
  const isDocs = ctx.template.key === "docs";
1915
1920
  const files = {
@@ -2113,6 +2118,51 @@ var FEATURES = [
2113
2118
  "Run `npm run dev` and open http://localhost:3000/ask."
2114
2119
  ],
2115
2120
  learn: "https://developer.lacspace.com/packages/rag"
2121
+ },
2122
+ {
2123
+ key: "content",
2124
+ label: "Content / blog",
2125
+ description: "A markdown content section (/updates) for any template \u2014 plus an RSS feed and an llms.txt. Drop in .md files, get pages.",
2126
+ deps: {
2127
+ "@lacspace/markdown": "^1.1.0",
2128
+ "@lacspace/rss": "^1.4.0",
2129
+ "@lacspace/llms-txt": "^1.4.0"
2130
+ },
2131
+ files: (ctx) => ({
2132
+ "content/updates/welcome.md": contentSampleWelcome(ctx),
2133
+ "content/updates/building-in-public.md": contentSampleSecond(),
2134
+ "lib/content.ts": contentLib(),
2135
+ "app/updates/page.tsx": updatesListPage(),
2136
+ "app/updates/[slug]/page.tsx": updatePostPage(),
2137
+ "app/feed.xml/route.ts": feedRoute(),
2138
+ "app/llms.txt/route.ts": llmsRoute()
2139
+ }),
2140
+ nextSteps: [
2141
+ "Add markdown files to content/updates/ \u2014 each becomes a page at /updates/<name>.",
2142
+ "Run `npm run dev` and open http://localhost:3000/updates, /feed.xml and /llms.txt.",
2143
+ "Set NEXT_PUBLIC_SITE_URL in .env so the feed and llms.txt links are absolute."
2144
+ ],
2145
+ learn: "https://developer.lacspace.com/packages/markdown"
2146
+ },
2147
+ {
2148
+ key: "search",
2149
+ label: "Search",
2150
+ description: "Instant, keyless full-text search (BM25) over your markdown content \u2014 a search box + /search page + API route. No key, no service.",
2151
+ deps: {
2152
+ "@lacspace/rerank": "^1.0.0",
2153
+ "@lacspace/markdown": "^1.1.0"
2154
+ },
2155
+ files: () => ({
2156
+ "app/api/search/route.ts": searchRoute(),
2157
+ "components/search.tsx": searchBox(),
2158
+ "app/search/page.tsx": searchPage()
2159
+ }),
2160
+ nextSteps: [
2161
+ "Add markdown to content/ (e.g. the `content` add-on's content/updates/) \u2014 search indexes it automatically.",
2162
+ "Run `npm run dev` and open http://localhost:3000/search, or drop <Search /> into your header.",
2163
+ "Want semantic search? Upgrade to @lacspace/embeddings + @lacspace/vector (keyless-local via Ollama)."
2164
+ ],
2165
+ learn: "https://developer.lacspace.com/packages/rerank"
2116
2166
  }
2117
2167
  ];
2118
2168
  var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
@@ -5091,6 +5141,1190 @@ var faqSection = (ctx) => {
5091
5141
  </div>
5092
5142
  </section>`;
5093
5143
  };
5144
+ var contentLib = () => `import fs from "node:fs";
5145
+ import path from "node:path";
5146
+ import { parseFrontmatter, markdownToHtml, excerpt } from "@lacspace/markdown";
5147
+
5148
+ // how this works: every .md file in content/updates/ becomes a post. Frontmatter
5149
+ // (title/date/description) is read with @lacspace/markdown; the body is rendered to
5150
+ // safe HTML (raw HTML in the source is escaped, so this is XSS-safe by construction).
5151
+ const DIR = path.join(process.cwd(), "content/updates");
5152
+
5153
+ export interface PostMeta { slug: string; title: string; date: string; description: string; }
5154
+ export interface Post extends PostMeta { html: string; }
5155
+
5156
+ function read(slug: string): Post | null {
5157
+ const file = path.join(DIR, slug + ".md");
5158
+ if (!fs.existsSync(file)) return null;
5159
+ const { data, content } = parseFrontmatter(fs.readFileSync(file, "utf8"));
5160
+ return {
5161
+ slug,
5162
+ title: String(data.title ?? slug),
5163
+ date: String(data.date ?? ""),
5164
+ description: String(data.description ?? excerpt(content, { length: 160 })),
5165
+ html: markdownToHtml(content, { headingIds: true, openLinksInNewTab: true }),
5166
+ };
5167
+ }
5168
+
5169
+ export function getAllPosts(): PostMeta[] {
5170
+ if (!fs.existsSync(DIR)) return [];
5171
+ return fs.readdirSync(DIR)
5172
+ .filter((f) => f.endsWith(".md"))
5173
+ .map((f) => read(f.replace(/\\.md$/, ""))!)
5174
+ .filter(Boolean)
5175
+ .sort((a, b) => (a.date < b.date ? 1 : -1));
5176
+ }
5177
+
5178
+ export function getPost(slug: string): Post | null {
5179
+ return read(slug);
5180
+ }
5181
+ `;
5182
+ var updatesListPage = () => `import Link from "next/link";
5183
+ import { site } from "@/lib/site";
5184
+ import { getAllPosts } from "@/lib/content";
5185
+
5186
+ export const metadata = site.meta({ title: "Updates", path: "/updates" });
5187
+
5188
+ export default function UpdatesPage() {
5189
+ const posts = getAllPosts();
5190
+ return (
5191
+ <main className="mx-auto max-w-3xl px-6 py-16">
5192
+ <h1 className="text-3xl font-bold">Updates</h1>
5193
+ <p className="mt-2 text-muted">News, notes and posts. <a href="/feed.xml" className="underline">RSS</a></p>
5194
+ {posts.length === 0 && (
5195
+ <p className="mt-8 text-muted">No posts yet \u2014 add a markdown file in <code>content/updates/</code>.</p>
5196
+ )}
5197
+ <ul className="mt-8 space-y-6">
5198
+ {posts.map((p) => (
5199
+ <li key={p.slug} className="border-b border-hairline pb-6">
5200
+ <Link href={\`/updates/\${p.slug}\`} className="text-xl font-semibold hover:underline">{p.title}</Link>
5201
+ {p.date && <p className="mt-1 text-sm text-muted">{p.date}</p>}
5202
+ <p className="mt-2 text-muted">{p.description}</p>
5203
+ </li>
5204
+ ))}
5205
+ </ul>
5206
+ </main>
5207
+ );
5208
+ }
5209
+ `;
5210
+ var updatePostPage = () => `import Link from "next/link";
5211
+ import { notFound } from "next/navigation";
5212
+ import { site } from "@/lib/site";
5213
+ import { getAllPosts, getPost } from "@/lib/content";
5214
+
5215
+ export function generateStaticParams() {
5216
+ return getAllPosts().map((p) => ({ slug: p.slug }));
5217
+ }
5218
+
5219
+ export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
5220
+ const { slug } = await params;
5221
+ const post = getPost(slug);
5222
+ return site.meta({ title: post?.title ?? "Updates", path: \`/updates/\${slug}\` });
5223
+ }
5224
+
5225
+ export default async function PostPage({ params }: { params: Promise<{ slug: string }> }) {
5226
+ const { slug } = await params;
5227
+ const post = getPost(slug);
5228
+ if (!post) notFound();
5229
+ return (
5230
+ <main className="mx-auto max-w-2xl px-6 py-16">
5231
+ <Link href="/updates" className="text-sm text-muted hover:underline">\u2190 Updates</Link>
5232
+ <h1 className="mt-4 text-3xl font-bold">{post.title}</h1>
5233
+ {post.date && <p className="mt-1 text-sm text-muted">{post.date}</p>}
5234
+ <article
5235
+ className="mt-8 leading-7 [&_h2]:mt-8 [&_h2]:text-2xl [&_h2]:font-bold [&_h3]:mt-6 [&_h3]:text-xl [&_h3]:font-semibold [&_p]:mt-4 [&_p]:text-muted [&_a]:underline [&_ul]:mt-4 [&_ul]:list-disc [&_ul]:pl-6 [&_li]:mt-1 [&_pre]:mt-4 [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-surface [&_pre]:p-4 [&_pre]:text-sm [&_code]:text-sm"
5236
+ dangerouslySetInnerHTML={{ __html: post.html }}
5237
+ />
5238
+ </main>
5239
+ );
5240
+ }
5241
+ `;
5242
+ var feedRoute = () => `import { rssResponse } from "@lacspace/rss";
5243
+ import { getAllPosts } from "@/lib/content";
5244
+
5245
+ export const dynamic = "force-static";
5246
+
5247
+ export function GET() {
5248
+ const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
5249
+ const items = getAllPosts().map((p) => ({
5250
+ title: p.title,
5251
+ link: base + "/updates/" + p.slug,
5252
+ description: p.description,
5253
+ // Only pass a date the feed can parse (rfc822Date throws on bad input).
5254
+ date: p.date && !Number.isNaN(Date.parse(p.date)) ? p.date : undefined,
5255
+ }));
5256
+ return rssResponse(
5257
+ { title: "Updates", link: base, description: "The latest posts and updates.", feedUrl: base + "/feed.xml" },
5258
+ items,
5259
+ );
5260
+ }
5261
+ `;
5262
+ var llmsRoute = () => `import { llmsTxtFromPages } from "@lacspace/llms-txt";
5263
+ import { getAllPosts } from "@/lib/content";
5264
+
5265
+ export const dynamic = "force-static";
5266
+
5267
+ export function GET() {
5268
+ const base = process.env.NEXT_PUBLIC_SITE_URL ?? "http://localhost:3000";
5269
+ const txt = llmsTxtFromPages(
5270
+ getAllPosts().map((p) => ({ title: p.title, url: base + "/updates/" + p.slug, content: p.description, section: "Updates" })),
5271
+ { title: "Updates", summary: "A content index for large language models." },
5272
+ );
5273
+ return new Response(txt, { headers: { "content-type": "text/plain; charset=utf-8" } });
5274
+ }
5275
+ `;
5276
+ var contentSampleWelcome = (ctx) => `---
5277
+ title: Welcome to ${ctx.template.siteName}'s content section
5278
+ date: 2026-01-15
5279
+ description: This markdown file became a page automatically \u2014 here's how it works.
5280
+ ---
5281
+
5282
+ ## It just works
5283
+
5284
+ Drop any \\\`.md\\\` file into \\\`content/updates/\\\` and it becomes a page at \\\`/updates/<filename>\\\`, listed on \\\`/updates\\\`, and included in \\\`/feed.xml\\\` and \\\`/llms.txt\\\`.
5285
+
5286
+ - Frontmatter \\\`title\\\`, \\\`date\\\` and \\\`description\\\` are read automatically.
5287
+ - The body is rendered safely with \\\`@lacspace/markdown\\\` \u2014 raw HTML is escaped, so it's XSS-safe.
5288
+ - Delete this file when you're ready to write your own.
5289
+ `;
5290
+ var contentSampleSecond = () => `---
5291
+ title: Building in public
5292
+ date: 2026-01-10
5293
+ description: A second example post so the list and feed have something to show.
5294
+ ---
5295
+
5296
+ ## Why a content section?
5297
+
5298
+ Every product needs a place for changelog posts, announcements and notes. This one is
5299
+ Markdown-powered, needs no database, and ships an RSS feed and an \\\`llms.txt\\\` for free.
5300
+
5301
+ ### Add your own
5302
+
5303
+ 1. Create \\\`content/updates/my-post.md\\\`.
5304
+ 2. Add frontmatter (\\\`title\\\`, \\\`date\\\`, \\\`description\\\`).
5305
+ 3. Write. That's it \u2014 it appears at \\\`/updates/my-post\\\`.
5306
+ `;
5307
+ var searchRoute = () => `import fs from "node:fs";
5308
+ import path from "node:path";
5309
+ import { parseFrontmatter, toPlainText, excerpt } from "@lacspace/markdown";
5310
+ import { rerank } from "@lacspace/rerank";
5311
+
5312
+ // how this works: full-text search with BM25 ranking \u2014 no external service, no API
5313
+ // key, no Ollama. It scans your markdown at request time and ranks matches. (For a
5314
+ // big site, precompute the corpus at build time and cache it instead.)
5315
+ export const runtime = "nodejs";
5316
+ export const dynamic = "force-dynamic";
5317
+
5318
+ const ROOT = path.join(process.cwd(), "content");
5319
+ // Map a content/<dir>/ to the route it renders under.
5320
+ const SECTION: Record<string, string> = { updates: "/updates", posts: "/blog", docs: "/docs" };
5321
+
5322
+ interface Entry { id: string; title: string; url: string; text: string; }
5323
+
5324
+ function corpus(): Entry[] {
5325
+ if (!fs.existsSync(ROOT)) return [];
5326
+ const out: Entry[] = [];
5327
+ for (const dir of fs.readdirSync(ROOT)) {
5328
+ const full = path.join(ROOT, dir);
5329
+ if (!fs.statSync(full).isDirectory()) continue;
5330
+ const base = SECTION[dir] ?? "/" + dir;
5331
+ for (const file of fs.readdirSync(full)) {
5332
+ if (!file.endsWith(".md")) continue;
5333
+ const slug = file.replace(/\\.md$/, "");
5334
+ const { data, content } = parseFrontmatter(fs.readFileSync(path.join(full, file), "utf8"));
5335
+ out.push({ id: dir + "/" + slug, title: String(data.title ?? slug), url: base + "/" + slug, text: toPlainText(content) });
5336
+ }
5337
+ }
5338
+ return out;
5339
+ }
5340
+
5341
+ export async function GET(req: Request) {
5342
+ const q = new URL(req.url).searchParams.get("q")?.trim() ?? "";
5343
+ if (!q) return Response.json([]);
5344
+ const docs = corpus().map((e) => ({ id: e.id, text: e.title + "\\n" + e.text, metadata: { title: e.title, url: e.url } }));
5345
+ const hits = await rerank(q, docs, { method: "bm25", k: 8 });
5346
+ return Response.json(hits.map((h) => ({
5347
+ title: String(h.metadata?.title ?? ""),
5348
+ url: String(h.metadata?.url ?? "#"),
5349
+ snippet: excerpt(h.text.split("\\n").slice(1).join(" "), { length: 140 }),
5350
+ })));
5351
+ }
5352
+ `;
5353
+ var searchBox = () => `"use client";
5354
+ import { useState, useEffect, useRef } from "react";
5355
+
5356
+ interface Result { title: string; url: string; snippet: string; }
5357
+
5358
+ export function Search() {
5359
+ const [q, setQ] = useState("");
5360
+ const [results, setResults] = useState<Result[]>([]);
5361
+ const [open, setOpen] = useState(false);
5362
+ const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
5363
+
5364
+ useEffect(() => {
5365
+ if (timer.current) clearTimeout(timer.current);
5366
+ if (!q.trim()) { setResults([]); return; }
5367
+ timer.current = setTimeout(async () => {
5368
+ const res = await fetch("/api/search?q=" + encodeURIComponent(q));
5369
+ setResults(await res.json());
5370
+ setOpen(true);
5371
+ }, 200);
5372
+ return () => { if (timer.current) clearTimeout(timer.current); };
5373
+ }, [q]);
5374
+
5375
+ return (
5376
+ <div className="relative w-full max-w-md">
5377
+ <input
5378
+ value={q}
5379
+ onChange={(e) => setQ(e.target.value)}
5380
+ onFocus={() => { if (results.length) setOpen(true); }}
5381
+ onBlur={() => setTimeout(() => setOpen(false), 150)}
5382
+ placeholder="Search\u2026"
5383
+ className="w-full rounded-xl border border-hairline bg-surface px-4 py-2 outline-none"
5384
+ />
5385
+ {open && results.length > 0 && (
5386
+ <ul className="absolute z-20 mt-2 w-full overflow-hidden rounded-xl border border-hairline bg-app shadow-lg">
5387
+ {results.map((r) => (
5388
+ <li key={r.url}>
5389
+ <a href={r.url} className="block px-4 py-3 transition hover:bg-surface">
5390
+ <p className="font-medium">{r.title}</p>
5391
+ <p className="mt-0.5 text-sm text-muted">{r.snippet}</p>
5392
+ </a>
5393
+ </li>
5394
+ ))}
5395
+ </ul>
5396
+ )}
5397
+ </div>
5398
+ );
5399
+ }
5400
+ `;
5401
+ var searchPage = () => `import { site } from "@/lib/site";
5402
+ import { Search } from "@/components/search";
5403
+
5404
+ export const metadata = site.meta({ title: "Search", path: "/search" });
5405
+
5406
+ export default function SearchPage() {
5407
+ return (
5408
+ <main className="mx-auto max-w-2xl px-6 py-16">
5409
+ <h1 className="text-3xl font-bold">Search</h1>
5410
+ <p className="mt-2 text-muted">Instant, keyless full-text search over your content.</p>
5411
+ <div className="mt-8"><Search /></div>
5412
+ </main>
5413
+ );
5414
+ }
5415
+ `;
5416
+ var scope = (ctx) => `@${ctx.name}`;
5417
+ var typesPkgJson = (ctx) => JSON.stringify({
5418
+ name: `${scope(ctx)}/types`,
5419
+ version: "0.1.0",
5420
+ private: true,
5421
+ // Type-only package: it ships a single .d.ts (no runtime code, nothing to build).
5422
+ types: "./index.d.ts",
5423
+ exports: { ".": { types: "./index.d.ts" } }
5424
+ }, null, 2) + "\n";
5425
+ var sharedTypes = () => `// Shared API contract \u2014 imported by BOTH the frontend and the backend, so the two
5426
+ // can never drift. These are TYPE-ONLY (no runtime code), so importing them adds
5427
+ // nothing to either bundle. Edit here once; both sides update. Use \`import type\`.
5428
+
5429
+ /** A user, as returned by the API (no password; dates are ISO strings). */
5430
+ export interface User {
5431
+ id: string;
5432
+ email: string;
5433
+ name: string;
5434
+ createdAt: string;
5435
+ }
5436
+
5437
+ /** The response from POST /auth/register and POST /auth/login. */
5438
+ export interface AuthResponse {
5439
+ token: string;
5440
+ user: User;
5441
+ }
5442
+
5443
+ /** The shape of an error response from the API. */
5444
+ export interface ApiError {
5445
+ error: string;
5446
+ fields?: Record<string, string>;
5447
+ }
5448
+
5449
+ /** The example CRUD resource. Rename "Note" to your real domain object. */
5450
+ export interface Note {
5451
+ id: string;
5452
+ title: string;
5453
+ body: string;
5454
+ userId: string;
5455
+ createdAt: string;
5456
+ updatedAt: string;
5457
+ }
5458
+
5459
+ export interface CreateNoteInput {
5460
+ title: string;
5461
+ body?: string;
5462
+ }
5463
+
5464
+ export interface UpdateNoteInput {
5465
+ title?: string;
5466
+ body?: string;
5467
+ }
5468
+ `;
5469
+ var rootPkgJson = (ctx) => JSON.stringify({
5470
+ name: ctx.name,
5471
+ version: "0.1.0",
5472
+ private: true,
5473
+ workspaces: ["types", "backend", "frontend"],
5474
+ scripts: {
5475
+ // Run the API and the frontend together (concurrently), colourised + kill-all.
5476
+ dev: 'concurrently -k -n api,web -c blue,magenta "npm:dev:api" "npm:dev:web"',
5477
+ "dev:api": `npm run dev --workspace ${scope(ctx)}/backend`,
5478
+ "dev:web": `npm run dev --workspace ${scope(ctx)}/frontend`,
5479
+ build: `npm run build --workspace ${scope(ctx)}/backend && npm run build --workspace ${scope(ctx)}/frontend`,
5480
+ start: 'concurrently -k -n api,web -c blue,magenta "npm:start:api" "npm:start:web"',
5481
+ "start:api": `npm run start --workspace ${scope(ctx)}/backend`,
5482
+ "start:web": `npm run start --workspace ${scope(ctx)}/frontend`,
5483
+ typecheck: `npm run typecheck --workspace ${scope(ctx)}/backend`
5484
+ },
5485
+ devDependencies: { concurrently: "^9.1.0" },
5486
+ engines: { node: ">=18" }
5487
+ }, null, 2) + "\n";
5488
+ var dockerCompose = (ctx) => `# Local infrastructure for ${ctx.name}. Start it with: docker compose up -d
5489
+ # Optional \u2014 the API also runs with NO Redis, and against any MongoDB (e.g. Atlas).
5490
+ services:
5491
+ mongo:
5492
+ image: mongo:7
5493
+ restart: unless-stopped
5494
+ ports:
5495
+ - "27017:27017"
5496
+ volumes:
5497
+ - mongo-data:/data/db
5498
+ redis:
5499
+ image: redis:7-alpine
5500
+ restart: unless-stopped
5501
+ ports:
5502
+ - "6379:6379"
5503
+
5504
+ volumes:
5505
+ mongo-data:
5506
+ `;
5507
+ var rootGitignore = () => `node_modules
5508
+ .env
5509
+ .env.local
5510
+ *.log
5511
+ .DS_Store
5512
+ dist
5513
+ .next
5514
+ `;
5515
+ var rootEnvExample = (ctx) => `# ${ctx.name} \u2014 ONE .env for the whole monorepo. Copy it: cp .env.example .env
5516
+ # The defaults below let the app run locally with zero changes.
5517
+
5518
+ # --- Backend ---
5519
+ NODE_ENV=development
5520
+ PORT=4000
5521
+
5522
+ # MongoDB. With Docker (docker compose up -d) the default just works. Or paste a
5523
+ # free MongoDB Atlas connection string here.
5524
+ MONGODB_URI=mongodb://localhost:27017/${ctx.name}
5525
+
5526
+ # Signs JWTs. In production generate a strong value: openssl rand -hex 32
5527
+ JWT_SECRET=dev-secret-change-me
5528
+
5529
+ # Redis is OPTIONAL \u2014 leave empty to use the in-memory cache fallback.
5530
+ # With Docker, set it to: redis://localhost:6379
5531
+ REDIS_URL=
5532
+
5533
+ # Who may call the API (the frontend origin). Comma-separate for multiple.
5534
+ CORS_ORIGIN=http://localhost:3000
5535
+
5536
+ # --- Frontend ---
5537
+ # Where the browser calls the API. Unset = http://localhost:4000 (local default).
5538
+ # NEXT_PUBLIC_API_URL=http://localhost:4000
5539
+ `;
5540
+ var rootReadme = (ctx) => `# ${ctx.name}
5541
+
5542
+ A full-stack app scaffolded with [create-lacspace-app](https://www.npmjs.com/package/create-lacspace-app) \u2014 a **${ctx.template.label}** frontend + a real backend, wired together.
5543
+
5544
+ \`\`\`
5545
+ ${ctx.name}/
5546
+ \u251C\u2500 frontend/ Next.js 15 app (your UI) \u2014 talks to the API
5547
+ \u251C\u2500 backend/ Node \xB7 Express \xB7 MongoDB \xB7 Redis \xB7 TypeScript API
5548
+ \u251C\u2500 types/ Shared API types \u2014 imported by BOTH sides, so they can't drift
5549
+ \u2514\u2500 docker-compose.yml Mongo + Redis for local dev (optional)
5550
+ \`\`\`
5551
+
5552
+ ## Quick start
5553
+
5554
+ \`\`\`bash
5555
+ cp .env.example .env # one env file for everything (sensible defaults)
5556
+ docker compose up -d # optional: starts MongoDB + Redis locally
5557
+ npm install # installs all three workspaces at once
5558
+ npm run dev # runs the API (:4000) and the frontend (:3000) together
5559
+ \`\`\`
5560
+
5561
+ Then open **http://localhost:3000** \u2192 visit **/register**, create an account, and you'll land on **/account** \u2014 a protected page that reads/writes the example "notes" resource through the API.
5562
+
5563
+ **No Docker?** Point \`MONGODB_URI\` at any MongoDB (a free [Atlas](https://www.mongodb.com/atlas) cluster works). Redis is optional \u2014 leave \`REDIS_URL\` empty and the API uses an in-memory cache instead.
5564
+
5565
+ ## What's already built
5566
+
5567
+ - **Auth** \u2014 \`POST /auth/register\`, \`POST /auth/login\` (JWT), \`GET /auth/me\`. Passwords hashed with \`@lacspace/password\`, tokens signed/verified with \`@lacspace/jwt\`.
5568
+ - **Example CRUD** \u2014 \`/notes\` (list \xB7 create \xB7 read \xB7 update \xB7 delete), protected, per-user, with a per-user cache (Redis or in-memory) that busts on writes.
5569
+ - **Validation** \u2014 request bodies validated with \`@lacspace/validate\`; failures return a clean 400.
5570
+ - **Rate limiting** \u2014 auth endpoints throttled with \`@lacspace/rate-limit\`.
5571
+ - **Typed env** \u2014 \`@lacspace/env\` fails fast at boot if config is wrong.
5572
+
5573
+ Everything on the backend is built from zero-dependency \`@lacspace/*\` packages \u2192 https://lacspace.com/packages
5574
+
5575
+ ## Make it yours
5576
+
5577
+ Rename the \`Note\` resource (in \`types/index.d.ts\`, \`backend/src/models/note.ts\`, \`backend/src/routes/notes.ts\`) to your real domain object, then follow the same pattern for more resources. The shared \`types/\` package keeps the frontend and backend in lock-step.
5578
+ `;
5579
+ var frontendApiClient = (ctx) => `import type { AuthResponse, User, Note, CreateNoteInput, ApiError } from "${scope(ctx)}/types";
5580
+
5581
+ // how this works: a tiny typed fetch wrapper around the backend API. The response
5582
+ // shapes come from the shared "${scope(ctx)}/types" package \u2014 the SAME types the
5583
+ // backend uses \u2014 so the client and server can never disagree about the contract.
5584
+ const BASE = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
5585
+ const TOKEN_KEY = "${ctx.name}_token";
5586
+
5587
+ export function getToken(): string | null {
5588
+ if (typeof window === "undefined") return null;
5589
+ try { return localStorage.getItem(TOKEN_KEY); } catch { return null; }
5590
+ }
5591
+ export function setToken(token: string | null): void {
5592
+ if (typeof window === "undefined") return;
5593
+ try { token ? localStorage.setItem(TOKEN_KEY, token) : localStorage.removeItem(TOKEN_KEY); } catch {}
5594
+ }
5595
+
5596
+ async function request<T>(path: string, init?: RequestInit): Promise<T> {
5597
+ const token = getToken();
5598
+ const res = await fetch(\`\${BASE}\${path}\`, {
5599
+ ...init,
5600
+ headers: {
5601
+ "Content-Type": "application/json",
5602
+ ...(token ? { Authorization: \`Bearer \${token}\` } : {}),
5603
+ ...(init?.headers ?? {}),
5604
+ },
5605
+ });
5606
+ if (!res.ok) {
5607
+ let message = "Request failed";
5608
+ try { message = ((await res.json()) as ApiError).error ?? message; } catch {}
5609
+ throw new Error(message);
5610
+ }
5611
+ if (res.status === 204) return undefined as T;
5612
+ return (await res.json()) as T;
5613
+ }
5614
+
5615
+ export const api = {
5616
+ register: (input: { name: string; email: string; password: string }) =>
5617
+ request<AuthResponse>("/auth/register", { method: "POST", body: JSON.stringify(input) }),
5618
+ login: (input: { email: string; password: string }) =>
5619
+ request<AuthResponse>("/auth/login", { method: "POST", body: JSON.stringify(input) }),
5620
+ me: () => request<User>("/auth/me"),
5621
+ listNotes: () => request<Note[]>("/notes"),
5622
+ createNote: (input: CreateNoteInput) =>
5623
+ request<Note>("/notes", { method: "POST", body: JSON.stringify(input) }),
5624
+ deleteNote: (id: string) => request<void>(\`/notes/\${id}\`, { method: "DELETE" }),
5625
+ };
5626
+ `;
5627
+ var frontendLoginPage = () => `"use client";
5628
+ import { useState } from "react";
5629
+ import { useRouter } from "next/navigation";
5630
+ import Link from "next/link";
5631
+ import { api, setToken } from "@/lib/api";
5632
+
5633
+ // how this works: posts to the backend /auth/login, stores the returned JWT, then
5634
+ // redirects to /account. Every request after this sends the token automatically.
5635
+ export default function LoginPage() {
5636
+ const router = useRouter();
5637
+ const [email, setEmail] = useState("");
5638
+ const [password, setPassword] = useState("");
5639
+ const [error, setError] = useState<string | null>(null);
5640
+ const [busy, setBusy] = useState(false);
5641
+
5642
+ async function onSubmit(e: React.FormEvent) {
5643
+ e.preventDefault();
5644
+ setBusy(true); setError(null);
5645
+ try {
5646
+ const { token } = await api.login({ email, password });
5647
+ setToken(token);
5648
+ router.push("/account");
5649
+ } catch (err) {
5650
+ setError(err instanceof Error ? err.message : "Something went wrong");
5651
+ } finally { setBusy(false); }
5652
+ }
5653
+
5654
+ return (
5655
+ <main className="mx-auto flex min-h-[70vh] max-w-sm flex-col justify-center px-6">
5656
+ <h1 className="text-2xl font-bold">Welcome back</h1>
5657
+ <p className="mt-1 text-muted">Sign in to your account.</p>
5658
+ <form onSubmit={onSubmit} className="mt-6 space-y-4">
5659
+ <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required placeholder="you@example.com" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
5660
+ <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" required placeholder="Password" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
5661
+ {error && <p className="text-sm text-red-400">{error}</p>}
5662
+ <button disabled={busy} className="w-full rounded-full gradient-bg px-4 py-3 font-semibold on-accent transition hover:opacity-90 disabled:opacity-60">{busy ? "Signing in\u2026" : "Sign in"}</button>
5663
+ </form>
5664
+ <p className="mt-4 text-sm text-muted">No account? <Link href="/register" className="underline">Create one</Link></p>
5665
+ </main>
5666
+ );
5667
+ }
5668
+ `;
5669
+ var frontendRegisterPage = () => `"use client";
5670
+ import { useState } from "react";
5671
+ import { useRouter } from "next/navigation";
5672
+ import Link from "next/link";
5673
+ import { api, setToken } from "@/lib/api";
5674
+
5675
+ // how this works: posts to /auth/register, stores the JWT, redirects to /account.
5676
+ export default function RegisterPage() {
5677
+ const router = useRouter();
5678
+ const [name, setName] = useState("");
5679
+ const [email, setEmail] = useState("");
5680
+ const [password, setPassword] = useState("");
5681
+ const [error, setError] = useState<string | null>(null);
5682
+ const [busy, setBusy] = useState(false);
5683
+
5684
+ async function onSubmit(e: React.FormEvent) {
5685
+ e.preventDefault();
5686
+ setBusy(true); setError(null);
5687
+ try {
5688
+ const { token } = await api.register({ name, email, password });
5689
+ setToken(token);
5690
+ router.push("/account");
5691
+ } catch (err) {
5692
+ setError(err instanceof Error ? err.message : "Something went wrong");
5693
+ } finally { setBusy(false); }
5694
+ }
5695
+
5696
+ return (
5697
+ <main className="mx-auto flex min-h-[70vh] max-w-sm flex-col justify-center px-6">
5698
+ <h1 className="text-2xl font-bold">Create your account</h1>
5699
+ <p className="mt-1 text-muted">It takes a few seconds.</p>
5700
+ <form onSubmit={onSubmit} className="mt-6 space-y-4">
5701
+ <input value={name} onChange={(e) => setName(e.target.value)} required placeholder="Your name" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
5702
+ <input value={email} onChange={(e) => setEmail(e.target.value)} type="email" required placeholder="you@example.com" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
5703
+ <input value={password} onChange={(e) => setPassword(e.target.value)} type="password" required minLength={8} placeholder="Password (min 8 chars)" className="w-full rounded-xl border border-hairline bg-surface px-4 py-3 outline-none" />
5704
+ {error && <p className="text-sm text-red-400">{error}</p>}
5705
+ <button disabled={busy} className="w-full rounded-full gradient-bg px-4 py-3 font-semibold on-accent transition hover:opacity-90 disabled:opacity-60">{busy ? "Creating\u2026" : "Create account"}</button>
5706
+ </form>
5707
+ <p className="mt-4 text-sm text-muted">Already have one? <Link href="/login" className="underline">Sign in</Link></p>
5708
+ </main>
5709
+ );
5710
+ }
5711
+ `;
5712
+ var frontendAccountPage = (ctx) => `"use client";
5713
+ import { useEffect, useState } from "react";
5714
+ import { useRouter } from "next/navigation";
5715
+ import { api, getToken, setToken } from "@/lib/api";
5716
+ import type { User, Note } from "${scope(ctx)}/types";
5717
+
5718
+ // how this works: a PROTECTED page. On mount it calls /auth/me with the stored
5719
+ // token; if that fails it bounces to /login. Notes are the example CRUD resource.
5720
+ export default function AccountPage() {
5721
+ const router = useRouter();
5722
+ const [user, setUser] = useState<User | null>(null);
5723
+ const [notes, setNotes] = useState<Note[]>([]);
5724
+ const [title, setTitle] = useState("");
5725
+ const [body, setBody] = useState("");
5726
+ const [error, setError] = useState<string | null>(null);
5727
+
5728
+ useEffect(() => {
5729
+ if (!getToken()) { router.push("/login"); return; }
5730
+ (async () => {
5731
+ try {
5732
+ setUser(await api.me());
5733
+ setNotes(await api.listNotes());
5734
+ } catch { setToken(null); router.push("/login"); }
5735
+ })();
5736
+ }, [router]);
5737
+
5738
+ async function addNote(e: React.FormEvent) {
5739
+ e.preventDefault();
5740
+ setError(null);
5741
+ try {
5742
+ const note = await api.createNote({ title, body });
5743
+ setNotes((prev) => [note, ...prev]);
5744
+ setTitle(""); setBody("");
5745
+ } catch (err) { setError(err instanceof Error ? err.message : "Failed to save"); }
5746
+ }
5747
+ async function remove(id: string) {
5748
+ await api.deleteNote(id);
5749
+ setNotes((prev) => prev.filter((n) => n.id !== id));
5750
+ }
5751
+ function signOut() { setToken(null); router.push("/login"); }
5752
+
5753
+ if (!user) return <main className="mx-auto max-w-2xl px-6 py-16 text-muted">Loading\u2026</main>;
5754
+
5755
+ return (
5756
+ <main className="mx-auto max-w-2xl px-6 py-16">
5757
+ <div className="flex items-center justify-between">
5758
+ <div>
5759
+ <h1 className="text-2xl font-bold">Hi, {user.name}</h1>
5760
+ <p className="text-muted">{user.email}</p>
5761
+ </div>
5762
+ <button onClick={signOut} className="rounded-full border border-hairline px-4 py-2 text-sm transition hover:bg-surface">Sign out</button>
5763
+ </div>
5764
+
5765
+ <form onSubmit={addNote} className="mt-8 space-y-3 rounded-2xl border border-hairline bg-surface p-5">
5766
+ <h2 className="font-semibold">Add a note</h2>
5767
+ <input value={title} onChange={(e) => setTitle(e.target.value)} required placeholder="Title" className="w-full rounded-xl border border-hairline bg-app px-4 py-2 outline-none" />
5768
+ <textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write something\u2026" rows={3} className="w-full rounded-xl border border-hairline bg-app px-4 py-2 outline-none" />
5769
+ {error && <p className="text-sm text-red-400">{error}</p>}
5770
+ <button className="rounded-full gradient-bg px-4 py-2 text-sm font-semibold on-accent">Save note</button>
5771
+ </form>
5772
+
5773
+ <ul className="mt-6 space-y-3">
5774
+ {notes.length === 0 && <li className="text-muted">No notes yet \u2014 add your first above.</li>}
5775
+ {notes.map((n) => (
5776
+ <li key={n.id} className="flex items-start justify-between rounded-2xl border border-hairline p-4">
5777
+ <div>
5778
+ <p className="font-medium">{n.title}</p>
5779
+ {n.body && <p className="mt-1 text-sm text-muted">{n.body}</p>}
5780
+ </div>
5781
+ <button onClick={() => remove(n.id)} className="shrink-0 text-sm text-muted transition hover:text-red-400">Delete</button>
5782
+ </li>
5783
+ ))}
5784
+ </ul>
5785
+ </main>
5786
+ );
5787
+ }
5788
+ `;
5789
+ function buildFullStack(ctx) {
5790
+ const out = {};
5791
+ const s = scope(ctx);
5792
+ const app = buildApp(ctx);
5793
+ for (const [rel, content] of Object.entries(app)) out[`frontend/${rel}`] = content;
5794
+ const fpkg = JSON.parse(out["frontend/package.json"]);
5795
+ fpkg.name = `${s}/frontend`;
5796
+ fpkg.dependencies[`${s}/types`] = "*";
5797
+ out["frontend/package.json"] = JSON.stringify(fpkg, null, 2) + "\n";
5798
+ const fts = JSON.parse(out["frontend/tsconfig.json"]);
5799
+ fts.compilerOptions.paths[`${s}/types`] = ["../types/index.d.ts"];
5800
+ out["frontend/tsconfig.json"] = JSON.stringify(fts, null, 2) + "\n";
5801
+ const fenv = out["frontend/.env.example"] ?? "";
5802
+ out["frontend/.env.example"] = (fenv.endsWith("\n") || fenv === "" ? fenv : fenv + "\n") + "\n# The backend API base URL. Unset = http://localhost:4000 (local default).\n# NEXT_PUBLIC_API_URL=http://localhost:4000\n";
5803
+ out["frontend/lib/api.ts"] = frontendApiClient(ctx);
5804
+ out["frontend/app/login/page.tsx"] = frontendLoginPage();
5805
+ out["frontend/app/register/page.tsx"] = frontendRegisterPage();
5806
+ out["frontend/app/account/page.tsx"] = frontendAccountPage(ctx);
5807
+ Object.assign(out, backendFiles(ctx));
5808
+ out["types/package.json"] = typesPkgJson(ctx);
5809
+ out["types/index.d.ts"] = sharedTypes();
5810
+ out["package.json"] = rootPkgJson(ctx);
5811
+ out["docker-compose.yml"] = dockerCompose(ctx);
5812
+ out[".gitignore"] = rootGitignore();
5813
+ out[".env.example"] = rootEnvExample(ctx);
5814
+ out["README.md"] = rootReadme(ctx);
5815
+ return out;
5816
+ }
5817
+ var backendPkgJson = (ctx) => JSON.stringify({
5818
+ name: `${scope(ctx)}/backend`,
5819
+ version: "0.1.0",
5820
+ private: true,
5821
+ type: "module",
5822
+ main: "dist/index.js",
5823
+ scripts: {
5824
+ dev: "tsx watch src/index.ts",
5825
+ build: "tsc -p tsconfig.json",
5826
+ start: "node dist/index.js",
5827
+ typecheck: "tsc -p tsconfig.json --noEmit"
5828
+ },
5829
+ dependencies: {
5830
+ express: "^4.21.2",
5831
+ cors: "^2.8.5",
5832
+ mongoose: "^8.9.0",
5833
+ ioredis: "^5.4.2",
5834
+ dotenv: "^16.4.7",
5835
+ // ✨ Backend built on zero-dep @lacspace/* packages instead of the usual grab-bag.
5836
+ "@lacspace/env": "^1.1.0",
5837
+ "@lacspace/jwt": "^1.4.0",
5838
+ "@lacspace/password": "^1.1.0",
5839
+ "@lacspace/validate": "^1.1.0",
5840
+ "@lacspace/id": "^1.1.0",
5841
+ "@lacspace/rate-limit": "^1.2.0",
5842
+ "@lacspace/cache": "^1.1.0",
5843
+ [`${scope(ctx)}/types`]: "*"
5844
+ },
5845
+ devDependencies: {
5846
+ typescript: "^5.7.0",
5847
+ tsx: "^4.19.2",
5848
+ "@types/node": "^22.10.0",
5849
+ "@types/express": "^4.17.21",
5850
+ "@types/cors": "^2.8.17"
5851
+ }
5852
+ }, null, 2) + "\n";
5853
+ var backendTsconfig = (ctx) => JSON.stringify({
5854
+ compilerOptions: {
5855
+ target: "ES2022",
5856
+ module: "NodeNext",
5857
+ moduleResolution: "NodeNext",
5858
+ lib: ["ES2022"],
5859
+ esModuleInterop: true,
5860
+ allowSyntheticDefaultImports: true,
5861
+ strict: true,
5862
+ skipLibCheck: true,
5863
+ resolveJsonModule: true,
5864
+ outDir: "dist",
5865
+ rootDir: "src",
5866
+ sourceMap: true,
5867
+ types: ["node"],
5868
+ baseUrl: ".",
5869
+ paths: { [`${scope(ctx)}/types`]: ["../types/index.d.ts"] }
5870
+ },
5871
+ include: ["src/**/*"],
5872
+ exclude: ["node_modules", "dist"]
5873
+ }, null, 2) + "\n";
5874
+ var backendGitignore = () => `node_modules
5875
+ dist
5876
+ .env
5877
+ *.log
5878
+ `;
5879
+ var backendReadme = (ctx) => `# ${ctx.name} \u2014 API
5880
+
5881
+ Node \xB7 Express \xB7 MongoDB \xB7 Redis (optional) \xB7 TypeScript. Built on zero-dependency \`@lacspace/*\` packages.
5882
+
5883
+ Run it from the repo root with \`npm run dev\` (starts this API + the frontend). Standalone: \`npm run dev\` inside this folder.
5884
+
5885
+ ## Endpoints
5886
+
5887
+ | Method | Path | Auth | Description |
5888
+ |--------|------------------|------|--------------------------------|
5889
+ | GET | \`/health\` | \u2014 | Liveness check |
5890
+ | POST | \`/auth/register\` | \u2014 | Create an account \u2192 JWT + user |
5891
+ | POST | \`/auth/login\` | \u2014 | Log in \u2192 JWT + user |
5892
+ | GET | \`/auth/me\` | \u2713 | The current user |
5893
+ | GET | \`/notes\` | \u2713 | List your notes (cached) |
5894
+ | POST | \`/notes\` | \u2713 | Create a note |
5895
+ | GET | \`/notes/:id\` | \u2713 | Read one note |
5896
+ | PATCH | \`/notes/:id\` | \u2713 | Update a note |
5897
+ | DELETE | \`/notes/:id\` | \u2713 | Delete a note |
5898
+
5899
+ Send \`Authorization: Bearer <token>\` for the \u2713 routes (you get the token from register/login).
5900
+
5901
+ ## Layout
5902
+
5903
+ \`\`\`
5904
+ src/
5905
+ \u251C\u2500 index.ts boot: load env \u2192 connect Mongo \u2192 listen
5906
+ \u251C\u2500 load-env.ts loads the root .env (imported first)
5907
+ \u251C\u2500 env.ts typed, validated env (@lacspace/env)
5908
+ \u251C\u2500 app.ts the Express app (CORS, routes, error handler)
5909
+ \u251C\u2500 db.ts Mongoose connection
5910
+ \u251C\u2500 cache.ts Redis, or an in-memory fallback (@lacspace/cache)
5911
+ \u251C\u2500 http.ts HttpError + asyncHandler
5912
+ \u251C\u2500 validation.ts request schemas (@lacspace/validate)
5913
+ \u251C\u2500 middleware/ auth (@lacspace/jwt) + error handling
5914
+ \u251C\u2500 models/ Mongoose models (User, Note)
5915
+ \u2514\u2500 routes/ auth + notes (the example CRUD resource)
5916
+ \`\`\`
5917
+ `;
5918
+ var backendLoadEnv = () => `import { config } from "dotenv";
5919
+ import { resolve } from "node:path";
5920
+
5921
+ // how this works: the whole monorepo shares ONE .env at the repo root. When this
5922
+ // API runs via \`npm run dev\` its working directory is backend/, so the root .env
5923
+ // is one level up. This module is imported FIRST (before env.ts) so the variables
5924
+ // exist by the time anything reads them.
5925
+ config({ path: resolve(process.cwd(), "../.env") });
5926
+ config(); // also load backend/.env if you keep one (root values already set win)
5927
+ `;
5928
+ var backendEnv = (ctx) => `import { createEnv, str, port, oneOf } from "@lacspace/env";
5929
+
5930
+ // how this works: fail-fast, TYPED environment variables (@lacspace/env). Bad or
5931
+ // missing values throw at boot with a clear message. Sensible dev defaults mean it
5932
+ // runs with zero config against a local MongoDB; set real values in the root .env.
5933
+ export const env = createEnv({
5934
+ NODE_ENV: oneOf(["development", "production", "test"], { default: "development" }),
5935
+ PORT: port({ default: 4000 }),
5936
+ MONGODB_URI: str({ default: "mongodb://localhost:27017/${ctx.name}" }),
5937
+ JWT_SECRET: str({ default: "dev-secret-change-me" }),
5938
+ REDIS_URL: str({ optional: true }),
5939
+ CORS_ORIGIN: str({ default: "http://localhost:3000" }),
5940
+ });
5941
+
5942
+ if (env.NODE_ENV === "production" && env.JWT_SECRET === "dev-secret-change-me") {
5943
+ throw new Error("Set a strong JWT_SECRET in production (see .env.example).");
5944
+ }
5945
+ `;
5946
+ var backendIndex = (ctx) => `import "./load-env.js"; // MUST be first: fills process.env before anything reads it
5947
+ import { env } from "./env.js";
5948
+ import { connectDb } from "./db.js";
5949
+ import { createApp } from "./app.js";
5950
+
5951
+ // how this works: connect to MongoDB, build the Express app, then listen.
5952
+ async function main(): Promise<void> {
5953
+ await connectDb(env.MONGODB_URI);
5954
+ const app = createApp();
5955
+ app.listen(env.PORT, () => {
5956
+ console.log(\`\u{1F680} ${ctx.name} API ready on http://localhost:\${env.PORT}\`);
5957
+ });
5958
+ }
5959
+
5960
+ main().catch((err) => {
5961
+ console.error("Failed to start the API:", err);
5962
+ process.exit(1);
5963
+ });
5964
+ `;
5965
+ var backendDb = () => `import mongoose from "mongoose";
5966
+
5967
+ // how this works: opens the Mongoose connection. Call once at boot.
5968
+ export async function connectDb(uri: string): Promise<void> {
5969
+ mongoose.set("strictQuery", true);
5970
+ await mongoose.connect(uri);
5971
+ console.log("\u2714 MongoDB connected");
5972
+ }
5973
+ `;
5974
+ var backendCache = () => `import { createCache } from "@lacspace/cache";
5975
+ import { Redis } from "ioredis";
5976
+ import { env } from "./env.js";
5977
+
5978
+ // how this works: one cache interface, TWO backends. If REDIS_URL is set we use
5979
+ // Redis; otherwise we fall back to @lacspace/cache (zero-dep, in-memory) so the app
5980
+ // runs with NO Redis at all. The rest of the code doesn't care which is active.
5981
+ export interface Cache {
5982
+ get(key: string): Promise<string | null>;
5983
+ set(key: string, value: string, ttlSeconds?: number): Promise<void>;
5984
+ del(key: string): Promise<void>;
5985
+ readonly kind: "redis" | "memory";
5986
+ }
5987
+
5988
+ function redisCache(url: string): Cache {
5989
+ const client = new Redis(url, { maxRetriesPerRequest: 2 });
5990
+ client.on("error", (e: Error) => console.warn("\u26A0 Redis error:", e.message));
5991
+ return {
5992
+ kind: "redis",
5993
+ async get(key) { return client.get(key); },
5994
+ async set(key, value, ttl) { if (ttl) await client.set(key, value, "EX", ttl); else await client.set(key, value); },
5995
+ async del(key) { await client.del(key); },
5996
+ };
5997
+ }
5998
+
5999
+ function memoryCache(): Cache {
6000
+ const mem = createCache<string>({ max: 5000 });
6001
+ return {
6002
+ kind: "memory",
6003
+ async get(key) { return mem.get(key) ?? null; },
6004
+ async set(key, value, ttl) { mem.set(key, value, ttl ? ttl * 1000 : undefined); },
6005
+ async del(key) { mem.delete(key); },
6006
+ };
6007
+ }
6008
+
6009
+ export const cache: Cache = env.REDIS_URL ? redisCache(env.REDIS_URL) : memoryCache();
6010
+ console.log(\`\u2714 Cache: \${cache.kind}\${cache.kind === "memory" ? " (no REDIS_URL \u2014 in-memory fallback)" : ""}\`);
6011
+ `;
6012
+ var backendHttp = () => `import type { Request, Response, NextFunction, RequestHandler } from "express";
6013
+
6014
+ /** A simple HTTP error with a status code \u2014 throw it from any handler. */
6015
+ export class HttpError extends Error {
6016
+ status: number;
6017
+ constructor(status: number, message: string) {
6018
+ super(message);
6019
+ this.status = status;
6020
+ this.name = "HttpError";
6021
+ }
6022
+ }
6023
+
6024
+ // how this works: Express v4 doesn't catch rejected Promises from async handlers,
6025
+ // so wrap them \u2014 any thrown error is forwarded to the error middleware.
6026
+ export const asyncHandler =
6027
+ (fn: (req: Request, res: Response, next: NextFunction) => Promise<unknown>): RequestHandler =>
6028
+ (req, res, next) => { fn(req, res, next).catch(next); };
6029
+ `;
6030
+ var backendExpressTypes = () => `// Adds req.user (populated by requireAuth) to Express's Request type.
6031
+ import "express";
6032
+
6033
+ declare global {
6034
+ namespace Express {
6035
+ interface Request {
6036
+ user?: { sub: string; email: string; iat?: number; exp?: number; iss?: string };
6037
+ }
6038
+ }
6039
+ }
6040
+
6041
+ export {};
6042
+ `;
6043
+ var backendErrorMw = () => `import type { Request, Response, NextFunction } from "express";
6044
+ import { ValidationError } from "@lacspace/validate";
6045
+ import { HttpError } from "../http.js";
6046
+
6047
+ // how this works: ONE place that turns thrown errors into clean JSON responses.
6048
+ // Register it LAST (after all routes). Express identifies it by its 4 arguments.
6049
+ export function errorHandler(err: unknown, _req: Request, res: Response, _next: NextFunction): void {
6050
+ if (err instanceof ValidationError) {
6051
+ res.status(400).json({ error: "Validation failed", fields: err.flatten() });
6052
+ return;
6053
+ }
6054
+ if (err instanceof HttpError) {
6055
+ res.status(err.status).json({ error: err.message });
6056
+ return;
6057
+ }
6058
+ console.error(err);
6059
+ res.status(500).json({ error: "Internal server error" });
6060
+ }
6061
+ `;
6062
+ var backendAuthMw = (ctx) => `import type { RequestHandler } from "express";
6063
+ import { expressJwt } from "@lacspace/jwt";
6064
+ import { env } from "../env.js";
6065
+
6066
+ // how this works: verifies the "Authorization: Bearer <token>" header with
6067
+ // @lacspace/jwt and attaches the decoded payload to req.user (see express.d.ts).
6068
+ // Missing/invalid token \u2192 it responds 401 automatically. Put it on any route you
6069
+ // want protected: router.get("/secret", requireAuth, handler)
6070
+ // (@lacspace/jwt's middleware is framework-agnostic, so we cast it to Express's
6071
+ // RequestHandler \u2014 it's a standard (req, res, next) function underneath.)
6072
+ export const requireAuth = expressJwt(env.JWT_SECRET, { issuer: "${ctx.name}-api" }) as unknown as RequestHandler;
6073
+ `;
6074
+ var backendUserModel = () => `import mongoose from "mongoose";
6075
+ import { uuidv7 } from "@lacspace/id";
6076
+
6077
+ // how this works: the User fields. _id is a time-sortable UUID (@lacspace/id)
6078
+ // instead of an ObjectId, so ids are readable and orderable. We store only a
6079
+ // password HASH, never the raw password. (We DON'T extend mongoose.Document \u2014 that
6080
+ // would force _id to be an ObjectId; a plain interface lets _id be our string.)
6081
+ export interface UserDoc {
6082
+ _id: string;
6083
+ email: string;
6084
+ name: string;
6085
+ passwordHash: string;
6086
+ createdAt: Date;
6087
+ updatedAt: Date;
6088
+ }
6089
+
6090
+ const userSchema = new mongoose.Schema<UserDoc>(
6091
+ {
6092
+ _id: { type: String, default: () => uuidv7() },
6093
+ email: { type: String, required: true, unique: true, lowercase: true, trim: true },
6094
+ name: { type: String, required: true, trim: true },
6095
+ passwordHash: { type: String, required: true },
6096
+ },
6097
+ { timestamps: true },
6098
+ );
6099
+
6100
+ export const User =
6101
+ (mongoose.models.User as mongoose.Model<UserDoc>) ?? mongoose.model<UserDoc>("User", userSchema);
6102
+ `;
6103
+ var backendNoteModel = () => `import mongoose from "mongoose";
6104
+ import { uuidv7 } from "@lacspace/id";
6105
+
6106
+ // how this works: the example CRUD resource. Rename "Note" to your real domain
6107
+ // object (Task, Post, Product\u2026) and add fields \u2014 the routes follow the same shape.
6108
+ // (Plain interface, not mongoose.Document, so _id can be our string UUID.)
6109
+ export interface NoteDoc {
6110
+ _id: string;
6111
+ title: string;
6112
+ body: string;
6113
+ userId: string;
6114
+ createdAt: Date;
6115
+ updatedAt: Date;
6116
+ }
6117
+
6118
+ const noteSchema = new mongoose.Schema<NoteDoc>(
6119
+ {
6120
+ _id: { type: String, default: () => uuidv7() },
6121
+ title: { type: String, required: true, trim: true },
6122
+ body: { type: String, default: "" },
6123
+ userId: { type: String, required: true, index: true },
6124
+ },
6125
+ { timestamps: true },
6126
+ );
6127
+
6128
+ export const Note =
6129
+ (mongoose.models.Note as mongoose.Model<NoteDoc>) ?? mongoose.model<NoteDoc>("Note", noteSchema);
6130
+ `;
6131
+ var backendValidation = () => `import { v, type Infer } from "@lacspace/validate";
6132
+
6133
+ // how this works: Zod-like schemas (@lacspace/validate). .parse(body) throws a
6134
+ // ValidationError on bad input, which the error middleware turns into a clean 400
6135
+ // with per-field messages.
6136
+ export const RegisterInput = v.object({
6137
+ name: v.string().min(2).max(80),
6138
+ email: v.string().email(),
6139
+ password: v.string().min(8).max(200),
6140
+ });
6141
+
6142
+ export const LoginInput = v.object({
6143
+ email: v.string().email(),
6144
+ password: v.string().min(1),
6145
+ });
6146
+
6147
+ export const NoteInput = v.object({
6148
+ title: v.string().min(1).max(200),
6149
+ body: v.string().max(10_000).default(""),
6150
+ });
6151
+
6152
+ export type RegisterBody = Infer<typeof RegisterInput>;
6153
+ export type LoginBody = Infer<typeof LoginInput>;
6154
+ export type NoteBody = Infer<typeof NoteInput>;
6155
+ `;
6156
+ var backendAuthRoutes = (ctx) => `import express from "express";
6157
+ import { hash, verify as verifyPassword } from "@lacspace/password";
6158
+ import { sign } from "@lacspace/jwt";
6159
+ import { asyncHandler, HttpError } from "../http.js";
6160
+ import { requireAuth } from "../middleware/auth.js";
6161
+ import { User, type UserDoc } from "../models/user.js";
6162
+ import { env } from "../env.js";
6163
+ import { RegisterInput, LoginInput } from "../validation.js";
6164
+ import type { AuthResponse, User as UserDTO } from "${scope(ctx)}/types";
6165
+
6166
+ const ISSUER = "${ctx.name}-api";
6167
+ const WEEK = 60 * 60 * 24 * 7; // token lifetime, in seconds
6168
+ const router = express.Router();
6169
+
6170
+ function toDTO(u: UserDoc): UserDTO {
6171
+ return { id: String(u._id), email: u.email, name: u.name, createdAt: u.createdAt.toISOString() };
6172
+ }
6173
+ function tokenFor(u: UserDoc): Promise<string> {
6174
+ return sign({ sub: String(u._id), email: u.email }, env.JWT_SECRET, { expiresIn: WEEK, issuer: ISSUER });
6175
+ }
6176
+
6177
+ // POST /auth/register \u2014 create an account, return a JWT + the user.
6178
+ router.post("/register", asyncHandler(async (req, res) => {
6179
+ const { name, email, password } = RegisterInput.parse(req.body);
6180
+ if (await User.findOne({ email })) throw new HttpError(409, "That email is already registered");
6181
+ const passwordHash = await hash(password); // PBKDF2 via @lacspace/password
6182
+ const user = await User.create({ name, email, passwordHash });
6183
+ const out: AuthResponse = { token: await tokenFor(user), user: toDTO(user) };
6184
+ res.status(201).json(out);
6185
+ }));
6186
+
6187
+ // POST /auth/login \u2014 verify credentials, return a JWT + the user.
6188
+ router.post("/login", asyncHandler(async (req, res) => {
6189
+ const { email, password } = LoginInput.parse(req.body);
6190
+ const user = await User.findOne({ email });
6191
+ if (!user || !(await verifyPassword(password, user.passwordHash))) {
6192
+ throw new HttpError(401, "Invalid email or password");
6193
+ }
6194
+ const out: AuthResponse = { token: await tokenFor(user), user: toDTO(user) };
6195
+ res.json(out);
6196
+ }));
6197
+
6198
+ // GET /auth/me \u2014 the current user (requires a valid token).
6199
+ router.get("/me", requireAuth, asyncHandler(async (req, res) => {
6200
+ const user = await User.findById(req.user!.sub);
6201
+ if (!user) throw new HttpError(404, "User not found");
6202
+ res.json(toDTO(user));
6203
+ }));
6204
+
6205
+ export default router;
6206
+ `;
6207
+ var backendNoteRoutes = (ctx) => `import express from "express";
6208
+ import { asyncHandler, HttpError } from "../http.js";
6209
+ import { requireAuth } from "../middleware/auth.js";
6210
+ import { Note, type NoteDoc } from "../models/note.js";
6211
+ import { cache } from "../cache.js";
6212
+ import { NoteInput } from "../validation.js";
6213
+ import type { Note as NoteDTO } from "${scope(ctx)}/types";
6214
+
6215
+ const router = express.Router();
6216
+ router.use(requireAuth); // every /notes route requires a valid token
6217
+
6218
+ function toDTO(n: NoteDoc): NoteDTO {
6219
+ return {
6220
+ id: String(n._id),
6221
+ title: n.title,
6222
+ body: n.body,
6223
+ userId: String(n.userId),
6224
+ createdAt: n.createdAt.toISOString(),
6225
+ updatedAt: n.updatedAt.toISOString(),
6226
+ };
6227
+ }
6228
+ const listKey = (userId: string): string => \`notes:\${userId}\`;
6229
+
6230
+ // GET /notes \u2014 this user's notes. Cached for 30s (Redis or in-memory); every write
6231
+ // below busts the cache, so reads are fast but never stale after a change.
6232
+ router.get("/", asyncHandler(async (req, res) => {
6233
+ const userId = req.user!.sub;
6234
+ const cached = await cache.get(listKey(userId));
6235
+ if (cached) { res.json(JSON.parse(cached) as NoteDTO[]); return; }
6236
+ const notes = await Note.find({ userId }).sort({ createdAt: -1 });
6237
+ const dto = notes.map(toDTO);
6238
+ await cache.set(listKey(userId), JSON.stringify(dto), 30);
6239
+ res.json(dto);
6240
+ }));
6241
+
6242
+ // POST /notes \u2014 create a note.
6243
+ router.post("/", asyncHandler(async (req, res) => {
6244
+ const userId = req.user!.sub;
6245
+ const { title, body } = NoteInput.parse(req.body);
6246
+ const note = await Note.create({ title, body, userId });
6247
+ await cache.del(listKey(userId));
6248
+ res.status(201).json(toDTO(note));
6249
+ }));
6250
+
6251
+ // GET /notes/:id \u2014 one note (only if it's yours).
6252
+ router.get("/:id", asyncHandler(async (req, res) => {
6253
+ const note = await Note.findOne({ _id: req.params.id, userId: req.user!.sub });
6254
+ if (!note) throw new HttpError(404, "Note not found");
6255
+ res.json(toDTO(note));
6256
+ }));
6257
+
6258
+ // PATCH /notes/:id \u2014 update a note.
6259
+ router.patch("/:id", asyncHandler(async (req, res) => {
6260
+ const patch = NoteInput.partial().parse(req.body);
6261
+ const note = await Note.findOneAndUpdate({ _id: req.params.id, userId: req.user!.sub }, { $set: patch }, { new: true });
6262
+ if (!note) throw new HttpError(404, "Note not found");
6263
+ await cache.del(listKey(req.user!.sub));
6264
+ res.json(toDTO(note));
6265
+ }));
6266
+
6267
+ // DELETE /notes/:id \u2014 delete a note.
6268
+ router.delete("/:id", asyncHandler(async (req, res) => {
6269
+ const note = await Note.findOneAndDelete({ _id: req.params.id, userId: req.user!.sub });
6270
+ if (!note) throw new HttpError(404, "Note not found");
6271
+ await cache.del(listKey(req.user!.sub));
6272
+ res.status(204).end();
6273
+ }));
6274
+
6275
+ export default router;
6276
+ `;
6277
+ var backendApp = (ctx) => `import express from "express";
6278
+ import type { RequestHandler } from "express";
6279
+ import cors from "cors";
6280
+ import { rateLimit, expressRateLimit } from "@lacspace/rate-limit";
6281
+ import { env } from "./env.js";
6282
+ import { errorHandler } from "./middleware/error.js";
6283
+ import authRoutes from "./routes/auth.js";
6284
+ import noteRoutes from "./routes/notes.js";
6285
+
6286
+ // how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
6287
+ // a health check, the auth + notes routers, then the error handler LAST.
6288
+ export function createApp(): express.Express {
6289
+ const app = express();
6290
+
6291
+ app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
6292
+ app.use(express.json());
6293
+
6294
+ // Brute-force protection on auth: 20 requests / minute / IP (@lacspace/rate-limit).
6295
+ const authLimiter = expressRateLimit(rateLimit({ limit: 20, windowMs: 60_000 })) as unknown as RequestHandler;
6296
+
6297
+ app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
6298
+ app.use("/auth", authLimiter, authRoutes);
6299
+ app.use("/notes", noteRoutes);
6300
+
6301
+ app.use(errorHandler);
6302
+ return app;
6303
+ }
6304
+ `;
6305
+ function backendFiles(ctx) {
6306
+ return {
6307
+ "backend/package.json": backendPkgJson(ctx),
6308
+ "backend/tsconfig.json": backendTsconfig(ctx),
6309
+ "backend/.gitignore": backendGitignore(),
6310
+ "backend/README.md": backendReadme(ctx),
6311
+ "backend/src/index.ts": backendIndex(ctx),
6312
+ "backend/src/load-env.ts": backendLoadEnv(),
6313
+ "backend/src/env.ts": backendEnv(ctx),
6314
+ "backend/src/db.ts": backendDb(),
6315
+ "backend/src/cache.ts": backendCache(),
6316
+ "backend/src/app.ts": backendApp(ctx),
6317
+ "backend/src/http.ts": backendHttp(),
6318
+ "backend/src/express.d.ts": backendExpressTypes(),
6319
+ "backend/src/validation.ts": backendValidation(),
6320
+ "backend/src/middleware/auth.ts": backendAuthMw(ctx),
6321
+ "backend/src/middleware/error.ts": backendErrorMw(),
6322
+ "backend/src/models/user.ts": backendUserModel(),
6323
+ "backend/src/models/note.ts": backendNoteModel(),
6324
+ "backend/src/routes/auth.ts": backendAuthRoutes(ctx),
6325
+ "backend/src/routes/notes.ts": backendNoteRoutes(ctx)
6326
+ };
6327
+ }
5094
6328
  var splitList = (s) => s.split(",").map((x) => x.trim()).filter(Boolean);
5095
6329
  function parseArgs(list) {
5096
6330
  const a = { features: [], yes: false, install: true, git: true, pm: "npm", help: false };
@@ -5104,7 +6338,15 @@ function parseArgs(list) {
5104
6338
  else if (arg === "--pm") a.pm = next();
5105
6339
  else if (arg === "--theme" || arg === "--accent") a.theme = next();
5106
6340
  else if (arg === "--with" || arg === "--features") a.features.push(...splitList(next()));
5107
- else if (arg === "-h" || arg === "--help") a.help = true;
6341
+ else if (arg === "--fullstack" || arg === "--full-stack" || arg === "--dynamic") a.mode = "dynamic";
6342
+ else if (arg === "--static") a.mode = "static";
6343
+ else if (arg === "--mode") {
6344
+ const m = next().toLowerCase();
6345
+ a.mode = m === "dynamic" ? "dynamic" : "static";
6346
+ } else if (arg.startsWith("--mode=")) {
6347
+ const m = arg.slice(7).toLowerCase();
6348
+ a.mode = m === "dynamic" ? "dynamic" : "static";
6349
+ } else if (arg === "-h" || arg === "--help") a.help = true;
5108
6350
  else if (arg.startsWith("--template=")) a.template = arg.slice(11);
5109
6351
  else if (arg.startsWith("--theme=")) a.theme = arg.slice(8);
5110
6352
  else if (arg.startsWith("--accent=")) a.theme = arg.slice(9);
@@ -5204,6 +6446,9 @@ ${TEMPLATES.map((t) => ` ${t.key.padEnd(10)} ${t.description}`).join("\n")}
5204
6446
 
5205
6447
  ${c("bold", "Options")}
5206
6448
  -t, --template <key> Template (${TEMPLATES.map((t) => t.key).join(" | ")})
6449
+ --fullstack Full-stack monorepo: Next.js frontend + Node/Express/
6450
+ MongoDB/Redis backend (JWT auth + CRUD) + shared types
6451
+ ${c("dim", "(alias --dynamic; default is --static, a single Next.js app)")}
5207
6452
  --with <a,b> Feature add-ons, comma-separated (alias --features)
5208
6453
  --theme <name|hex> Accent: a preset, a "#hex", or "from,to" (e.g. --theme lacspace)
5209
6454
  --pm <npm|pnpm|yarn|bun> Package manager (default npm)
@@ -5595,6 +6840,7 @@ ${c("bold", c("magenta", "\u25C6 create-lacspace-app"))} ${c("dim", "\u2014 a go
5595
6840
  `);
5596
6841
  let name = args.name;
5597
6842
  let templateKey = args.template;
6843
+ let mode = args.mode ?? "static";
5598
6844
  const featureKeys = [...args.features];
5599
6845
  if (!args.yes && stdin.isTTY) {
5600
6846
  const rl = createInterface({ input: stdin, output: stdout });
@@ -5611,6 +6857,18 @@ ${c("green", "?")} Template ${c("dim", "(1)")}: `)).trim() || "1";
5611
6857
  const idx = /^\d+$/.test(ans) ? parseInt(ans, 10) - 1 : TEMPLATES.findIndex((t) => t.key === ans);
5612
6858
  templateKey = TEMPLATES[idx]?.key ?? "personal";
5613
6859
  }
6860
+ if (args.mode === void 0) {
6861
+ stdout.write(`
6862
+ What kind of app?
6863
+ `);
6864
+ stdout.write(` ${c("cyan", "1")}. ${c("bold", "Static / frontend only")} ${c("dim", "\u2014 a single Next.js app (SEO site, marketing, blog, docs). Fast, deploy anywhere.")}
6865
+ `);
6866
+ stdout.write(` ${c("cyan", "2")}. ${c("bold", "Dynamic / full-stack")} ${c("dim", "\u2014 frontend + a Node\xB7Express\xB7MongoDB\xB7Redis API with working auth & CRUD, wired together.")}
6867
+ `);
6868
+ const ans = (await rl.question(`
6869
+ ${c("green", "?")} Type ${c("dim", "(1)")}: `)).trim() || "1";
6870
+ mode = ans === "2" || ans.toLowerCase().startsWith("dyn") || ans.toLowerCase().startsWith("full") ? "dynamic" : "static";
6871
+ }
5614
6872
  if (featureKeys.length === 0) {
5615
6873
  stdout.write(`
5616
6874
  Add feature add-ons? ${c("dim", "(free & keyless \u2014 local AI by default)")}
@@ -5652,14 +6910,15 @@ ${c("green", "?")} Add features? ${c("dim", "(comma-separated numbers, or Enter
5652
6910
  return;
5653
6911
  }
5654
6912
  const features = normalizeFeatures(featureKeys);
5655
- const files = buildFiles({ name: projectName, template, features });
6913
+ const files = buildFiles({ name: projectName, template, features, mode });
5656
6914
  for (const [rel, content] of Object.entries(files)) {
5657
6915
  const full = join(dir, rel);
5658
6916
  mkdirSync(dirname(full), { recursive: true });
5659
6917
  writeFileSync(full, content);
5660
6918
  }
6919
+ const shape = mode === "dynamic" ? " \xB7 full-stack" : "";
5661
6920
  stdout.write(`
5662
- ${c("green", "\u2714")} Created ${c("bold", name)} ${c("dim", `(${template.label})`)}
6921
+ ${c("green", "\u2714")} Created ${c("bold", name)} ${c("dim", `(${template.label}${shape})`)}
5663
6922
  `);
5664
6923
  if (features.length) stdout.write(` ${c("green", "\u2714")} Feature add-ons: ${features.map((f) => c("cyan", f.key)).join(", ")}
5665
6924
  `);
@@ -5689,11 +6948,25 @@ ${c("bold", "Done! Next steps")}
5689
6948
  `);
5690
6949
  if (!args.install) stdout.write(` ${c("cyan", `${args.pm} install`)}
5691
6950
  `);
5692
- stdout.write(` ${c("cyan", run)}
6951
+ if (mode === "dynamic") {
6952
+ stdout.write(` ${c("cyan", "cp .env.example .env")} ${c("dim", "\u2014 then, optionally:")} ${c("cyan", "docker compose up -d")} ${c("dim", "(Mongo + Redis)")}
5693
6953
  `);
5694
- stdout.write(`
6954
+ stdout.write(` ${c("cyan", run)} ${c("dim", "\u2014 runs the API (:4000) and the frontend (:3000) together")}
6955
+ `);
6956
+ stdout.write(`
6957
+ ${c("dim", "backend \u2192")} ${c("cyan", "backend/src/")} ${c("dim", "(Express \xB7 MongoDB \xB7 Redis \xB7 JWT auth \xB7 example CRUD)")}
6958
+ `);
6959
+ stdout.write(` ${c("dim", "frontend \u2192")} ${c("cyan", "frontend/app/")} ${c("dim", "\xB7 shared API types \u2192")} ${c("cyan", "types/src/")}
6960
+ `);
6961
+ stdout.write(` ${c("dim", "No Docker? Point")} ${c("cyan", "MONGODB_URI")} ${c("dim", "at any MongoDB (e.g. free Atlas); Redis is optional.")}
6962
+ `);
6963
+ } else {
6964
+ stdout.write(` ${c("cyan", run)}
6965
+ `);
6966
+ stdout.write(`
5695
6967
  Edit ${c("cyan", "lib/site.ts")} (your SEO config) and ${c("cyan", "app/page.tsx")}.
5696
6968
  `);
6969
+ }
5697
6970
  if (features.length) {
5698
6971
  const hasAi = features.some((f) => f.key === "ai-chat" || f.key === "rag");
5699
6972
  stdout.write(`