create-lacspace-app 2.10.0 → 2.12.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 +9 -0
- package/dist/index.js +406 -0
- package/dist/lib.cjs +406 -0
- package/dist/lib.js +406 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -20,6 +20,15 @@ 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
|
+
>
|
|
27
|
+
> **New in v2.11 — the Web Engagement Kit (14 add-ons).** Three keyless, no-vendor add-ons on brand-new `@lacspace` packages:
|
|
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.
|
|
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.
|
|
30
|
+
> - **`notify`** — **in-app toast notifications** (`@lacspace/notify`): a `<Toaster/>` plus success/error/promise toasts, accessible and zero-config. Frontend, any template.
|
|
31
|
+
>
|
|
23
32
|
> **New in v2.10 — three more add-ons (11 total).**
|
|
24
33
|
> - **`uploads`** — authenticated file uploads stored in MongoDB, served via **signed, expiring URLs** (`@lacspace/signed-url`) — no S3 required.
|
|
25
34
|
> - **`i18n`** — multi-language UI: a tiny dependency-free `t()` translator + a language switcher, with English + Nepali locales and an `i18n:check` lint script.
|
package/dist/index.js
CHANGED
|
@@ -2342,6 +2342,98 @@ var FEATURES = [
|
|
|
2342
2342
|
"For large files or production, swap the Mongo storage for disk or object storage."
|
|
2343
2343
|
],
|
|
2344
2344
|
learn: "https://developer.lacspace.com/packages/signed-url"
|
|
2345
|
+
},
|
|
2346
|
+
{
|
|
2347
|
+
key: "notify",
|
|
2348
|
+
label: "Toast notifications",
|
|
2349
|
+
description: "Beautiful in-app toast notifications (@lacspace/notify) \u2014 a <Toaster/> plus success/error/promise toasts, accessible and zero-config. Frontend, any template.",
|
|
2350
|
+
deps: { "@lacspace/notify": "^1.0.0" },
|
|
2351
|
+
files: () => ({ "components/toaster.tsx": notifyToaster(), "app/notify-demo/page.tsx": notifyDemoPage() }),
|
|
2352
|
+
nextSteps: [
|
|
2353
|
+
"Render <Toaster/> once in app/layout.tsx: import { Toaster } from '@/components/toaster'.",
|
|
2354
|
+
"Then call toast.success('Saved!') (or .error/.info/.promise) from any client component.",
|
|
2355
|
+
"See it live at http://localhost:3000/notify-demo."
|
|
2356
|
+
],
|
|
2357
|
+
learn: "https://developer.lacspace.com/packages/notify"
|
|
2358
|
+
},
|
|
2359
|
+
{
|
|
2360
|
+
key: "captcha",
|
|
2361
|
+
label: "Keyless CAPTCHA",
|
|
2362
|
+
description: "Privacy-friendly proof-of-work CAPTCHA (@lacspace/captcha) \u2014 no Google/Cloudflare, no keys, no tracking. A drop-in widget + a backend verify route. Full-stack.",
|
|
2363
|
+
requiresBackend: true,
|
|
2364
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2365
|
+
files: () => ({ "components/captcha.tsx": captchaComponent(), "app/captcha-demo/page.tsx": captchaDemoPage() }),
|
|
2366
|
+
backend: () => ({
|
|
2367
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2368
|
+
files: { "src/routes/captcha.ts": captchaRoutesBackend() },
|
|
2369
|
+
routes: [{ path: "/captcha", handler: "captchaRoutes", auth: false, importLine: 'import captchaRoutes from "./captcha.js";' }],
|
|
2370
|
+
env: { CAPTCHA_SECRET: "Secret for signing CAPTCHA challenges (blank = reuse JWT_SECRET)." }
|
|
2371
|
+
}),
|
|
2372
|
+
nextSteps: [
|
|
2373
|
+
"Open http://localhost:3000/captcha-demo \u2014 solve the CAPTCHA, then Submit to verify it server-side.",
|
|
2374
|
+
"Drop <Captcha onVerified={setToken} /> into any form, then POST the token to /captcha/verify.",
|
|
2375
|
+
"No accounts or keys \u2014 tune difficulty via createChallenge({ maxNumber }) in src/routes/captcha.ts."
|
|
2376
|
+
],
|
|
2377
|
+
learn: "https://developer.lacspace.com/packages/captcha"
|
|
2378
|
+
},
|
|
2379
|
+
{
|
|
2380
|
+
key: "push",
|
|
2381
|
+
label: "Web push notifications",
|
|
2382
|
+
description: "Real browser push notifications (@lacspace/web-push) \u2014 keyless VAPID, no Firebase/FCM. Generates a service worker, a subscribe button, and a backend that stores subscriptions and sends. Full-stack.",
|
|
2383
|
+
requiresBackend: true,
|
|
2384
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2385
|
+
files: () => ({ "public/sw.js": pushServiceWorkerFile(), "app/push-demo/page.tsx": pushDemoPage() }),
|
|
2386
|
+
backend: () => ({
|
|
2387
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2388
|
+
files: {
|
|
2389
|
+
"src/models/push-subscription.ts": pushSubscriptionModel(),
|
|
2390
|
+
"src/routes/push.ts": pushRoutesBackend()
|
|
2391
|
+
},
|
|
2392
|
+
routes: [{ path: "/push", handler: "pushRoutes", auth: false, importLine: 'import pushRoutes from "./push.js";' }],
|
|
2393
|
+
env: {
|
|
2394
|
+
VAPID_PUBLIC: "VAPID public key \u2014 generate with: npx @lacspace/web-push (or generateVapidKeys()).",
|
|
2395
|
+
VAPID_PRIVATE: "VAPID private key (keep secret).",
|
|
2396
|
+
VAPID_SUBJECT: "Contact for the push service, e.g. mailto:you@yoursite.com."
|
|
2397
|
+
}
|
|
2398
|
+
}),
|
|
2399
|
+
nextSteps: [
|
|
2400
|
+
"Generate VAPID keys: npx @lacspace/web-push \u2014 put them in .env (VAPID_PUBLIC / VAPID_PRIVATE).",
|
|
2401
|
+
"Open http://localhost:3000/push-demo \u2192 'Enable notifications', then 'Send test'.",
|
|
2402
|
+
"Push works on https (or localhost); the generated public/sw.js receives the notifications."
|
|
2403
|
+
],
|
|
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"
|
|
2345
2437
|
}
|
|
2346
2438
|
];
|
|
2347
2439
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6611,6 +6703,320 @@ export default function UploadsPage() {
|
|
|
6611
6703
|
);
|
|
6612
6704
|
}
|
|
6613
6705
|
`;
|
|
6706
|
+
var notifyToaster = () => `"use client";
|
|
6707
|
+
// how this works: re-export the Lacspace toast API + <Toaster/> from one place.
|
|
6708
|
+
// Render <Toaster/> once in app/layout.tsx, then call toast.* from anywhere.
|
|
6709
|
+
export { Toaster, useToast, toast } from "@lacspace/notify/react";
|
|
6710
|
+
`;
|
|
6711
|
+
var notifyDemoPage = () => `"use client";
|
|
6712
|
+
import { toast } from "@/components/toaster";
|
|
6713
|
+
|
|
6714
|
+
export default function NotifyDemo() {
|
|
6715
|
+
return (
|
|
6716
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6717
|
+
<h1 className="text-2xl font-bold">Toasts</h1>
|
|
6718
|
+
<p className="mt-1 text-muted">In-app notifications from @lacspace/notify.</p>
|
|
6719
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6720
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.success("Saved!")}>Success</button>
|
|
6721
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.error("Something broke")}>Error</button>
|
|
6722
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.info("Heads up", { action: { label: "Undo", onClick: () => toast.success("Undone") } })}>With action</button>
|
|
6723
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => void toast.promise(new Promise((r) => setTimeout(r, 1200)), { loading: "Working\u2026", success: "Done!", error: "Failed" })}>Promise</button>
|
|
6724
|
+
</div>
|
|
6725
|
+
</main>
|
|
6726
|
+
);
|
|
6727
|
+
}
|
|
6728
|
+
`;
|
|
6729
|
+
var captchaComponent = () => `"use client";
|
|
6730
|
+
import { useEffect, useRef } from "react";
|
|
6731
|
+
import { renderCaptcha } from "@lacspace/captcha/client";
|
|
6732
|
+
|
|
6733
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6734
|
+
|
|
6735
|
+
// how this works: the widget fetches a challenge from your backend, solves a small
|
|
6736
|
+
// proof-of-work in the browser, and drops the solution into a hidden input named
|
|
6737
|
+
// "lacspace-captcha". Read it via onVerified, or submit it with a surrounding form.
|
|
6738
|
+
export function Captcha({ onVerified }: { onVerified?: (token: string) => void }) {
|
|
6739
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
6740
|
+
useEffect(() => {
|
|
6741
|
+
if (!ref.current) return;
|
|
6742
|
+
const widget = renderCaptcha(ref.current, {
|
|
6743
|
+
challenge: API + "/captcha",
|
|
6744
|
+
onVerified: (token) => onVerified?.(token),
|
|
6745
|
+
});
|
|
6746
|
+
return () => widget.destroy();
|
|
6747
|
+
}, [onVerified]);
|
|
6748
|
+
return <div ref={ref} />;
|
|
6749
|
+
}
|
|
6750
|
+
`;
|
|
6751
|
+
var captchaDemoPage = () => `"use client";
|
|
6752
|
+
import { useState } from "react";
|
|
6753
|
+
import { Captcha } from "@/components/captcha";
|
|
6754
|
+
|
|
6755
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6756
|
+
|
|
6757
|
+
export default function CaptchaDemo() {
|
|
6758
|
+
const [token, setToken] = useState<string | null>(null);
|
|
6759
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
6760
|
+
|
|
6761
|
+
async function submit(e: React.FormEvent) {
|
|
6762
|
+
e.preventDefault();
|
|
6763
|
+
if (!token) { setStatus("Please solve the CAPTCHA first."); return; }
|
|
6764
|
+
const res = await fetch(API + "/captcha/verify", {
|
|
6765
|
+
method: "POST",
|
|
6766
|
+
headers: { "Content-Type": "application/json" },
|
|
6767
|
+
body: JSON.stringify({ solution: token }),
|
|
6768
|
+
});
|
|
6769
|
+
setStatus(res.ok ? "Verified on the server \u2713 \u2014 safe to proceed." : "Verification failed.");
|
|
6770
|
+
}
|
|
6771
|
+
|
|
6772
|
+
return (
|
|
6773
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6774
|
+
<h1 className="text-2xl font-bold">Keyless CAPTCHA</h1>
|
|
6775
|
+
<p className="mt-1 text-muted">Proof-of-work \u2014 no Google / Cloudflare, no keys.</p>
|
|
6776
|
+
<form onSubmit={submit} className="mt-6 space-y-4">
|
|
6777
|
+
<Captcha onVerified={setToken} />
|
|
6778
|
+
<button type="submit" className="rounded-xl border border-hairline px-4 py-2">Submit</button>
|
|
6779
|
+
</form>
|
|
6780
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6781
|
+
</main>
|
|
6782
|
+
);
|
|
6783
|
+
}
|
|
6784
|
+
`;
|
|
6785
|
+
var captchaRoutesBackend = () => `import express from "express";
|
|
6786
|
+
import { createChallenge, verifySolution } from "@lacspace/captcha";
|
|
6787
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6788
|
+
import { env } from "../env.js";
|
|
6789
|
+
|
|
6790
|
+
// how this works: a keyless proof-of-work CAPTCHA. GET /captcha issues a signed
|
|
6791
|
+
// challenge; the browser solves it; POST /captcha/verify checks the solution with
|
|
6792
|
+
// only your secret \u2014 stateless, nothing stored between the two calls.
|
|
6793
|
+
const router = express.Router();
|
|
6794
|
+
const SECRET = process.env.CAPTCHA_SECRET ?? env.JWT_SECRET;
|
|
6795
|
+
|
|
6796
|
+
router.get("/", asyncHandler(async (_req, res) => {
|
|
6797
|
+
res.json(await createChallenge({ secret: SECRET }));
|
|
6798
|
+
}));
|
|
6799
|
+
|
|
6800
|
+
router.post("/verify", asyncHandler(async (req, res) => {
|
|
6801
|
+
const solution = typeof req.body?.solution === "string" ? req.body.solution : "";
|
|
6802
|
+
const result = await verifySolution(solution, { secret: SECRET });
|
|
6803
|
+
if (!result.success) throw new HttpError(400, "CAPTCHA failed: " + result.error);
|
|
6804
|
+
res.json({ success: true });
|
|
6805
|
+
}));
|
|
6806
|
+
|
|
6807
|
+
export default router;
|
|
6808
|
+
`;
|
|
6809
|
+
var pushServiceWorkerFile = () => `// Service worker \u2014 web push notifications. Generated by create-lacspace-app.
|
|
6810
|
+
// Served at /sw.js. Wakes on incoming pushes and opens the app on click.
|
|
6811
|
+
|
|
6812
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
6813
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
|
6814
|
+
|
|
6815
|
+
self.addEventListener("push", (event) => {
|
|
6816
|
+
let data = {};
|
|
6817
|
+
try { data = event.data ? event.data.json() : {}; }
|
|
6818
|
+
catch (err) { data = { body: event.data ? event.data.text() : "" }; }
|
|
6819
|
+
const title = data.title || "Notification";
|
|
6820
|
+
event.waitUntil(
|
|
6821
|
+
self.registration.showNotification(title, {
|
|
6822
|
+
body: data.body,
|
|
6823
|
+
icon: data.icon,
|
|
6824
|
+
data: { url: data.url || "/" },
|
|
6825
|
+
}),
|
|
6826
|
+
);
|
|
6827
|
+
});
|
|
6828
|
+
|
|
6829
|
+
self.addEventListener("notificationclick", (event) => {
|
|
6830
|
+
event.notification.close();
|
|
6831
|
+
const target = (event.notification.data && event.notification.data.url) || "/";
|
|
6832
|
+
event.waitUntil(
|
|
6833
|
+
(async () => {
|
|
6834
|
+
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
6835
|
+
for (const client of all) { if (client.url === target && "focus" in client) return client.focus(); }
|
|
6836
|
+
if (self.clients.openWindow) return self.clients.openWindow(target);
|
|
6837
|
+
})(),
|
|
6838
|
+
);
|
|
6839
|
+
});
|
|
6840
|
+
`;
|
|
6841
|
+
var pushDemoPage = () => `"use client";
|
|
6842
|
+
import { useState } from "react";
|
|
6843
|
+
import { subscribeToPush } from "@lacspace/web-push/client";
|
|
6844
|
+
|
|
6845
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6846
|
+
|
|
6847
|
+
export default function PushDemo() {
|
|
6848
|
+
const [status, setStatus] = useState("");
|
|
6849
|
+
|
|
6850
|
+
async function enable() {
|
|
6851
|
+
try {
|
|
6852
|
+
const { publicKey } = await fetch(API + "/push/vapid").then((r) => r.json());
|
|
6853
|
+
if (!publicKey) { setStatus("Set VAPID_PUBLIC / VAPID_PRIVATE in the backend .env first (npx @lacspace/web-push)."); return; }
|
|
6854
|
+
await subscribeToPush({ vapidPublicKey: publicKey, serviceWorkerUrl: "/sw.js", saveUrl: API + "/push/subscribe" });
|
|
6855
|
+
setStatus("Subscribed! Click 'Send test' to receive a push.");
|
|
6856
|
+
} catch (err) { setStatus("Failed: " + String(err)); }
|
|
6857
|
+
}
|
|
6858
|
+
|
|
6859
|
+
async function sendTest() {
|
|
6860
|
+
const r = await fetch(API + "/push/send", {
|
|
6861
|
+
method: "POST",
|
|
6862
|
+
headers: { "Content-Type": "application/json" },
|
|
6863
|
+
body: JSON.stringify({ title: "It works", body: "Your first web push \u{1F389}" }),
|
|
6864
|
+
}).then((r) => r.json());
|
|
6865
|
+
setStatus("Sent to " + r.sent + " device(s).");
|
|
6866
|
+
}
|
|
6867
|
+
|
|
6868
|
+
return (
|
|
6869
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6870
|
+
<h1 className="text-2xl font-bold">Web push</h1>
|
|
6871
|
+
<p className="mt-1 text-muted">Real browser notifications \u2014 keyless VAPID, no Firebase.</p>
|
|
6872
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6873
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={enable}>Enable notifications</button>
|
|
6874
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={sendTest}>Send test</button>
|
|
6875
|
+
</div>
|
|
6876
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6877
|
+
</main>
|
|
6878
|
+
);
|
|
6879
|
+
}
|
|
6880
|
+
`;
|
|
6881
|
+
var pushSubscriptionModel = () => `import mongoose from "mongoose";
|
|
6882
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6883
|
+
|
|
6884
|
+
// how this works: one document per browser subscription. endpoint is unique so a
|
|
6885
|
+
// re-subscribe upserts instead of duplicating. Store the two keys the Web Push
|
|
6886
|
+
// protocol needs (p256dh + auth).
|
|
6887
|
+
export interface PushSubDoc {
|
|
6888
|
+
_id: string;
|
|
6889
|
+
endpoint: string;
|
|
6890
|
+
p256dh: string;
|
|
6891
|
+
auth: string;
|
|
6892
|
+
createdAt: Date;
|
|
6893
|
+
}
|
|
6894
|
+
|
|
6895
|
+
const schema = new mongoose.Schema<PushSubDoc>(
|
|
6896
|
+
{
|
|
6897
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6898
|
+
endpoint: { type: String, required: true, unique: true },
|
|
6899
|
+
p256dh: { type: String, required: true },
|
|
6900
|
+
auth: { type: String, required: true },
|
|
6901
|
+
},
|
|
6902
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6903
|
+
);
|
|
6904
|
+
|
|
6905
|
+
export const PushSubscriptionModel =
|
|
6906
|
+
(mongoose.models.PushSubscription as mongoose.Model<PushSubDoc>) ??
|
|
6907
|
+
mongoose.model<PushSubDoc>("PushSubscription", schema);
|
|
6908
|
+
`;
|
|
6909
|
+
var pushRoutesBackend = () => `import express from "express";
|
|
6910
|
+
import { sendNotifications, type PushSubscription } from "@lacspace/web-push";
|
|
6911
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6912
|
+
import { PushSubscriptionModel } from "../models/push-subscription.js";
|
|
6913
|
+
|
|
6914
|
+
// how this works: /push/vapid gives the browser the public key; /push/subscribe
|
|
6915
|
+
// stores a subscription; /push/send blasts a payload to every stored subscription
|
|
6916
|
+
// and prunes any the push service reports as gone (404/410).
|
|
6917
|
+
const router = express.Router();
|
|
6918
|
+
|
|
6919
|
+
function vapid() {
|
|
6920
|
+
const publicKey = process.env.VAPID_PUBLIC;
|
|
6921
|
+
const privateKey = process.env.VAPID_PRIVATE;
|
|
6922
|
+
if (!publicKey || !privateKey) {
|
|
6923
|
+
throw new HttpError(500, "Missing VAPID keys. Generate them (npx @lacspace/web-push) and set VAPID_PUBLIC / VAPID_PRIVATE in .env.");
|
|
6924
|
+
}
|
|
6925
|
+
return { subject: process.env.VAPID_SUBJECT ?? "mailto:admin@example.com", publicKey, privateKey };
|
|
6926
|
+
}
|
|
6927
|
+
|
|
6928
|
+
router.get("/vapid", asyncHandler(async (_req, res) => {
|
|
6929
|
+
res.json({ publicKey: process.env.VAPID_PUBLIC ?? "" });
|
|
6930
|
+
}));
|
|
6931
|
+
|
|
6932
|
+
router.post("/subscribe", asyncHandler(async (req, res) => {
|
|
6933
|
+
const endpoint = typeof req.body?.endpoint === "string" ? req.body.endpoint : "";
|
|
6934
|
+
const p256dh = req.body?.keys?.p256dh;
|
|
6935
|
+
const auth = req.body?.keys?.auth;
|
|
6936
|
+
if (!endpoint || typeof p256dh !== "string" || typeof auth !== "string") {
|
|
6937
|
+
throw new HttpError(400, "Invalid subscription");
|
|
6938
|
+
}
|
|
6939
|
+
await PushSubscriptionModel.updateOne({ endpoint }, { $set: { endpoint, p256dh, auth } }, { upsert: true });
|
|
6940
|
+
res.status(201).json({ ok: true });
|
|
6941
|
+
}));
|
|
6942
|
+
|
|
6943
|
+
router.post("/send", asyncHandler(async (req, res) => {
|
|
6944
|
+
const title = typeof req.body?.title === "string" ? req.body.title : "Hello from your app";
|
|
6945
|
+
const body = typeof req.body?.body === "string" ? req.body.body : "This is a web push notification.";
|
|
6946
|
+
const docs = await PushSubscriptionModel.find();
|
|
6947
|
+
const subs: PushSubscription[] = docs.map((d) => ({ endpoint: d.endpoint, keys: { p256dh: d.p256dh, auth: d.auth } }));
|
|
6948
|
+
const payload = JSON.stringify({ title, body, url: "/" });
|
|
6949
|
+
const results = await sendNotifications(subs, payload, { vapid: vapid() });
|
|
6950
|
+
const expired = results.filter((r) => r.result?.expired);
|
|
6951
|
+
await Promise.all(expired.map((r) => PushSubscriptionModel.deleteOne({ endpoint: r.subscription.endpoint })));
|
|
6952
|
+
res.json({ sent: results.filter((r) => r.result && !r.result.expired).length, removed: expired.length });
|
|
6953
|
+
}));
|
|
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
|
+
|
|
7018
|
+
export default router;
|
|
7019
|
+
`;
|
|
6614
7020
|
var scope = (ctx) => `@${ctx.name}`;
|
|
6615
7021
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
6616
7022
|
name: `${scope(ctx)}/types`,
|
package/dist/lib.cjs
CHANGED
|
@@ -2346,6 +2346,98 @@ var FEATURES = [
|
|
|
2346
2346
|
"For large files or production, swap the Mongo storage for disk or object storage."
|
|
2347
2347
|
],
|
|
2348
2348
|
learn: "https://developer.lacspace.com/packages/signed-url"
|
|
2349
|
+
},
|
|
2350
|
+
{
|
|
2351
|
+
key: "notify",
|
|
2352
|
+
label: "Toast notifications",
|
|
2353
|
+
description: "Beautiful in-app toast notifications (@lacspace/notify) \u2014 a <Toaster/> plus success/error/promise toasts, accessible and zero-config. Frontend, any template.",
|
|
2354
|
+
deps: { "@lacspace/notify": "^1.0.0" },
|
|
2355
|
+
files: () => ({ "components/toaster.tsx": notifyToaster(), "app/notify-demo/page.tsx": notifyDemoPage() }),
|
|
2356
|
+
nextSteps: [
|
|
2357
|
+
"Render <Toaster/> once in app/layout.tsx: import { Toaster } from '@/components/toaster'.",
|
|
2358
|
+
"Then call toast.success('Saved!') (or .error/.info/.promise) from any client component.",
|
|
2359
|
+
"See it live at http://localhost:3000/notify-demo."
|
|
2360
|
+
],
|
|
2361
|
+
learn: "https://developer.lacspace.com/packages/notify"
|
|
2362
|
+
},
|
|
2363
|
+
{
|
|
2364
|
+
key: "captcha",
|
|
2365
|
+
label: "Keyless CAPTCHA",
|
|
2366
|
+
description: "Privacy-friendly proof-of-work CAPTCHA (@lacspace/captcha) \u2014 no Google/Cloudflare, no keys, no tracking. A drop-in widget + a backend verify route. Full-stack.",
|
|
2367
|
+
requiresBackend: true,
|
|
2368
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2369
|
+
files: () => ({ "components/captcha.tsx": captchaComponent(), "app/captcha-demo/page.tsx": captchaDemoPage() }),
|
|
2370
|
+
backend: () => ({
|
|
2371
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2372
|
+
files: { "src/routes/captcha.ts": captchaRoutesBackend() },
|
|
2373
|
+
routes: [{ path: "/captcha", handler: "captchaRoutes", auth: false, importLine: 'import captchaRoutes from "./captcha.js";' }],
|
|
2374
|
+
env: { CAPTCHA_SECRET: "Secret for signing CAPTCHA challenges (blank = reuse JWT_SECRET)." }
|
|
2375
|
+
}),
|
|
2376
|
+
nextSteps: [
|
|
2377
|
+
"Open http://localhost:3000/captcha-demo \u2014 solve the CAPTCHA, then Submit to verify it server-side.",
|
|
2378
|
+
"Drop <Captcha onVerified={setToken} /> into any form, then POST the token to /captcha/verify.",
|
|
2379
|
+
"No accounts or keys \u2014 tune difficulty via createChallenge({ maxNumber }) in src/routes/captcha.ts."
|
|
2380
|
+
],
|
|
2381
|
+
learn: "https://developer.lacspace.com/packages/captcha"
|
|
2382
|
+
},
|
|
2383
|
+
{
|
|
2384
|
+
key: "push",
|
|
2385
|
+
label: "Web push notifications",
|
|
2386
|
+
description: "Real browser push notifications (@lacspace/web-push) \u2014 keyless VAPID, no Firebase/FCM. Generates a service worker, a subscribe button, and a backend that stores subscriptions and sends. Full-stack.",
|
|
2387
|
+
requiresBackend: true,
|
|
2388
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2389
|
+
files: () => ({ "public/sw.js": pushServiceWorkerFile(), "app/push-demo/page.tsx": pushDemoPage() }),
|
|
2390
|
+
backend: () => ({
|
|
2391
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2392
|
+
files: {
|
|
2393
|
+
"src/models/push-subscription.ts": pushSubscriptionModel(),
|
|
2394
|
+
"src/routes/push.ts": pushRoutesBackend()
|
|
2395
|
+
},
|
|
2396
|
+
routes: [{ path: "/push", handler: "pushRoutes", auth: false, importLine: 'import pushRoutes from "./push.js";' }],
|
|
2397
|
+
env: {
|
|
2398
|
+
VAPID_PUBLIC: "VAPID public key \u2014 generate with: npx @lacspace/web-push (or generateVapidKeys()).",
|
|
2399
|
+
VAPID_PRIVATE: "VAPID private key (keep secret).",
|
|
2400
|
+
VAPID_SUBJECT: "Contact for the push service, e.g. mailto:you@yoursite.com."
|
|
2401
|
+
}
|
|
2402
|
+
}),
|
|
2403
|
+
nextSteps: [
|
|
2404
|
+
"Generate VAPID keys: npx @lacspace/web-push \u2014 put them in .env (VAPID_PUBLIC / VAPID_PRIVATE).",
|
|
2405
|
+
"Open http://localhost:3000/push-demo \u2192 'Enable notifications', then 'Send test'.",
|
|
2406
|
+
"Push works on https (or localhost); the generated public/sw.js receives the notifications."
|
|
2407
|
+
],
|
|
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"
|
|
2349
2441
|
}
|
|
2350
2442
|
];
|
|
2351
2443
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6615,6 +6707,320 @@ export default function UploadsPage() {
|
|
|
6615
6707
|
);
|
|
6616
6708
|
}
|
|
6617
6709
|
`;
|
|
6710
|
+
var notifyToaster = () => `"use client";
|
|
6711
|
+
// how this works: re-export the Lacspace toast API + <Toaster/> from one place.
|
|
6712
|
+
// Render <Toaster/> once in app/layout.tsx, then call toast.* from anywhere.
|
|
6713
|
+
export { Toaster, useToast, toast } from "@lacspace/notify/react";
|
|
6714
|
+
`;
|
|
6715
|
+
var notifyDemoPage = () => `"use client";
|
|
6716
|
+
import { toast } from "@/components/toaster";
|
|
6717
|
+
|
|
6718
|
+
export default function NotifyDemo() {
|
|
6719
|
+
return (
|
|
6720
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6721
|
+
<h1 className="text-2xl font-bold">Toasts</h1>
|
|
6722
|
+
<p className="mt-1 text-muted">In-app notifications from @lacspace/notify.</p>
|
|
6723
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6724
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.success("Saved!")}>Success</button>
|
|
6725
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.error("Something broke")}>Error</button>
|
|
6726
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.info("Heads up", { action: { label: "Undo", onClick: () => toast.success("Undone") } })}>With action</button>
|
|
6727
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => void toast.promise(new Promise((r) => setTimeout(r, 1200)), { loading: "Working\u2026", success: "Done!", error: "Failed" })}>Promise</button>
|
|
6728
|
+
</div>
|
|
6729
|
+
</main>
|
|
6730
|
+
);
|
|
6731
|
+
}
|
|
6732
|
+
`;
|
|
6733
|
+
var captchaComponent = () => `"use client";
|
|
6734
|
+
import { useEffect, useRef } from "react";
|
|
6735
|
+
import { renderCaptcha } from "@lacspace/captcha/client";
|
|
6736
|
+
|
|
6737
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6738
|
+
|
|
6739
|
+
// how this works: the widget fetches a challenge from your backend, solves a small
|
|
6740
|
+
// proof-of-work in the browser, and drops the solution into a hidden input named
|
|
6741
|
+
// "lacspace-captcha". Read it via onVerified, or submit it with a surrounding form.
|
|
6742
|
+
export function Captcha({ onVerified }: { onVerified?: (token: string) => void }) {
|
|
6743
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
6744
|
+
useEffect(() => {
|
|
6745
|
+
if (!ref.current) return;
|
|
6746
|
+
const widget = renderCaptcha(ref.current, {
|
|
6747
|
+
challenge: API + "/captcha",
|
|
6748
|
+
onVerified: (token) => onVerified?.(token),
|
|
6749
|
+
});
|
|
6750
|
+
return () => widget.destroy();
|
|
6751
|
+
}, [onVerified]);
|
|
6752
|
+
return <div ref={ref} />;
|
|
6753
|
+
}
|
|
6754
|
+
`;
|
|
6755
|
+
var captchaDemoPage = () => `"use client";
|
|
6756
|
+
import { useState } from "react";
|
|
6757
|
+
import { Captcha } from "@/components/captcha";
|
|
6758
|
+
|
|
6759
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6760
|
+
|
|
6761
|
+
export default function CaptchaDemo() {
|
|
6762
|
+
const [token, setToken] = useState<string | null>(null);
|
|
6763
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
6764
|
+
|
|
6765
|
+
async function submit(e: React.FormEvent) {
|
|
6766
|
+
e.preventDefault();
|
|
6767
|
+
if (!token) { setStatus("Please solve the CAPTCHA first."); return; }
|
|
6768
|
+
const res = await fetch(API + "/captcha/verify", {
|
|
6769
|
+
method: "POST",
|
|
6770
|
+
headers: { "Content-Type": "application/json" },
|
|
6771
|
+
body: JSON.stringify({ solution: token }),
|
|
6772
|
+
});
|
|
6773
|
+
setStatus(res.ok ? "Verified on the server \u2713 \u2014 safe to proceed." : "Verification failed.");
|
|
6774
|
+
}
|
|
6775
|
+
|
|
6776
|
+
return (
|
|
6777
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6778
|
+
<h1 className="text-2xl font-bold">Keyless CAPTCHA</h1>
|
|
6779
|
+
<p className="mt-1 text-muted">Proof-of-work \u2014 no Google / Cloudflare, no keys.</p>
|
|
6780
|
+
<form onSubmit={submit} className="mt-6 space-y-4">
|
|
6781
|
+
<Captcha onVerified={setToken} />
|
|
6782
|
+
<button type="submit" className="rounded-xl border border-hairline px-4 py-2">Submit</button>
|
|
6783
|
+
</form>
|
|
6784
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6785
|
+
</main>
|
|
6786
|
+
);
|
|
6787
|
+
}
|
|
6788
|
+
`;
|
|
6789
|
+
var captchaRoutesBackend = () => `import express from "express";
|
|
6790
|
+
import { createChallenge, verifySolution } from "@lacspace/captcha";
|
|
6791
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6792
|
+
import { env } from "../env.js";
|
|
6793
|
+
|
|
6794
|
+
// how this works: a keyless proof-of-work CAPTCHA. GET /captcha issues a signed
|
|
6795
|
+
// challenge; the browser solves it; POST /captcha/verify checks the solution with
|
|
6796
|
+
// only your secret \u2014 stateless, nothing stored between the two calls.
|
|
6797
|
+
const router = express.Router();
|
|
6798
|
+
const SECRET = process.env.CAPTCHA_SECRET ?? env.JWT_SECRET;
|
|
6799
|
+
|
|
6800
|
+
router.get("/", asyncHandler(async (_req, res) => {
|
|
6801
|
+
res.json(await createChallenge({ secret: SECRET }));
|
|
6802
|
+
}));
|
|
6803
|
+
|
|
6804
|
+
router.post("/verify", asyncHandler(async (req, res) => {
|
|
6805
|
+
const solution = typeof req.body?.solution === "string" ? req.body.solution : "";
|
|
6806
|
+
const result = await verifySolution(solution, { secret: SECRET });
|
|
6807
|
+
if (!result.success) throw new HttpError(400, "CAPTCHA failed: " + result.error);
|
|
6808
|
+
res.json({ success: true });
|
|
6809
|
+
}));
|
|
6810
|
+
|
|
6811
|
+
export default router;
|
|
6812
|
+
`;
|
|
6813
|
+
var pushServiceWorkerFile = () => `// Service worker \u2014 web push notifications. Generated by create-lacspace-app.
|
|
6814
|
+
// Served at /sw.js. Wakes on incoming pushes and opens the app on click.
|
|
6815
|
+
|
|
6816
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
6817
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
|
6818
|
+
|
|
6819
|
+
self.addEventListener("push", (event) => {
|
|
6820
|
+
let data = {};
|
|
6821
|
+
try { data = event.data ? event.data.json() : {}; }
|
|
6822
|
+
catch (err) { data = { body: event.data ? event.data.text() : "" }; }
|
|
6823
|
+
const title = data.title || "Notification";
|
|
6824
|
+
event.waitUntil(
|
|
6825
|
+
self.registration.showNotification(title, {
|
|
6826
|
+
body: data.body,
|
|
6827
|
+
icon: data.icon,
|
|
6828
|
+
data: { url: data.url || "/" },
|
|
6829
|
+
}),
|
|
6830
|
+
);
|
|
6831
|
+
});
|
|
6832
|
+
|
|
6833
|
+
self.addEventListener("notificationclick", (event) => {
|
|
6834
|
+
event.notification.close();
|
|
6835
|
+
const target = (event.notification.data && event.notification.data.url) || "/";
|
|
6836
|
+
event.waitUntil(
|
|
6837
|
+
(async () => {
|
|
6838
|
+
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
6839
|
+
for (const client of all) { if (client.url === target && "focus" in client) return client.focus(); }
|
|
6840
|
+
if (self.clients.openWindow) return self.clients.openWindow(target);
|
|
6841
|
+
})(),
|
|
6842
|
+
);
|
|
6843
|
+
});
|
|
6844
|
+
`;
|
|
6845
|
+
var pushDemoPage = () => `"use client";
|
|
6846
|
+
import { useState } from "react";
|
|
6847
|
+
import { subscribeToPush } from "@lacspace/web-push/client";
|
|
6848
|
+
|
|
6849
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6850
|
+
|
|
6851
|
+
export default function PushDemo() {
|
|
6852
|
+
const [status, setStatus] = useState("");
|
|
6853
|
+
|
|
6854
|
+
async function enable() {
|
|
6855
|
+
try {
|
|
6856
|
+
const { publicKey } = await fetch(API + "/push/vapid").then((r) => r.json());
|
|
6857
|
+
if (!publicKey) { setStatus("Set VAPID_PUBLIC / VAPID_PRIVATE in the backend .env first (npx @lacspace/web-push)."); return; }
|
|
6858
|
+
await subscribeToPush({ vapidPublicKey: publicKey, serviceWorkerUrl: "/sw.js", saveUrl: API + "/push/subscribe" });
|
|
6859
|
+
setStatus("Subscribed! Click 'Send test' to receive a push.");
|
|
6860
|
+
} catch (err) { setStatus("Failed: " + String(err)); }
|
|
6861
|
+
}
|
|
6862
|
+
|
|
6863
|
+
async function sendTest() {
|
|
6864
|
+
const r = await fetch(API + "/push/send", {
|
|
6865
|
+
method: "POST",
|
|
6866
|
+
headers: { "Content-Type": "application/json" },
|
|
6867
|
+
body: JSON.stringify({ title: "It works", body: "Your first web push \u{1F389}" }),
|
|
6868
|
+
}).then((r) => r.json());
|
|
6869
|
+
setStatus("Sent to " + r.sent + " device(s).");
|
|
6870
|
+
}
|
|
6871
|
+
|
|
6872
|
+
return (
|
|
6873
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6874
|
+
<h1 className="text-2xl font-bold">Web push</h1>
|
|
6875
|
+
<p className="mt-1 text-muted">Real browser notifications \u2014 keyless VAPID, no Firebase.</p>
|
|
6876
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6877
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={enable}>Enable notifications</button>
|
|
6878
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={sendTest}>Send test</button>
|
|
6879
|
+
</div>
|
|
6880
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6881
|
+
</main>
|
|
6882
|
+
);
|
|
6883
|
+
}
|
|
6884
|
+
`;
|
|
6885
|
+
var pushSubscriptionModel = () => `import mongoose from "mongoose";
|
|
6886
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6887
|
+
|
|
6888
|
+
// how this works: one document per browser subscription. endpoint is unique so a
|
|
6889
|
+
// re-subscribe upserts instead of duplicating. Store the two keys the Web Push
|
|
6890
|
+
// protocol needs (p256dh + auth).
|
|
6891
|
+
export interface PushSubDoc {
|
|
6892
|
+
_id: string;
|
|
6893
|
+
endpoint: string;
|
|
6894
|
+
p256dh: string;
|
|
6895
|
+
auth: string;
|
|
6896
|
+
createdAt: Date;
|
|
6897
|
+
}
|
|
6898
|
+
|
|
6899
|
+
const schema = new mongoose.Schema<PushSubDoc>(
|
|
6900
|
+
{
|
|
6901
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6902
|
+
endpoint: { type: String, required: true, unique: true },
|
|
6903
|
+
p256dh: { type: String, required: true },
|
|
6904
|
+
auth: { type: String, required: true },
|
|
6905
|
+
},
|
|
6906
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6907
|
+
);
|
|
6908
|
+
|
|
6909
|
+
export const PushSubscriptionModel =
|
|
6910
|
+
(mongoose.models.PushSubscription as mongoose.Model<PushSubDoc>) ??
|
|
6911
|
+
mongoose.model<PushSubDoc>("PushSubscription", schema);
|
|
6912
|
+
`;
|
|
6913
|
+
var pushRoutesBackend = () => `import express from "express";
|
|
6914
|
+
import { sendNotifications, type PushSubscription } from "@lacspace/web-push";
|
|
6915
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6916
|
+
import { PushSubscriptionModel } from "../models/push-subscription.js";
|
|
6917
|
+
|
|
6918
|
+
// how this works: /push/vapid gives the browser the public key; /push/subscribe
|
|
6919
|
+
// stores a subscription; /push/send blasts a payload to every stored subscription
|
|
6920
|
+
// and prunes any the push service reports as gone (404/410).
|
|
6921
|
+
const router = express.Router();
|
|
6922
|
+
|
|
6923
|
+
function vapid() {
|
|
6924
|
+
const publicKey = process.env.VAPID_PUBLIC;
|
|
6925
|
+
const privateKey = process.env.VAPID_PRIVATE;
|
|
6926
|
+
if (!publicKey || !privateKey) {
|
|
6927
|
+
throw new HttpError(500, "Missing VAPID keys. Generate them (npx @lacspace/web-push) and set VAPID_PUBLIC / VAPID_PRIVATE in .env.");
|
|
6928
|
+
}
|
|
6929
|
+
return { subject: process.env.VAPID_SUBJECT ?? "mailto:admin@example.com", publicKey, privateKey };
|
|
6930
|
+
}
|
|
6931
|
+
|
|
6932
|
+
router.get("/vapid", asyncHandler(async (_req, res) => {
|
|
6933
|
+
res.json({ publicKey: process.env.VAPID_PUBLIC ?? "" });
|
|
6934
|
+
}));
|
|
6935
|
+
|
|
6936
|
+
router.post("/subscribe", asyncHandler(async (req, res) => {
|
|
6937
|
+
const endpoint = typeof req.body?.endpoint === "string" ? req.body.endpoint : "";
|
|
6938
|
+
const p256dh = req.body?.keys?.p256dh;
|
|
6939
|
+
const auth = req.body?.keys?.auth;
|
|
6940
|
+
if (!endpoint || typeof p256dh !== "string" || typeof auth !== "string") {
|
|
6941
|
+
throw new HttpError(400, "Invalid subscription");
|
|
6942
|
+
}
|
|
6943
|
+
await PushSubscriptionModel.updateOne({ endpoint }, { $set: { endpoint, p256dh, auth } }, { upsert: true });
|
|
6944
|
+
res.status(201).json({ ok: true });
|
|
6945
|
+
}));
|
|
6946
|
+
|
|
6947
|
+
router.post("/send", asyncHandler(async (req, res) => {
|
|
6948
|
+
const title = typeof req.body?.title === "string" ? req.body.title : "Hello from your app";
|
|
6949
|
+
const body = typeof req.body?.body === "string" ? req.body.body : "This is a web push notification.";
|
|
6950
|
+
const docs = await PushSubscriptionModel.find();
|
|
6951
|
+
const subs: PushSubscription[] = docs.map((d) => ({ endpoint: d.endpoint, keys: { p256dh: d.p256dh, auth: d.auth } }));
|
|
6952
|
+
const payload = JSON.stringify({ title, body, url: "/" });
|
|
6953
|
+
const results = await sendNotifications(subs, payload, { vapid: vapid() });
|
|
6954
|
+
const expired = results.filter((r) => r.result?.expired);
|
|
6955
|
+
await Promise.all(expired.map((r) => PushSubscriptionModel.deleteOne({ endpoint: r.subscription.endpoint })));
|
|
6956
|
+
res.json({ sent: results.filter((r) => r.result && !r.result.expired).length, removed: expired.length });
|
|
6957
|
+
}));
|
|
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
|
+
|
|
7022
|
+
export default router;
|
|
7023
|
+
`;
|
|
6618
7024
|
var scope = (ctx) => `@${ctx.name}`;
|
|
6619
7025
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
6620
7026
|
name: `${scope(ctx)}/types`,
|
package/dist/lib.js
CHANGED
|
@@ -2343,6 +2343,98 @@ var FEATURES = [
|
|
|
2343
2343
|
"For large files or production, swap the Mongo storage for disk or object storage."
|
|
2344
2344
|
],
|
|
2345
2345
|
learn: "https://developer.lacspace.com/packages/signed-url"
|
|
2346
|
+
},
|
|
2347
|
+
{
|
|
2348
|
+
key: "notify",
|
|
2349
|
+
label: "Toast notifications",
|
|
2350
|
+
description: "Beautiful in-app toast notifications (@lacspace/notify) \u2014 a <Toaster/> plus success/error/promise toasts, accessible and zero-config. Frontend, any template.",
|
|
2351
|
+
deps: { "@lacspace/notify": "^1.0.0" },
|
|
2352
|
+
files: () => ({ "components/toaster.tsx": notifyToaster(), "app/notify-demo/page.tsx": notifyDemoPage() }),
|
|
2353
|
+
nextSteps: [
|
|
2354
|
+
"Render <Toaster/> once in app/layout.tsx: import { Toaster } from '@/components/toaster'.",
|
|
2355
|
+
"Then call toast.success('Saved!') (or .error/.info/.promise) from any client component.",
|
|
2356
|
+
"See it live at http://localhost:3000/notify-demo."
|
|
2357
|
+
],
|
|
2358
|
+
learn: "https://developer.lacspace.com/packages/notify"
|
|
2359
|
+
},
|
|
2360
|
+
{
|
|
2361
|
+
key: "captcha",
|
|
2362
|
+
label: "Keyless CAPTCHA",
|
|
2363
|
+
description: "Privacy-friendly proof-of-work CAPTCHA (@lacspace/captcha) \u2014 no Google/Cloudflare, no keys, no tracking. A drop-in widget + a backend verify route. Full-stack.",
|
|
2364
|
+
requiresBackend: true,
|
|
2365
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2366
|
+
files: () => ({ "components/captcha.tsx": captchaComponent(), "app/captcha-demo/page.tsx": captchaDemoPage() }),
|
|
2367
|
+
backend: () => ({
|
|
2368
|
+
deps: { "@lacspace/captcha": "^1.0.0" },
|
|
2369
|
+
files: { "src/routes/captcha.ts": captchaRoutesBackend() },
|
|
2370
|
+
routes: [{ path: "/captcha", handler: "captchaRoutes", auth: false, importLine: 'import captchaRoutes from "./captcha.js";' }],
|
|
2371
|
+
env: { CAPTCHA_SECRET: "Secret for signing CAPTCHA challenges (blank = reuse JWT_SECRET)." }
|
|
2372
|
+
}),
|
|
2373
|
+
nextSteps: [
|
|
2374
|
+
"Open http://localhost:3000/captcha-demo \u2014 solve the CAPTCHA, then Submit to verify it server-side.",
|
|
2375
|
+
"Drop <Captcha onVerified={setToken} /> into any form, then POST the token to /captcha/verify.",
|
|
2376
|
+
"No accounts or keys \u2014 tune difficulty via createChallenge({ maxNumber }) in src/routes/captcha.ts."
|
|
2377
|
+
],
|
|
2378
|
+
learn: "https://developer.lacspace.com/packages/captcha"
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
key: "push",
|
|
2382
|
+
label: "Web push notifications",
|
|
2383
|
+
description: "Real browser push notifications (@lacspace/web-push) \u2014 keyless VAPID, no Firebase/FCM. Generates a service worker, a subscribe button, and a backend that stores subscriptions and sends. Full-stack.",
|
|
2384
|
+
requiresBackend: true,
|
|
2385
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2386
|
+
files: () => ({ "public/sw.js": pushServiceWorkerFile(), "app/push-demo/page.tsx": pushDemoPage() }),
|
|
2387
|
+
backend: () => ({
|
|
2388
|
+
deps: { "@lacspace/web-push": "^1.0.0" },
|
|
2389
|
+
files: {
|
|
2390
|
+
"src/models/push-subscription.ts": pushSubscriptionModel(),
|
|
2391
|
+
"src/routes/push.ts": pushRoutesBackend()
|
|
2392
|
+
},
|
|
2393
|
+
routes: [{ path: "/push", handler: "pushRoutes", auth: false, importLine: 'import pushRoutes from "./push.js";' }],
|
|
2394
|
+
env: {
|
|
2395
|
+
VAPID_PUBLIC: "VAPID public key \u2014 generate with: npx @lacspace/web-push (or generateVapidKeys()).",
|
|
2396
|
+
VAPID_PRIVATE: "VAPID private key (keep secret).",
|
|
2397
|
+
VAPID_SUBJECT: "Contact for the push service, e.g. mailto:you@yoursite.com."
|
|
2398
|
+
}
|
|
2399
|
+
}),
|
|
2400
|
+
nextSteps: [
|
|
2401
|
+
"Generate VAPID keys: npx @lacspace/web-push \u2014 put them in .env (VAPID_PUBLIC / VAPID_PRIVATE).",
|
|
2402
|
+
"Open http://localhost:3000/push-demo \u2192 'Enable notifications', then 'Send test'.",
|
|
2403
|
+
"Push works on https (or localhost); the generated public/sw.js receives the notifications."
|
|
2404
|
+
],
|
|
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"
|
|
2346
2438
|
}
|
|
2347
2439
|
];
|
|
2348
2440
|
var aiChatRoute = (_ctx) => `import { resolveConfig } from "@lacspace/providers";
|
|
@@ -6612,6 +6704,320 @@ export default function UploadsPage() {
|
|
|
6612
6704
|
);
|
|
6613
6705
|
}
|
|
6614
6706
|
`;
|
|
6707
|
+
var notifyToaster = () => `"use client";
|
|
6708
|
+
// how this works: re-export the Lacspace toast API + <Toaster/> from one place.
|
|
6709
|
+
// Render <Toaster/> once in app/layout.tsx, then call toast.* from anywhere.
|
|
6710
|
+
export { Toaster, useToast, toast } from "@lacspace/notify/react";
|
|
6711
|
+
`;
|
|
6712
|
+
var notifyDemoPage = () => `"use client";
|
|
6713
|
+
import { toast } from "@/components/toaster";
|
|
6714
|
+
|
|
6715
|
+
export default function NotifyDemo() {
|
|
6716
|
+
return (
|
|
6717
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6718
|
+
<h1 className="text-2xl font-bold">Toasts</h1>
|
|
6719
|
+
<p className="mt-1 text-muted">In-app notifications from @lacspace/notify.</p>
|
|
6720
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6721
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.success("Saved!")}>Success</button>
|
|
6722
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.error("Something broke")}>Error</button>
|
|
6723
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => toast.info("Heads up", { action: { label: "Undo", onClick: () => toast.success("Undone") } })}>With action</button>
|
|
6724
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={() => void toast.promise(new Promise((r) => setTimeout(r, 1200)), { loading: "Working\u2026", success: "Done!", error: "Failed" })}>Promise</button>
|
|
6725
|
+
</div>
|
|
6726
|
+
</main>
|
|
6727
|
+
);
|
|
6728
|
+
}
|
|
6729
|
+
`;
|
|
6730
|
+
var captchaComponent = () => `"use client";
|
|
6731
|
+
import { useEffect, useRef } from "react";
|
|
6732
|
+
import { renderCaptcha } from "@lacspace/captcha/client";
|
|
6733
|
+
|
|
6734
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6735
|
+
|
|
6736
|
+
// how this works: the widget fetches a challenge from your backend, solves a small
|
|
6737
|
+
// proof-of-work in the browser, and drops the solution into a hidden input named
|
|
6738
|
+
// "lacspace-captcha". Read it via onVerified, or submit it with a surrounding form.
|
|
6739
|
+
export function Captcha({ onVerified }: { onVerified?: (token: string) => void }) {
|
|
6740
|
+
const ref = useRef<HTMLDivElement>(null);
|
|
6741
|
+
useEffect(() => {
|
|
6742
|
+
if (!ref.current) return;
|
|
6743
|
+
const widget = renderCaptcha(ref.current, {
|
|
6744
|
+
challenge: API + "/captcha",
|
|
6745
|
+
onVerified: (token) => onVerified?.(token),
|
|
6746
|
+
});
|
|
6747
|
+
return () => widget.destroy();
|
|
6748
|
+
}, [onVerified]);
|
|
6749
|
+
return <div ref={ref} />;
|
|
6750
|
+
}
|
|
6751
|
+
`;
|
|
6752
|
+
var captchaDemoPage = () => `"use client";
|
|
6753
|
+
import { useState } from "react";
|
|
6754
|
+
import { Captcha } from "@/components/captcha";
|
|
6755
|
+
|
|
6756
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6757
|
+
|
|
6758
|
+
export default function CaptchaDemo() {
|
|
6759
|
+
const [token, setToken] = useState<string | null>(null);
|
|
6760
|
+
const [status, setStatus] = useState<string | null>(null);
|
|
6761
|
+
|
|
6762
|
+
async function submit(e: React.FormEvent) {
|
|
6763
|
+
e.preventDefault();
|
|
6764
|
+
if (!token) { setStatus("Please solve the CAPTCHA first."); return; }
|
|
6765
|
+
const res = await fetch(API + "/captcha/verify", {
|
|
6766
|
+
method: "POST",
|
|
6767
|
+
headers: { "Content-Type": "application/json" },
|
|
6768
|
+
body: JSON.stringify({ solution: token }),
|
|
6769
|
+
});
|
|
6770
|
+
setStatus(res.ok ? "Verified on the server \u2713 \u2014 safe to proceed." : "Verification failed.");
|
|
6771
|
+
}
|
|
6772
|
+
|
|
6773
|
+
return (
|
|
6774
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6775
|
+
<h1 className="text-2xl font-bold">Keyless CAPTCHA</h1>
|
|
6776
|
+
<p className="mt-1 text-muted">Proof-of-work \u2014 no Google / Cloudflare, no keys.</p>
|
|
6777
|
+
<form onSubmit={submit} className="mt-6 space-y-4">
|
|
6778
|
+
<Captcha onVerified={setToken} />
|
|
6779
|
+
<button type="submit" className="rounded-xl border border-hairline px-4 py-2">Submit</button>
|
|
6780
|
+
</form>
|
|
6781
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6782
|
+
</main>
|
|
6783
|
+
);
|
|
6784
|
+
}
|
|
6785
|
+
`;
|
|
6786
|
+
var captchaRoutesBackend = () => `import express from "express";
|
|
6787
|
+
import { createChallenge, verifySolution } from "@lacspace/captcha";
|
|
6788
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6789
|
+
import { env } from "../env.js";
|
|
6790
|
+
|
|
6791
|
+
// how this works: a keyless proof-of-work CAPTCHA. GET /captcha issues a signed
|
|
6792
|
+
// challenge; the browser solves it; POST /captcha/verify checks the solution with
|
|
6793
|
+
// only your secret \u2014 stateless, nothing stored between the two calls.
|
|
6794
|
+
const router = express.Router();
|
|
6795
|
+
const SECRET = process.env.CAPTCHA_SECRET ?? env.JWT_SECRET;
|
|
6796
|
+
|
|
6797
|
+
router.get("/", asyncHandler(async (_req, res) => {
|
|
6798
|
+
res.json(await createChallenge({ secret: SECRET }));
|
|
6799
|
+
}));
|
|
6800
|
+
|
|
6801
|
+
router.post("/verify", asyncHandler(async (req, res) => {
|
|
6802
|
+
const solution = typeof req.body?.solution === "string" ? req.body.solution : "";
|
|
6803
|
+
const result = await verifySolution(solution, { secret: SECRET });
|
|
6804
|
+
if (!result.success) throw new HttpError(400, "CAPTCHA failed: " + result.error);
|
|
6805
|
+
res.json({ success: true });
|
|
6806
|
+
}));
|
|
6807
|
+
|
|
6808
|
+
export default router;
|
|
6809
|
+
`;
|
|
6810
|
+
var pushServiceWorkerFile = () => `// Service worker \u2014 web push notifications. Generated by create-lacspace-app.
|
|
6811
|
+
// Served at /sw.js. Wakes on incoming pushes and opens the app on click.
|
|
6812
|
+
|
|
6813
|
+
self.addEventListener("install", () => self.skipWaiting());
|
|
6814
|
+
self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
|
|
6815
|
+
|
|
6816
|
+
self.addEventListener("push", (event) => {
|
|
6817
|
+
let data = {};
|
|
6818
|
+
try { data = event.data ? event.data.json() : {}; }
|
|
6819
|
+
catch (err) { data = { body: event.data ? event.data.text() : "" }; }
|
|
6820
|
+
const title = data.title || "Notification";
|
|
6821
|
+
event.waitUntil(
|
|
6822
|
+
self.registration.showNotification(title, {
|
|
6823
|
+
body: data.body,
|
|
6824
|
+
icon: data.icon,
|
|
6825
|
+
data: { url: data.url || "/" },
|
|
6826
|
+
}),
|
|
6827
|
+
);
|
|
6828
|
+
});
|
|
6829
|
+
|
|
6830
|
+
self.addEventListener("notificationclick", (event) => {
|
|
6831
|
+
event.notification.close();
|
|
6832
|
+
const target = (event.notification.data && event.notification.data.url) || "/";
|
|
6833
|
+
event.waitUntil(
|
|
6834
|
+
(async () => {
|
|
6835
|
+
const all = await self.clients.matchAll({ type: "window", includeUncontrolled: true });
|
|
6836
|
+
for (const client of all) { if (client.url === target && "focus" in client) return client.focus(); }
|
|
6837
|
+
if (self.clients.openWindow) return self.clients.openWindow(target);
|
|
6838
|
+
})(),
|
|
6839
|
+
);
|
|
6840
|
+
});
|
|
6841
|
+
`;
|
|
6842
|
+
var pushDemoPage = () => `"use client";
|
|
6843
|
+
import { useState } from "react";
|
|
6844
|
+
import { subscribeToPush } from "@lacspace/web-push/client";
|
|
6845
|
+
|
|
6846
|
+
const API = process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:4000";
|
|
6847
|
+
|
|
6848
|
+
export default function PushDemo() {
|
|
6849
|
+
const [status, setStatus] = useState("");
|
|
6850
|
+
|
|
6851
|
+
async function enable() {
|
|
6852
|
+
try {
|
|
6853
|
+
const { publicKey } = await fetch(API + "/push/vapid").then((r) => r.json());
|
|
6854
|
+
if (!publicKey) { setStatus("Set VAPID_PUBLIC / VAPID_PRIVATE in the backend .env first (npx @lacspace/web-push)."); return; }
|
|
6855
|
+
await subscribeToPush({ vapidPublicKey: publicKey, serviceWorkerUrl: "/sw.js", saveUrl: API + "/push/subscribe" });
|
|
6856
|
+
setStatus("Subscribed! Click 'Send test' to receive a push.");
|
|
6857
|
+
} catch (err) { setStatus("Failed: " + String(err)); }
|
|
6858
|
+
}
|
|
6859
|
+
|
|
6860
|
+
async function sendTest() {
|
|
6861
|
+
const r = await fetch(API + "/push/send", {
|
|
6862
|
+
method: "POST",
|
|
6863
|
+
headers: { "Content-Type": "application/json" },
|
|
6864
|
+
body: JSON.stringify({ title: "It works", body: "Your first web push \u{1F389}" }),
|
|
6865
|
+
}).then((r) => r.json());
|
|
6866
|
+
setStatus("Sent to " + r.sent + " device(s).");
|
|
6867
|
+
}
|
|
6868
|
+
|
|
6869
|
+
return (
|
|
6870
|
+
<main className="mx-auto max-w-lg px-6 py-16">
|
|
6871
|
+
<h1 className="text-2xl font-bold">Web push</h1>
|
|
6872
|
+
<p className="mt-1 text-muted">Real browser notifications \u2014 keyless VAPID, no Firebase.</p>
|
|
6873
|
+
<div className="mt-6 flex flex-wrap gap-3">
|
|
6874
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={enable}>Enable notifications</button>
|
|
6875
|
+
<button className="rounded-xl border border-hairline px-4 py-2" onClick={sendTest}>Send test</button>
|
|
6876
|
+
</div>
|
|
6877
|
+
{status && <p className="mt-3 text-sm text-muted">{status}</p>}
|
|
6878
|
+
</main>
|
|
6879
|
+
);
|
|
6880
|
+
}
|
|
6881
|
+
`;
|
|
6882
|
+
var pushSubscriptionModel = () => `import mongoose from "mongoose";
|
|
6883
|
+
import { uuidv7 } from "@lacspace/id";
|
|
6884
|
+
|
|
6885
|
+
// how this works: one document per browser subscription. endpoint is unique so a
|
|
6886
|
+
// re-subscribe upserts instead of duplicating. Store the two keys the Web Push
|
|
6887
|
+
// protocol needs (p256dh + auth).
|
|
6888
|
+
export interface PushSubDoc {
|
|
6889
|
+
_id: string;
|
|
6890
|
+
endpoint: string;
|
|
6891
|
+
p256dh: string;
|
|
6892
|
+
auth: string;
|
|
6893
|
+
createdAt: Date;
|
|
6894
|
+
}
|
|
6895
|
+
|
|
6896
|
+
const schema = new mongoose.Schema<PushSubDoc>(
|
|
6897
|
+
{
|
|
6898
|
+
_id: { type: String, default: () => uuidv7() },
|
|
6899
|
+
endpoint: { type: String, required: true, unique: true },
|
|
6900
|
+
p256dh: { type: String, required: true },
|
|
6901
|
+
auth: { type: String, required: true },
|
|
6902
|
+
},
|
|
6903
|
+
{ timestamps: { createdAt: true, updatedAt: false } },
|
|
6904
|
+
);
|
|
6905
|
+
|
|
6906
|
+
export const PushSubscriptionModel =
|
|
6907
|
+
(mongoose.models.PushSubscription as mongoose.Model<PushSubDoc>) ??
|
|
6908
|
+
mongoose.model<PushSubDoc>("PushSubscription", schema);
|
|
6909
|
+
`;
|
|
6910
|
+
var pushRoutesBackend = () => `import express from "express";
|
|
6911
|
+
import { sendNotifications, type PushSubscription } from "@lacspace/web-push";
|
|
6912
|
+
import { asyncHandler, HttpError } from "../http.js";
|
|
6913
|
+
import { PushSubscriptionModel } from "../models/push-subscription.js";
|
|
6914
|
+
|
|
6915
|
+
// how this works: /push/vapid gives the browser the public key; /push/subscribe
|
|
6916
|
+
// stores a subscription; /push/send blasts a payload to every stored subscription
|
|
6917
|
+
// and prunes any the push service reports as gone (404/410).
|
|
6918
|
+
const router = express.Router();
|
|
6919
|
+
|
|
6920
|
+
function vapid() {
|
|
6921
|
+
const publicKey = process.env.VAPID_PUBLIC;
|
|
6922
|
+
const privateKey = process.env.VAPID_PRIVATE;
|
|
6923
|
+
if (!publicKey || !privateKey) {
|
|
6924
|
+
throw new HttpError(500, "Missing VAPID keys. Generate them (npx @lacspace/web-push) and set VAPID_PUBLIC / VAPID_PRIVATE in .env.");
|
|
6925
|
+
}
|
|
6926
|
+
return { subject: process.env.VAPID_SUBJECT ?? "mailto:admin@example.com", publicKey, privateKey };
|
|
6927
|
+
}
|
|
6928
|
+
|
|
6929
|
+
router.get("/vapid", asyncHandler(async (_req, res) => {
|
|
6930
|
+
res.json({ publicKey: process.env.VAPID_PUBLIC ?? "" });
|
|
6931
|
+
}));
|
|
6932
|
+
|
|
6933
|
+
router.post("/subscribe", asyncHandler(async (req, res) => {
|
|
6934
|
+
const endpoint = typeof req.body?.endpoint === "string" ? req.body.endpoint : "";
|
|
6935
|
+
const p256dh = req.body?.keys?.p256dh;
|
|
6936
|
+
const auth = req.body?.keys?.auth;
|
|
6937
|
+
if (!endpoint || typeof p256dh !== "string" || typeof auth !== "string") {
|
|
6938
|
+
throw new HttpError(400, "Invalid subscription");
|
|
6939
|
+
}
|
|
6940
|
+
await PushSubscriptionModel.updateOne({ endpoint }, { $set: { endpoint, p256dh, auth } }, { upsert: true });
|
|
6941
|
+
res.status(201).json({ ok: true });
|
|
6942
|
+
}));
|
|
6943
|
+
|
|
6944
|
+
router.post("/send", asyncHandler(async (req, res) => {
|
|
6945
|
+
const title = typeof req.body?.title === "string" ? req.body.title : "Hello from your app";
|
|
6946
|
+
const body = typeof req.body?.body === "string" ? req.body.body : "This is a web push notification.";
|
|
6947
|
+
const docs = await PushSubscriptionModel.find();
|
|
6948
|
+
const subs: PushSubscription[] = docs.map((d) => ({ endpoint: d.endpoint, keys: { p256dh: d.p256dh, auth: d.auth } }));
|
|
6949
|
+
const payload = JSON.stringify({ title, body, url: "/" });
|
|
6950
|
+
const results = await sendNotifications(subs, payload, { vapid: vapid() });
|
|
6951
|
+
const expired = results.filter((r) => r.result?.expired);
|
|
6952
|
+
await Promise.all(expired.map((r) => PushSubscriptionModel.deleteOne({ endpoint: r.subscription.endpoint })));
|
|
6953
|
+
res.json({ sent: results.filter((r) => r.result && !r.result.expired).length, removed: expired.length });
|
|
6954
|
+
}));
|
|
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
|
+
|
|
7019
|
+
export default router;
|
|
7020
|
+
`;
|
|
6615
7021
|
var scope = (ctx) => `@${ctx.name}`;
|
|
6616
7022
|
var typesPkgJson = (ctx) => JSON.stringify({
|
|
6617
7023
|
name: `${scope(ctx)}/types`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-lacspace-app",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.12.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": {
|