create-lacspace-app 2.11.0 → 2.13.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/README.md +4 -0
- package/dist/index.js +136 -3
- package/dist/lib.cjs +136 -3
- package/dist/lib.js +136 -3
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,10 @@ npx create-lacspace-app my-app --template saas --fullstack
|
|
|
20
20
|
|
|
21
21
|
You choose the *kind* of site you're building. It writes a **real Next.js 15 + React 19 + Tailwind v4 app** — not a hello-world, but a genuinely **polished, modern site**: a fluid `clamp()` type scale, tight display headings, a refined light **and** dark palette, glass chrome, soft layered shadows, a smooth logo marquee, animated counters, scroll reveals and a shimmering primary CTA — every page filled in, an SEO stack wired end-to-end, and a **26-component UI kit** you can drop in anywhere.
|
|
22
22
|
|
|
23
|
+
> **New in v2.12 — real-time & privacy (16 add-ons).** Two more keyless, no-vendor add-ons:
|
|
24
|
+
> - **`realtime`** — live server→browser updates over **Server-Sent Events** (`@lacspace/sse`): a channel hub + a `/live` stream + a React `useSSE` feed. No WebSocket server. Full-stack.
|
|
25
|
+
> - **`consent`** — **GDPR-friendly cookie consent** (`@lacspace/consent`): a drop-in `<ConsentBanner/>`, per-category choices, cookie persistence and `whenConsent()` script-gating. Frontend.
|
|
26
|
+
>
|
|
23
27
|
> **New in v2.11 — the Web Engagement Kit (14 add-ons).** Three keyless, no-vendor add-ons on brand-new `@lacspace` packages:
|
|
24
28
|
> - **`push`** — real **browser push notifications** (`@lacspace/web-push`): keyless VAPID, no Firebase/FCM. Generates a service worker, a subscribe button, and a backend that stores subscriptions and sends. Full-stack.
|
|
25
29
|
> - **`captcha`** — a **privacy-friendly proof-of-work CAPTCHA** (`@lacspace/captcha`): no Google/Cloudflare, no keys, no tracking. A drop-in widget + a backend verify route. Full-stack.
|
package/dist/index.js
CHANGED
|
@@ -2402,6 +2402,38 @@ var FEATURES = [
|
|
|
2402
2402
|
"Push works on https (or localhost); the generated public/sw.js receives the notifications."
|
|
2403
2403
|
],
|
|
2404
2404
|
learn: "https://developer.lacspace.com/packages/web-push"
|
|
2405
|
+
},
|
|
2406
|
+
{
|
|
2407
|
+
key: "consent",
|
|
2408
|
+
label: "Cookie consent",
|
|
2409
|
+
description: "GDPR-friendly cookie consent (@lacspace/consent) \u2014 a drop-in <ConsentBanner/>, per-category choices, cookie persistence and whenConsent() script-gating. Frontend, any template.",
|
|
2410
|
+
deps: { "@lacspace/consent": "^1.0.0" },
|
|
2411
|
+
files: () => ({ "components/consent.tsx": consentComponent() }),
|
|
2412
|
+
nextSteps: [
|
|
2413
|
+
"Render <ConsentBanner/> once in app/layout.tsx: import { ConsentBanner } from '@/components/consent'.",
|
|
2414
|
+
"Gate analytics/marketing scripts: whenConsent(consent, 'analytics', () => { /* load tag */ }).",
|
|
2415
|
+
"The choice is stored in a cookie \u2014 gate server-side too with parseConsentCookie()."
|
|
2416
|
+
],
|
|
2417
|
+
learn: "https://developer.lacspace.com/packages/consent"
|
|
2418
|
+
},
|
|
2419
|
+
{
|
|
2420
|
+
key: "realtime",
|
|
2421
|
+
label: "Real-time (SSE)",
|
|
2422
|
+
description: "Live server-to-browser updates over Server-Sent Events (@lacspace/sse) \u2014 a channel hub + a /live stream + a React useSSE feed. No WebSocket server. Full-stack.",
|
|
2423
|
+
requiresBackend: true,
|
|
2424
|
+
deps: { "@lacspace/sse": "^1.0.0" },
|
|
2425
|
+
files: () => ({ "app/live/page.tsx": realtimeLivePage() }),
|
|
2426
|
+
backend: () => ({
|
|
2427
|
+
deps: { "@lacspace/sse": "^1.0.0" },
|
|
2428
|
+
files: { "src/routes/live.ts": realtimeRoutesBackend() },
|
|
2429
|
+
routes: [{ path: "/live", handler: "liveRoutes", auth: false, importLine: 'import liveRoutes from "./live.js";' }]
|
|
2430
|
+
}),
|
|
2431
|
+
nextSteps: [
|
|
2432
|
+
"Open http://localhost:3000/live in two tabs; click 'Broadcast' in one \u2014 the other updates instantly.",
|
|
2433
|
+
"Push from anywhere on the server: hub.broadcast({ event: 'message', data }) in src/routes/live.ts.",
|
|
2434
|
+
"SSE auto-reconnects and needs no WebSocket server \u2014 great for notifications and live feeds."
|
|
2435
|
+
],
|
|
2436
|
+
learn: "https://developer.lacspace.com/packages/sse"
|
|
2405
2437
|
}
|
|
2406
2438
|
];
|
|
2407
2439
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6920,6 +6952,69 @@ router.post("/send", asyncHandler(async (req, res) => {
|
|
|
6920
6952
|
res.json({ sent: results.filter((r) => r.result && !r.result.expired).length, removed: expired.length });
|
|
6921
6953
|
}));
|
|
6922
6954
|
|
|
6955
|
+
export default router;
|
|
6956
|
+
`;
|
|
6957
|
+
var consentComponent = () => `"use client";
|
|
6958
|
+
// how this works: re-export the consent UI + API from one component. Render
|
|
6959
|
+
// <ConsentBanner/> once in app/layout.tsx; gate scripts with whenConsent().
|
|
6960
|
+
export { ConsentBanner, useConsent } from "@lacspace/consent/react";
|
|
6961
|
+
export { consent, whenConsent } from "@lacspace/consent";
|
|
6962
|
+
`;
|
|
6963
|
+
var realtimeLivePage = () => `"use client";
|
|
6964
|
+
import { useState } from "react";
|
|
6965
|
+
import { useSSE } from "@lacspace/sse/react";
|
|
6966
|
+
|
|
6967
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6968
|
+
|
|
6969
|
+
export default function LivePage() {
|
|
6970
|
+
const [log, setLog] = useState<string[]>([]);
|
|
6971
|
+
const { status } = useSSE(API + "/live", {
|
|
6972
|
+
onEvent: { message: (m) => setLog((prev) => [String((m as { text?: string }).text ?? ""), ...prev].slice(0, 50)) },
|
|
6973
|
+
});
|
|
6974
|
+
|
|
6975
|
+
async function broadcast() {
|
|
6976
|
+
await fetch(API + "/live/broadcast", {
|
|
6977
|
+
method: "POST",
|
|
6978
|
+
headers: { "Content-Type": "application/json" },
|
|
6979
|
+
body: JSON.stringify({ text: "Hello at " + new Date().toLocaleTimeString() }),
|
|
6980
|
+
});
|
|
6981
|
+
}
|
|
6982
|
+
|
|
6983
|
+
return (
|
|
6984
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6985
|
+
<h1 className="text-2xl font-bold">Live feed</h1>
|
|
6986
|
+
<p className="mt-1 text-muted">Server-Sent Events \u2014 status: {status}.</p>
|
|
6987
|
+
<button className="mt-4 rounded-xl border border-hairline px-4 py-2" onClick={broadcast}>Broadcast a message</button>
|
|
6988
|
+
<ul className="mt-6 space-y-2">
|
|
6989
|
+
{log.map((line, i) => (<li key={i} className="rounded-xl border border-hairline p-3">{line}</li>))}
|
|
6990
|
+
</ul>
|
|
6991
|
+
</main>
|
|
6992
|
+
);
|
|
6993
|
+
}
|
|
6994
|
+
`;
|
|
6995
|
+
var realtimeRoutesBackend = () => `import express from "express";
|
|
6996
|
+
import { SSEHub, sseHandler } from "@lacspace/sse";
|
|
6997
|
+
import { asyncHandler } from "../http.js";
|
|
6998
|
+
|
|
6999
|
+
// how this works: one shared hub holds every open SSE connection. GET /live opens
|
|
7000
|
+
// a stream; POST /live/broadcast pushes a "message" event to everyone connected.
|
|
7001
|
+
// Push from anywhere in your app by importing this hub and calling hub.broadcast().
|
|
7002
|
+
const router = express.Router();
|
|
7003
|
+
const hub = new SSEHub();
|
|
7004
|
+
hub.startHeartbeat();
|
|
7005
|
+
|
|
7006
|
+
router.get("/", (_req, res) => {
|
|
7007
|
+
const client = sseHandler(res, { onClose: () => hub.remove(client) });
|
|
7008
|
+
hub.add(client);
|
|
7009
|
+
client.send({ event: "message", data: { text: "Connected to the live feed." } });
|
|
7010
|
+
});
|
|
7011
|
+
|
|
7012
|
+
router.post("/broadcast", asyncHandler(async (req, res) => {
|
|
7013
|
+
const text = typeof req.body?.text === "string" ? req.body.text : "ping";
|
|
7014
|
+
const sent = hub.broadcast({ event: "message", data: { text } });
|
|
7015
|
+
res.json({ sent });
|
|
7016
|
+
}));
|
|
7017
|
+
|
|
6923
7018
|
export default router;
|
|
6924
7019
|
`;
|
|
6925
7020
|
var scope = (ctx) => `@${ctx.name}`;
|
|
@@ -7373,6 +7468,7 @@ var backendPkgJson = (ctx) => {
|
|
|
7373
7468
|
"@lacspace/id": "^1.1.0",
|
|
7374
7469
|
"@lacspace/rate-limit": "^1.2.0",
|
|
7375
7470
|
"@lacspace/cache": "^1.1.0",
|
|
7471
|
+
"@lacspace/logger": "^1.0.0",
|
|
7376
7472
|
...featureDeps,
|
|
7377
7473
|
[`${scope(ctx)}/types`]: "*"
|
|
7378
7474
|
},
|
|
@@ -7482,21 +7578,54 @@ var backendIndex = (ctx) => `import "./load-env.js"; // MUST be first: fills pro
|
|
|
7482
7578
|
import { env } from "./env.js";
|
|
7483
7579
|
import { connectDb } from "./db.js";
|
|
7484
7580
|
import { createApp } from "./app.js";
|
|
7581
|
+
import { log } from "./logger.js";
|
|
7485
7582
|
|
|
7486
7583
|
// how this works: connect to MongoDB, build the Express app, then listen.
|
|
7487
7584
|
async function main(): Promise<void> {
|
|
7488
7585
|
await connectDb(env.MONGODB_URI);
|
|
7489
7586
|
const app = createApp();
|
|
7490
7587
|
app.listen(env.PORT, () => {
|
|
7491
|
-
|
|
7588
|
+
log.info("${ctx.name} API ready", { url: \`http://localhost:\${env.PORT}\` });
|
|
7492
7589
|
});
|
|
7493
7590
|
}
|
|
7494
7591
|
|
|
7495
7592
|
main().catch((err) => {
|
|
7496
|
-
|
|
7593
|
+
log.fatal("Failed to start the API", { err });
|
|
7497
7594
|
process.exit(1);
|
|
7498
7595
|
});
|
|
7499
7596
|
`;
|
|
7597
|
+
var backendLogger = () => `import { createLogger, jsonConsole, prettyConsole } from "@lacspace/logger";
|
|
7598
|
+
import type { Request, Response, NextFunction } from "express";
|
|
7599
|
+
import { env } from "./env.js";
|
|
7600
|
+
|
|
7601
|
+
// how this works: one structured logger for the whole API (@lacspace/logger, zero-dep).
|
|
7602
|
+
// In production we emit JSON-per-line (clean for log aggregators); in dev we print a
|
|
7603
|
+
// readable, coloured line. Common secret fields are redacted before anything is logged.
|
|
7604
|
+
const isProd = env.NODE_ENV === "production";
|
|
7605
|
+
export const log = createLogger({
|
|
7606
|
+
level: isProd ? "info" : "debug",
|
|
7607
|
+
transports: [isProd ? jsonConsole() : prettyConsole({ colors: true })],
|
|
7608
|
+
redact: ["password", "token", "authorization", "*.password", "*.token"],
|
|
7609
|
+
});
|
|
7610
|
+
|
|
7611
|
+
// Request logger \u2014 one line per request with method, path, status and duration.
|
|
7612
|
+
// Each request gets a child logger with a short request id you can thread through.
|
|
7613
|
+
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
|
7614
|
+
const start = Date.now();
|
|
7615
|
+
const reqId = Math.random().toString(36).slice(2, 10);
|
|
7616
|
+
(req as Request & { log: typeof log }).log = log.child({ reqId });
|
|
7617
|
+
res.on("finish", () => {
|
|
7618
|
+
log.info("request", {
|
|
7619
|
+
reqId,
|
|
7620
|
+
method: req.method,
|
|
7621
|
+
path: req.originalUrl,
|
|
7622
|
+
status: res.statusCode,
|
|
7623
|
+
ms: Date.now() - start,
|
|
7624
|
+
});
|
|
7625
|
+
});
|
|
7626
|
+
next();
|
|
7627
|
+
}
|
|
7628
|
+
`;
|
|
7500
7629
|
var backendDb = () => `import mongoose from "mongoose";
|
|
7501
7630
|
|
|
7502
7631
|
// how this works: opens the Mongoose connection. Call once at boot.
|
|
@@ -7812,16 +7941,19 @@ export default router;
|
|
|
7812
7941
|
var backendApp = (ctx) => `import express from "express";
|
|
7813
7942
|
import cors from "cors";
|
|
7814
7943
|
import { env } from "./env.js";
|
|
7944
|
+
import { requestLogger } from "./logger.js";
|
|
7815
7945
|
import { errorHandler } from "./middleware/error.js";
|
|
7816
7946
|
import { registerRoutes } from "./routes/index.js";
|
|
7817
7947
|
|
|
7818
7948
|
// how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
|
|
7819
|
-
// a health check, all route groups
|
|
7949
|
+
// structured request logging (@lacspace/logger), a health check, all route groups
|
|
7950
|
+
// (see routes/index.ts), then the error handler LAST.
|
|
7820
7951
|
export function createApp(): express.Express {
|
|
7821
7952
|
const app = express();
|
|
7822
7953
|
|
|
7823
7954
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
7824
7955
|
app.use(express.json());
|
|
7956
|
+
app.use(requestLogger); // one structured log line per request
|
|
7825
7957
|
|
|
7826
7958
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
|
7827
7959
|
registerRoutes(app);
|
|
@@ -7862,6 +7994,7 @@ function backendFiles(ctx) {
|
|
|
7862
7994
|
"backend/src/env.ts": backendEnv(ctx),
|
|
7863
7995
|
"backend/src/db.ts": backendDb(),
|
|
7864
7996
|
"backend/src/cache.ts": backendCache(),
|
|
7997
|
+
"backend/src/logger.ts": backendLogger(),
|
|
7865
7998
|
"backend/src/app.ts": backendApp(ctx),
|
|
7866
7999
|
"backend/src/http.ts": backendHttp(),
|
|
7867
8000
|
"backend/src/express.d.ts": backendExpressTypes(),
|
package/dist/lib.cjs
CHANGED
|
@@ -2406,6 +2406,38 @@ var FEATURES = [
|
|
|
2406
2406
|
"Push works on https (or localhost); the generated public/sw.js receives the notifications."
|
|
2407
2407
|
],
|
|
2408
2408
|
learn: "https://developer.lacspace.com/packages/web-push"
|
|
2409
|
+
},
|
|
2410
|
+
{
|
|
2411
|
+
key: "consent",
|
|
2412
|
+
label: "Cookie consent",
|
|
2413
|
+
description: "GDPR-friendly cookie consent (@lacspace/consent) \u2014 a drop-in <ConsentBanner/>, per-category choices, cookie persistence and whenConsent() script-gating. Frontend, any template.",
|
|
2414
|
+
deps: { "@lacspace/consent": "^1.0.0" },
|
|
2415
|
+
files: () => ({ "components/consent.tsx": consentComponent() }),
|
|
2416
|
+
nextSteps: [
|
|
2417
|
+
"Render <ConsentBanner/> once in app/layout.tsx: import { ConsentBanner } from '@/components/consent'.",
|
|
2418
|
+
"Gate analytics/marketing scripts: whenConsent(consent, 'analytics', () => { /* load tag */ }).",
|
|
2419
|
+
"The choice is stored in a cookie \u2014 gate server-side too with parseConsentCookie()."
|
|
2420
|
+
],
|
|
2421
|
+
learn: "https://developer.lacspace.com/packages/consent"
|
|
2422
|
+
},
|
|
2423
|
+
{
|
|
2424
|
+
key: "realtime",
|
|
2425
|
+
label: "Real-time (SSE)",
|
|
2426
|
+
description: "Live server-to-browser updates over Server-Sent Events (@lacspace/sse) \u2014 a channel hub + a /live stream + a React useSSE feed. No WebSocket server. Full-stack.",
|
|
2427
|
+
requiresBackend: true,
|
|
2428
|
+
deps: { "@lacspace/sse": "^1.0.0" },
|
|
2429
|
+
files: () => ({ "app/live/page.tsx": realtimeLivePage() }),
|
|
2430
|
+
backend: () => ({
|
|
2431
|
+
deps: { "@lacspace/sse": "^1.0.0" },
|
|
2432
|
+
files: { "src/routes/live.ts": realtimeRoutesBackend() },
|
|
2433
|
+
routes: [{ path: "/live", handler: "liveRoutes", auth: false, importLine: 'import liveRoutes from "./live.js";' }]
|
|
2434
|
+
}),
|
|
2435
|
+
nextSteps: [
|
|
2436
|
+
"Open http://localhost:3000/live in two tabs; click 'Broadcast' in one \u2014 the other updates instantly.",
|
|
2437
|
+
"Push from anywhere on the server: hub.broadcast({ event: 'message', data }) in src/routes/live.ts.",
|
|
2438
|
+
"SSE auto-reconnects and needs no WebSocket server \u2014 great for notifications and live feeds."
|
|
2439
|
+
],
|
|
2440
|
+
learn: "https://developer.lacspace.com/packages/sse"
|
|
2409
2441
|
}
|
|
2410
2442
|
];
|
|
2411
2443
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6924,6 +6956,69 @@ router.post("/send", asyncHandler(async (req, res) => {
|
|
|
6924
6956
|
res.json({ sent: results.filter((r) => r.result && !r.result.expired).length, removed: expired.length });
|
|
6925
6957
|
}));
|
|
6926
6958
|
|
|
6959
|
+
export default router;
|
|
6960
|
+
`;
|
|
6961
|
+
var consentComponent = () => `"use client";
|
|
6962
|
+
// how this works: re-export the consent UI + API from one component. Render
|
|
6963
|
+
// <ConsentBanner/> once in app/layout.tsx; gate scripts with whenConsent().
|
|
6964
|
+
export { ConsentBanner, useConsent } from "@lacspace/consent/react";
|
|
6965
|
+
export { consent, whenConsent } from "@lacspace/consent";
|
|
6966
|
+
`;
|
|
6967
|
+
var realtimeLivePage = () => `"use client";
|
|
6968
|
+
import { useState } from "react";
|
|
6969
|
+
import { useSSE } from "@lacspace/sse/react";
|
|
6970
|
+
|
|
6971
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6972
|
+
|
|
6973
|
+
export default function LivePage() {
|
|
6974
|
+
const [log, setLog] = useState<string[]>([]);
|
|
6975
|
+
const { status } = useSSE(API + "/live", {
|
|
6976
|
+
onEvent: { message: (m) => setLog((prev) => [String((m as { text?: string }).text ?? ""), ...prev].slice(0, 50)) },
|
|
6977
|
+
});
|
|
6978
|
+
|
|
6979
|
+
async function broadcast() {
|
|
6980
|
+
await fetch(API + "/live/broadcast", {
|
|
6981
|
+
method: "POST",
|
|
6982
|
+
headers: { "Content-Type": "application/json" },
|
|
6983
|
+
body: JSON.stringify({ text: "Hello at " + new Date().toLocaleTimeString() }),
|
|
6984
|
+
});
|
|
6985
|
+
}
|
|
6986
|
+
|
|
6987
|
+
return (
|
|
6988
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6989
|
+
<h1 className="text-2xl font-bold">Live feed</h1>
|
|
6990
|
+
<p className="mt-1 text-muted">Server-Sent Events \u2014 status: {status}.</p>
|
|
6991
|
+
<button className="mt-4 rounded-xl border border-hairline px-4 py-2" onClick={broadcast}>Broadcast a message</button>
|
|
6992
|
+
<ul className="mt-6 space-y-2">
|
|
6993
|
+
{log.map((line, i) => (<li key={i} className="rounded-xl border border-hairline p-3">{line}</li>))}
|
|
6994
|
+
</ul>
|
|
6995
|
+
</main>
|
|
6996
|
+
);
|
|
6997
|
+
}
|
|
6998
|
+
`;
|
|
6999
|
+
var realtimeRoutesBackend = () => `import express from "express";
|
|
7000
|
+
import { SSEHub, sseHandler } from "@lacspace/sse";
|
|
7001
|
+
import { asyncHandler } from "../http.js";
|
|
7002
|
+
|
|
7003
|
+
// how this works: one shared hub holds every open SSE connection. GET /live opens
|
|
7004
|
+
// a stream; POST /live/broadcast pushes a "message" event to everyone connected.
|
|
7005
|
+
// Push from anywhere in your app by importing this hub and calling hub.broadcast().
|
|
7006
|
+
const router = express.Router();
|
|
7007
|
+
const hub = new SSEHub();
|
|
7008
|
+
hub.startHeartbeat();
|
|
7009
|
+
|
|
7010
|
+
router.get("/", (_req, res) => {
|
|
7011
|
+
const client = sseHandler(res, { onClose: () => hub.remove(client) });
|
|
7012
|
+
hub.add(client);
|
|
7013
|
+
client.send({ event: "message", data: { text: "Connected to the live feed." } });
|
|
7014
|
+
});
|
|
7015
|
+
|
|
7016
|
+
router.post("/broadcast", asyncHandler(async (req, res) => {
|
|
7017
|
+
const text = typeof req.body?.text === "string" ? req.body.text : "ping";
|
|
7018
|
+
const sent = hub.broadcast({ event: "message", data: { text } });
|
|
7019
|
+
res.json({ sent });
|
|
7020
|
+
}));
|
|
7021
|
+
|
|
6927
7022
|
export default router;
|
|
6928
7023
|
`;
|
|
6929
7024
|
var scope = (ctx) => `@${ctx.name}`;
|
|
@@ -7377,6 +7472,7 @@ var backendPkgJson = (ctx) => {
|
|
|
7377
7472
|
"@lacspace/id": "^1.1.0",
|
|
7378
7473
|
"@lacspace/rate-limit": "^1.2.0",
|
|
7379
7474
|
"@lacspace/cache": "^1.1.0",
|
|
7475
|
+
"@lacspace/logger": "^1.0.0",
|
|
7380
7476
|
...featureDeps,
|
|
7381
7477
|
[`${scope(ctx)}/types`]: "*"
|
|
7382
7478
|
},
|
|
@@ -7486,21 +7582,54 @@ var backendIndex = (ctx) => `import "./load-env.js"; // MUST be first: fills pro
|
|
|
7486
7582
|
import { env } from "./env.js";
|
|
7487
7583
|
import { connectDb } from "./db.js";
|
|
7488
7584
|
import { createApp } from "./app.js";
|
|
7585
|
+
import { log } from "./logger.js";
|
|
7489
7586
|
|
|
7490
7587
|
// how this works: connect to MongoDB, build the Express app, then listen.
|
|
7491
7588
|
async function main(): Promise<void> {
|
|
7492
7589
|
await connectDb(env.MONGODB_URI);
|
|
7493
7590
|
const app = createApp();
|
|
7494
7591
|
app.listen(env.PORT, () => {
|
|
7495
|
-
|
|
7592
|
+
log.info("${ctx.name} API ready", { url: \`http://localhost:\${env.PORT}\` });
|
|
7496
7593
|
});
|
|
7497
7594
|
}
|
|
7498
7595
|
|
|
7499
7596
|
main().catch((err) => {
|
|
7500
|
-
|
|
7597
|
+
log.fatal("Failed to start the API", { err });
|
|
7501
7598
|
process.exit(1);
|
|
7502
7599
|
});
|
|
7503
7600
|
`;
|
|
7601
|
+
var backendLogger = () => `import { createLogger, jsonConsole, prettyConsole } from "@lacspace/logger";
|
|
7602
|
+
import type { Request, Response, NextFunction } from "express";
|
|
7603
|
+
import { env } from "./env.js";
|
|
7604
|
+
|
|
7605
|
+
// how this works: one structured logger for the whole API (@lacspace/logger, zero-dep).
|
|
7606
|
+
// In production we emit JSON-per-line (clean for log aggregators); in dev we print a
|
|
7607
|
+
// readable, coloured line. Common secret fields are redacted before anything is logged.
|
|
7608
|
+
const isProd = env.NODE_ENV === "production";
|
|
7609
|
+
export const log = createLogger({
|
|
7610
|
+
level: isProd ? "info" : "debug",
|
|
7611
|
+
transports: [isProd ? jsonConsole() : prettyConsole({ colors: true })],
|
|
7612
|
+
redact: ["password", "token", "authorization", "*.password", "*.token"],
|
|
7613
|
+
});
|
|
7614
|
+
|
|
7615
|
+
// Request logger \u2014 one line per request with method, path, status and duration.
|
|
7616
|
+
// Each request gets a child logger with a short request id you can thread through.
|
|
7617
|
+
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
|
7618
|
+
const start = Date.now();
|
|
7619
|
+
const reqId = Math.random().toString(36).slice(2, 10);
|
|
7620
|
+
(req as Request & { log: typeof log }).log = log.child({ reqId });
|
|
7621
|
+
res.on("finish", () => {
|
|
7622
|
+
log.info("request", {
|
|
7623
|
+
reqId,
|
|
7624
|
+
method: req.method,
|
|
7625
|
+
path: req.originalUrl,
|
|
7626
|
+
status: res.statusCode,
|
|
7627
|
+
ms: Date.now() - start,
|
|
7628
|
+
});
|
|
7629
|
+
});
|
|
7630
|
+
next();
|
|
7631
|
+
}
|
|
7632
|
+
`;
|
|
7504
7633
|
var backendDb = () => `import mongoose from "mongoose";
|
|
7505
7634
|
|
|
7506
7635
|
// how this works: opens the Mongoose connection. Call once at boot.
|
|
@@ -7816,16 +7945,19 @@ export default router;
|
|
|
7816
7945
|
var backendApp = (ctx) => `import express from "express";
|
|
7817
7946
|
import cors from "cors";
|
|
7818
7947
|
import { env } from "./env.js";
|
|
7948
|
+
import { requestLogger } from "./logger.js";
|
|
7819
7949
|
import { errorHandler } from "./middleware/error.js";
|
|
7820
7950
|
import { registerRoutes } from "./routes/index.js";
|
|
7821
7951
|
|
|
7822
7952
|
// how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
|
|
7823
|
-
// a health check, all route groups
|
|
7953
|
+
// structured request logging (@lacspace/logger), a health check, all route groups
|
|
7954
|
+
// (see routes/index.ts), then the error handler LAST.
|
|
7824
7955
|
export function createApp(): express.Express {
|
|
7825
7956
|
const app = express();
|
|
7826
7957
|
|
|
7827
7958
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
7828
7959
|
app.use(express.json());
|
|
7960
|
+
app.use(requestLogger); // one structured log line per request
|
|
7829
7961
|
|
|
7830
7962
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
|
7831
7963
|
registerRoutes(app);
|
|
@@ -7866,6 +7998,7 @@ function backendFiles(ctx) {
|
|
|
7866
7998
|
"backend/src/env.ts": backendEnv(ctx),
|
|
7867
7999
|
"backend/src/db.ts": backendDb(),
|
|
7868
8000
|
"backend/src/cache.ts": backendCache(),
|
|
8001
|
+
"backend/src/logger.ts": backendLogger(),
|
|
7869
8002
|
"backend/src/app.ts": backendApp(ctx),
|
|
7870
8003
|
"backend/src/http.ts": backendHttp(),
|
|
7871
8004
|
"backend/src/express.d.ts": backendExpressTypes(),
|
package/dist/lib.js
CHANGED
|
@@ -2403,6 +2403,38 @@ var FEATURES = [
|
|
|
2403
2403
|
"Push works on https (or localhost); the generated public/sw.js receives the notifications."
|
|
2404
2404
|
],
|
|
2405
2405
|
learn: "https://developer.lacspace.com/packages/web-push"
|
|
2406
|
+
},
|
|
2407
|
+
{
|
|
2408
|
+
key: "consent",
|
|
2409
|
+
label: "Cookie consent",
|
|
2410
|
+
description: "GDPR-friendly cookie consent (@lacspace/consent) \u2014 a drop-in <ConsentBanner/>, per-category choices, cookie persistence and whenConsent() script-gating. Frontend, any template.",
|
|
2411
|
+
deps: { "@lacspace/consent": "^1.0.0" },
|
|
2412
|
+
files: () => ({ "components/consent.tsx": consentComponent() }),
|
|
2413
|
+
nextSteps: [
|
|
2414
|
+
"Render <ConsentBanner/> once in app/layout.tsx: import { ConsentBanner } from '@/components/consent'.",
|
|
2415
|
+
"Gate analytics/marketing scripts: whenConsent(consent, 'analytics', () => { /* load tag */ }).",
|
|
2416
|
+
"The choice is stored in a cookie \u2014 gate server-side too with parseConsentCookie()."
|
|
2417
|
+
],
|
|
2418
|
+
learn: "https://developer.lacspace.com/packages/consent"
|
|
2419
|
+
},
|
|
2420
|
+
{
|
|
2421
|
+
key: "realtime",
|
|
2422
|
+
label: "Real-time (SSE)",
|
|
2423
|
+
description: "Live server-to-browser updates over Server-Sent Events (@lacspace/sse) \u2014 a channel hub + a /live stream + a React useSSE feed. No WebSocket server. Full-stack.",
|
|
2424
|
+
requiresBackend: true,
|
|
2425
|
+
deps: { "@lacspace/sse": "^1.0.0" },
|
|
2426
|
+
files: () => ({ "app/live/page.tsx": realtimeLivePage() }),
|
|
2427
|
+
backend: () => ({
|
|
2428
|
+
deps: { "@lacspace/sse": "^1.0.0" },
|
|
2429
|
+
files: { "src/routes/live.ts": realtimeRoutesBackend() },
|
|
2430
|
+
routes: [{ path: "/live", handler: "liveRoutes", auth: false, importLine: 'import liveRoutes from "./live.js";' }]
|
|
2431
|
+
}),
|
|
2432
|
+
nextSteps: [
|
|
2433
|
+
"Open http://localhost:3000/live in two tabs; click 'Broadcast' in one \u2014 the other updates instantly.",
|
|
2434
|
+
"Push from anywhere on the server: hub.broadcast({ event: 'message', data }) in src/routes/live.ts.",
|
|
2435
|
+
"SSE auto-reconnects and needs no WebSocket server \u2014 great for notifications and live feeds."
|
|
2436
|
+
],
|
|
2437
|
+
learn: "https://developer.lacspace.com/packages/sse"
|
|
2406
2438
|
}
|
|
2407
2439
|
];
|
|
2408
2440
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6921,6 +6953,69 @@ router.post("/send", asyncHandler(async (req, res) => {
|
|
|
6921
6953
|
res.json({ sent: results.filter((r) => r.result && !r.result.expired).length, removed: expired.length });
|
|
6922
6954
|
}));
|
|
6923
6955
|
|
|
6956
|
+
export default router;
|
|
6957
|
+
`;
|
|
6958
|
+
var consentComponent = () => `"use client";
|
|
6959
|
+
// how this works: re-export the consent UI + API from one component. Render
|
|
6960
|
+
// <ConsentBanner/> once in app/layout.tsx; gate scripts with whenConsent().
|
|
6961
|
+
export { ConsentBanner, useConsent } from "@lacspace/consent/react";
|
|
6962
|
+
export { consent, whenConsent } from "@lacspace/consent";
|
|
6963
|
+
`;
|
|
6964
|
+
var realtimeLivePage = () => `"use client";
|
|
6965
|
+
import { useState } from "react";
|
|
6966
|
+
import { useSSE } from "@lacspace/sse/react";
|
|
6967
|
+
|
|
6968
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6969
|
+
|
|
6970
|
+
export default function LivePage() {
|
|
6971
|
+
const [log, setLog] = useState<string[]>([]);
|
|
6972
|
+
const { status } = useSSE(API + "/live", {
|
|
6973
|
+
onEvent: { message: (m) => setLog((prev) => [String((m as { text?: string }).text ?? ""), ...prev].slice(0, 50)) },
|
|
6974
|
+
});
|
|
6975
|
+
|
|
6976
|
+
async function broadcast() {
|
|
6977
|
+
await fetch(API + "/live/broadcast", {
|
|
6978
|
+
method: "POST",
|
|
6979
|
+
headers: { "Content-Type": "application/json" },
|
|
6980
|
+
body: JSON.stringify({ text: "Hello at " + new Date().toLocaleTimeString() }),
|
|
6981
|
+
});
|
|
6982
|
+
}
|
|
6983
|
+
|
|
6984
|
+
return (
|
|
6985
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6986
|
+
<h1 className="text-2xl font-bold">Live feed</h1>
|
|
6987
|
+
<p className="mt-1 text-muted">Server-Sent Events \u2014 status: {status}.</p>
|
|
6988
|
+
<button className="mt-4 rounded-xl border border-hairline px-4 py-2" onClick={broadcast}>Broadcast a message</button>
|
|
6989
|
+
<ul className="mt-6 space-y-2">
|
|
6990
|
+
{log.map((line, i) => (<li key={i} className="rounded-xl border border-hairline p-3">{line}</li>))}
|
|
6991
|
+
</ul>
|
|
6992
|
+
</main>
|
|
6993
|
+
);
|
|
6994
|
+
}
|
|
6995
|
+
`;
|
|
6996
|
+
var realtimeRoutesBackend = () => `import express from "express";
|
|
6997
|
+
import { SSEHub, sseHandler } from "@lacspace/sse";
|
|
6998
|
+
import { asyncHandler } from "../http.js";
|
|
6999
|
+
|
|
7000
|
+
// how this works: one shared hub holds every open SSE connection. GET /live opens
|
|
7001
|
+
// a stream; POST /live/broadcast pushes a "message" event to everyone connected.
|
|
7002
|
+
// Push from anywhere in your app by importing this hub and calling hub.broadcast().
|
|
7003
|
+
const router = express.Router();
|
|
7004
|
+
const hub = new SSEHub();
|
|
7005
|
+
hub.startHeartbeat();
|
|
7006
|
+
|
|
7007
|
+
router.get("/", (_req, res) => {
|
|
7008
|
+
const client = sseHandler(res, { onClose: () => hub.remove(client) });
|
|
7009
|
+
hub.add(client);
|
|
7010
|
+
client.send({ event: "message", data: { text: "Connected to the live feed." } });
|
|
7011
|
+
});
|
|
7012
|
+
|
|
7013
|
+
router.post("/broadcast", asyncHandler(async (req, res) => {
|
|
7014
|
+
const text = typeof req.body?.text === "string" ? req.body.text : "ping";
|
|
7015
|
+
const sent = hub.broadcast({ event: "message", data: { text } });
|
|
7016
|
+
res.json({ sent });
|
|
7017
|
+
}));
|
|
7018
|
+
|
|
6924
7019
|
export default router;
|
|
6925
7020
|
`;
|
|
6926
7021
|
var scope = (ctx) => `@${ctx.name}`;
|
|
@@ -7374,6 +7469,7 @@ var backendPkgJson = (ctx) => {
|
|
|
7374
7469
|
"@lacspace/id": "^1.1.0",
|
|
7375
7470
|
"@lacspace/rate-limit": "^1.2.0",
|
|
7376
7471
|
"@lacspace/cache": "^1.1.0",
|
|
7472
|
+
"@lacspace/logger": "^1.0.0",
|
|
7377
7473
|
...featureDeps,
|
|
7378
7474
|
[`${scope(ctx)}/types`]: "*"
|
|
7379
7475
|
},
|
|
@@ -7483,21 +7579,54 @@ var backendIndex = (ctx) => `import "./load-env.js"; // MUST be first: fills pro
|
|
|
7483
7579
|
import { env } from "./env.js";
|
|
7484
7580
|
import { connectDb } from "./db.js";
|
|
7485
7581
|
import { createApp } from "./app.js";
|
|
7582
|
+
import { log } from "./logger.js";
|
|
7486
7583
|
|
|
7487
7584
|
// how this works: connect to MongoDB, build the Express app, then listen.
|
|
7488
7585
|
async function main(): Promise<void> {
|
|
7489
7586
|
await connectDb(env.MONGODB_URI);
|
|
7490
7587
|
const app = createApp();
|
|
7491
7588
|
app.listen(env.PORT, () => {
|
|
7492
|
-
|
|
7589
|
+
log.info("${ctx.name} API ready", { url: \`http://localhost:\${env.PORT}\` });
|
|
7493
7590
|
});
|
|
7494
7591
|
}
|
|
7495
7592
|
|
|
7496
7593
|
main().catch((err) => {
|
|
7497
|
-
|
|
7594
|
+
log.fatal("Failed to start the API", { err });
|
|
7498
7595
|
process.exit(1);
|
|
7499
7596
|
});
|
|
7500
7597
|
`;
|
|
7598
|
+
var backendLogger = () => `import { createLogger, jsonConsole, prettyConsole } from "@lacspace/logger";
|
|
7599
|
+
import type { Request, Response, NextFunction } from "express";
|
|
7600
|
+
import { env } from "./env.js";
|
|
7601
|
+
|
|
7602
|
+
// how this works: one structured logger for the whole API (@lacspace/logger, zero-dep).
|
|
7603
|
+
// In production we emit JSON-per-line (clean for log aggregators); in dev we print a
|
|
7604
|
+
// readable, coloured line. Common secret fields are redacted before anything is logged.
|
|
7605
|
+
const isProd = env.NODE_ENV === "production";
|
|
7606
|
+
export const log = createLogger({
|
|
7607
|
+
level: isProd ? "info" : "debug",
|
|
7608
|
+
transports: [isProd ? jsonConsole() : prettyConsole({ colors: true })],
|
|
7609
|
+
redact: ["password", "token", "authorization", "*.password", "*.token"],
|
|
7610
|
+
});
|
|
7611
|
+
|
|
7612
|
+
// Request logger \u2014 one line per request with method, path, status and duration.
|
|
7613
|
+
// Each request gets a child logger with a short request id you can thread through.
|
|
7614
|
+
export function requestLogger(req: Request, res: Response, next: NextFunction): void {
|
|
7615
|
+
const start = Date.now();
|
|
7616
|
+
const reqId = Math.random().toString(36).slice(2, 10);
|
|
7617
|
+
(req as Request & { log: typeof log }).log = log.child({ reqId });
|
|
7618
|
+
res.on("finish", () => {
|
|
7619
|
+
log.info("request", {
|
|
7620
|
+
reqId,
|
|
7621
|
+
method: req.method,
|
|
7622
|
+
path: req.originalUrl,
|
|
7623
|
+
status: res.statusCode,
|
|
7624
|
+
ms: Date.now() - start,
|
|
7625
|
+
});
|
|
7626
|
+
});
|
|
7627
|
+
next();
|
|
7628
|
+
}
|
|
7629
|
+
`;
|
|
7501
7630
|
var backendDb = () => `import mongoose from "mongoose";
|
|
7502
7631
|
|
|
7503
7632
|
// how this works: opens the Mongoose connection. Call once at boot.
|
|
@@ -7813,16 +7942,19 @@ export default router;
|
|
|
7813
7942
|
var backendApp = (ctx) => `import express from "express";
|
|
7814
7943
|
import cors from "cors";
|
|
7815
7944
|
import { env } from "./env.js";
|
|
7945
|
+
import { requestLogger } from "./logger.js";
|
|
7816
7946
|
import { errorHandler } from "./middleware/error.js";
|
|
7817
7947
|
import { registerRoutes } from "./routes/index.js";
|
|
7818
7948
|
|
|
7819
7949
|
// how this works: assembles the Express app \u2014 CORS for the frontend, JSON parsing,
|
|
7820
|
-
// a health check, all route groups
|
|
7950
|
+
// structured request logging (@lacspace/logger), a health check, all route groups
|
|
7951
|
+
// (see routes/index.ts), then the error handler LAST.
|
|
7821
7952
|
export function createApp(): express.Express {
|
|
7822
7953
|
const app = express();
|
|
7823
7954
|
|
|
7824
7955
|
app.use(cors({ origin: env.CORS_ORIGIN.split(",").map((o) => o.trim()), credentials: true }));
|
|
7825
7956
|
app.use(express.json());
|
|
7957
|
+
app.use(requestLogger); // one structured log line per request
|
|
7826
7958
|
|
|
7827
7959
|
app.get("/health", (_req, res) => { res.json({ ok: true, service: "${ctx.name}-api" }); });
|
|
7828
7960
|
registerRoutes(app);
|
|
@@ -7863,6 +7995,7 @@ function backendFiles(ctx) {
|
|
|
7863
7995
|
"backend/src/env.ts": backendEnv(ctx),
|
|
7864
7996
|
"backend/src/db.ts": backendDb(),
|
|
7865
7997
|
"backend/src/cache.ts": backendCache(),
|
|
7998
|
+
"backend/src/logger.ts": backendLogger(),
|
|
7866
7999
|
"backend/src/app.ts": backendApp(ctx),
|
|
7867
8000
|
"backend/src/http.ts": backendHttp(),
|
|
7868
8001
|
"backend/src/express.d.ts": backendExpressTypes(),
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lacspace-app",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.13.0",
|
|
4
4
|
"description": "Scaffold a beautiful, production-ready Next.js app from a Lacspace template — portfolio, business, e-commerce, SaaS, blog, docs, dashboard, restaurant or marketplace — pre-wired with SEO, security headers, sitemap and robots. Use it as a CLI (like create-next-app, but you start gorgeous) or import it as a library to generate projects programmatically.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|