@bhooai/nexus-examples 2.0.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.
Files changed (52) hide show
  1. package/examples/ai-chat/README.md +37 -0
  2. package/examples/ai-chat/apps/backend/src/routes/chat.ts +40 -0
  3. package/examples/ai-chat/apps/frontend/src/main.tsx +105 -0
  4. package/examples/blog-crud/README.md +45 -0
  5. package/examples/blog-crud/apps/backend/src/graphql/post.graph.ts +99 -0
  6. package/examples/blog-crud/apps/backend/src/models/Author.ts +10 -0
  7. package/examples/blog-crud/apps/backend/src/models/Comment.ts +10 -0
  8. package/examples/blog-crud/apps/backend/src/models/Post.ts +13 -0
  9. package/examples/blog-crud/apps/backend/src/routes/posts.ts +119 -0
  10. package/examples/blog-crud/apps/frontend/src/main.tsx +102 -0
  11. package/examples/chat/README.md +20 -0
  12. package/examples/chat/apps/backend/src/models/Message.ts +11 -0
  13. package/examples/chat/apps/backend/src/routes/chat.ts +17 -0
  14. package/examples/chat/apps/backend/src/ws/chat.room.ts +56 -0
  15. package/examples/chat/apps/frontend/src/main.tsx +97 -0
  16. package/examples/checkout/README.md +40 -0
  17. package/examples/checkout/apps/backend/src/mail/mailables/OrderConfirmationMail.ts +27 -0
  18. package/examples/checkout/apps/backend/src/mail/templates/order-confirmation.ejs +28 -0
  19. package/examples/checkout/apps/backend/src/models/Order.ts +14 -0
  20. package/examples/checkout/apps/backend/src/routes/checkout.ts +74 -0
  21. package/examples/checkout/apps/frontend/src/main.tsx +85 -0
  22. package/examples/dashboard/README.md +26 -0
  23. package/examples/dashboard/apps/backend/src/routes/metrics.ts +76 -0
  24. package/examples/dashboard/apps/frontend/src/main.tsx +100 -0
  25. package/examples/file-storage/README.md +48 -0
  26. package/examples/file-storage/apps/backend/src/routes/files.ts +86 -0
  27. package/examples/file-storage/apps/frontend/src/main.tsx +111 -0
  28. package/examples/livestream/README.md +34 -0
  29. package/examples/livestream/apps/backend/src/routes/list.ts +21 -0
  30. package/examples/livestream/apps/backend/src/ws/stream.room.ts +53 -0
  31. package/examples/livestream/apps/frontend/src/main.tsx +148 -0
  32. package/examples/multi-app/README.md +51 -0
  33. package/examples/multi-app/apps/backend-api/src/routes/users.ts +26 -0
  34. package/examples/multi-app/apps/backend-shop/src/routes/products.ts +27 -0
  35. package/examples/saas-starter/README.md +37 -0
  36. package/examples/saas-starter/apps/backend/src/events/TeamCreated.ts +11 -0
  37. package/examples/saas-starter/apps/backend/src/events/UserRegistered.ts +10 -0
  38. package/examples/saas-starter/apps/backend/src/listeners/OnTeamCreated.ts +11 -0
  39. package/examples/saas-starter/apps/backend/src/listeners/OnUserRegistered.ts +10 -0
  40. package/examples/saas-starter/apps/backend/src/middleware/auth.ts +14 -0
  41. package/examples/saas-starter/apps/backend/src/models/Membership.ts +20 -0
  42. package/examples/saas-starter/apps/backend/src/models/Team.ts +10 -0
  43. package/examples/saas-starter/apps/backend/src/models/User.ts +17 -0
  44. package/examples/saas-starter/apps/backend/src/policies/TeamPolicy.ts +35 -0
  45. package/examples/saas-starter/apps/backend/src/routes/projects.ts +52 -0
  46. package/examples/saas-starter/apps/backend/src/routes/teams.ts +112 -0
  47. package/examples/saas-starter/apps/frontend/src/main.tsx +95 -0
  48. package/examples/video-call/README.md +30 -0
  49. package/examples/video-call/apps/backend/src/ws/signaling.room.ts +47 -0
  50. package/examples/video-call/apps/frontend/src/main.tsx +116 -0
  51. package/package.json +11 -0
  52. package/src/index.ts +99 -0
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Chat WebSocket room. Auto-discovered by createNexusApp() from ws/*.room.ts.
3
+ *
4
+ * Client protocol:
5
+ * → join { roomId, username }
6
+ * ← presence { users: [...] }
7
+ * → msg { roomId, text }
8
+ * ← msg { from, text, at }
9
+ * → typing { roomId }
10
+ * ← typing { username }
11
+ */
12
+ export default {
13
+ name: 'chat',
14
+
15
+ async onJoin(socket: any, payload: { roomId: string; username: string }) {
16
+ socket.data.username = payload.username;
17
+ socket.join(`chat:${payload.roomId}`);
18
+ socket.to(`chat:${payload.roomId}`).emit('presence', {
19
+ type: 'join',
20
+ username: payload.username,
21
+ });
22
+ },
23
+
24
+ async onLeave(socket: any, payload: { roomId: string }) {
25
+ socket.to(`chat:${payload.roomId}`).emit('presence', {
26
+ type: 'leave',
27
+ username: socket.data?.username,
28
+ });
29
+ },
30
+
31
+ onMessage: {
32
+ 'msg': async (socket: any, payload: { roomId: string; text: string }, ctx: any) => {
33
+ const msg = {
34
+ from: socket.data?.username ?? 'anon',
35
+ text: payload.text,
36
+ at: new Date().toISOString(),
37
+ };
38
+ // Persist (optional; async so we don't block broadcast)
39
+ try {
40
+ const { Message } = await import('../models/Message.js');
41
+ await Message.create({
42
+ roomId: payload.roomId,
43
+ userId: socket.data?.userId ?? 'anon',
44
+ username: msg.from,
45
+ text: msg.text,
46
+ });
47
+ } catch { /* swallow */ }
48
+ ctx.server.to(`chat:${payload.roomId}`).emit('msg', msg);
49
+ },
50
+ 'typing': (socket: any, payload: { roomId: string }, ctx: any) => {
51
+ socket.to(`chat:${payload.roomId}`).emit('typing', {
52
+ username: socket.data?.username,
53
+ });
54
+ },
55
+ },
56
+ };
@@ -0,0 +1,97 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+
4
+ interface Msg { from: string; text: string; at: string }
5
+
6
+ function App() {
7
+ const [roomId, setRoomId] = useState('lobby');
8
+ const [username, setUsername] = useState(`guest-${Math.floor(Math.random() * 1000)}`);
9
+ const [messages, setMessages] = useState<Msg[]>([]);
10
+ const [draft, setDraft] = useState('');
11
+ const [presence, setPresence] = useState<string[]>([]);
12
+ const [typingUsers, setTypingUsers] = useState<string[]>([]);
13
+ const [connected, setConnected] = useState(false);
14
+ const wsRef = useRef<WebSocket | null>(null);
15
+
16
+ useEffect(() => {
17
+ const host = (import.meta as any).env?.VITE_BACKEND_HOST ?? 'localhost';
18
+ const port = (import.meta as any).env?.VITE_BACKEND_PORT ?? '4000';
19
+ const ws = new WebSocket(`ws://${host}:${port}/ws`);
20
+ wsRef.current = ws;
21
+
22
+ ws.onopen = () => {
23
+ setConnected(true);
24
+ ws.send(JSON.stringify({ type: 'join', roomId, username }));
25
+ };
26
+ ws.onclose = () => setConnected(false);
27
+ ws.onmessage = (ev) => {
28
+ try {
29
+ const data = JSON.parse(ev.data);
30
+ if (data.type === 'msg') setMessages((m) => [...m, data.payload]);
31
+ else if (data.type === 'presence') {
32
+ setPresence((p) => {
33
+ if (data.payload.type === 'join') return [...new Set([...p, data.payload.username])];
34
+ return p.filter((u) => u !== data.payload.username);
35
+ });
36
+ } else if (data.type === 'typing') {
37
+ setTypingUsers((t) => [...new Set([...t, data.payload.username])]);
38
+ setTimeout(() => setTypingUsers((t) => t.filter((u) => u !== data.payload.username)), 2000);
39
+ }
40
+ } catch { /* ignore */ }
41
+ };
42
+ return () => ws.close();
43
+ }, [roomId, username]);
44
+
45
+ // Load history on room change
46
+ useEffect(() => {
47
+ fetch(`/api/chat/rooms/${roomId}/messages`)
48
+ .then((r) => r.json())
49
+ .then((data) => setMessages(data.messages ?? []))
50
+ .catch(() => {});
51
+ }, [roomId]);
52
+
53
+ const send = () => {
54
+ if (!draft.trim() || !wsRef.current) return;
55
+ wsRef.current.send(JSON.stringify({ type: 'msg', roomId, text: draft }));
56
+ setDraft('');
57
+ };
58
+
59
+ const onType = (v: string) => {
60
+ setDraft(v);
61
+ if (wsRef.current && v.length % 3 === 0) {
62
+ wsRef.current.send(JSON.stringify({ type: 'typing', roomId }));
63
+ }
64
+ };
65
+
66
+ return (
67
+ <main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
68
+ <h1>Chat — room: {roomId}</h1>
69
+ <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
70
+ <input value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="room" />
71
+ <input value={username} onChange={(e) => setUsername(e.target.value)} placeholder="username" />
72
+ <span>{connected ? '●' : '○'}</span>
73
+ </div>
74
+
75
+ <div style={{ fontSize: '0.85rem', color: '#666', marginBottom: '0.5rem' }}>
76
+ Online: {presence.join(', ') || '—'}
77
+ {typingUsers.length > 0 && <em> · typing: {typingUsers.join(', ')}</em>}
78
+ </div>
79
+
80
+ <div style={{ border: '1px solid #ccc', borderRadius: 8, padding: '1rem', minHeight: 300, maxHeight: 400, overflow: 'auto', marginBottom: '1rem', background: '#fafafa' }}>
81
+ {messages.map((m, i) => (
82
+ <div key={i} style={{ marginBottom: '0.5rem' }}>
83
+ <strong>{m.from}</strong>: {m.text} <span style={{ color: '#999', fontSize: '0.75rem' }}>{new Date(m.at).toLocaleTimeString()}</span>
84
+ </div>
85
+ ))}
86
+ </div>
87
+
88
+ <div style={{ display: 'flex', gap: '0.5rem' }}>
89
+ <input value={draft} onChange={(e) => onType(e.target.value)} onKeyDown={(e) => e.key === 'Enter' && send()} placeholder="Type a message…" style={{ flex: 1, padding: '0.5rem' }} />
90
+ <button onClick={send}>Send</button>
91
+ </div>
92
+ </main>
93
+ );
94
+ }
95
+
96
+ const el = document.getElementById('root');
97
+ if (el) createRoot(el).render(<App />);
@@ -0,0 +1,40 @@
1
+ # Checkout example
2
+
3
+ Razorpay-style payment flow: order creation on the backend, a signed webhook
4
+ for payment confirmation, and a confirmation mailable queued on success.
5
+
6
+ ## What's here
7
+
8
+ - `apps/backend/src/routes/checkout.ts` — `POST /api/checkout/order` + `POST /api/checkout/webhook`
9
+ - `apps/backend/src/models/Order.ts` — order document (`created` -> `paid` / `failed`)
10
+ - `apps/backend/src/mail/mailables/OrderConfirmationMail.ts` — Mailable subclass
11
+ - `apps/backend/src/mail/templates/order-confirmation.ejs` — receipt template
12
+ - `apps/frontend/src/main.tsx` — cart + checkout button (mock; no real provider call)
13
+
14
+ ## Flow
15
+
16
+ 1. Frontend posts `{ email, items }` to `/api/checkout/order`.
17
+ 2. Backend totals the cart, persists an `Order`, and returns `{ orderId, amount }`.
18
+ (With a real provider this is where `razorpay.orders.create(...)` goes.)
19
+ 3. The provider later calls `/api/checkout/webhook`. The signature in
20
+ `x-webhook-signature` is verified with `NEXUS_PAYMENTS_WEBHOOK_SECRET`
21
+ (demo default: `demo-webhook-secret`), the order flips to `paid`, and an
22
+ `OrderConfirmationMail` is queued.
23
+
24
+ ### Simulate the webhook
25
+
26
+ ```bash
27
+ $body = '{"orderId":"<id>","status":"captured"}'
28
+ $sig = # HMAC-SHA256 hex of $body with the webhook secret
29
+ curl -X POST http://localhost:4000/api/checkout/webhook \
30
+ -H "content-type: application/json" -H "x-webhook-signature: $sig" -d $body
31
+ ```
32
+
33
+ ## Run
34
+
35
+ ```bash
36
+ npx nexus dev
37
+ ```
38
+
39
+ Add items to the cart and press *Pay* — the demo stops right before opening a
40
+ real payment modal.
@@ -0,0 +1,27 @@
1
+ import { Mailable } from '@bhooai/nexus-core';
2
+
3
+ export interface OrderLike {
4
+ orderId: string;
5
+ email: string;
6
+ amount: number;
7
+ currency: string;
8
+ items: Array<{ name: string; qty: number; price: number }>;
9
+ }
10
+
11
+ export class OrderConfirmationMail extends Mailable {
12
+ subject = 'Your order is confirmed';
13
+ template = 'order-confirmation';
14
+
15
+ constructor(public order: OrderLike) {
16
+ super();
17
+ }
18
+
19
+ data(): Record<string, unknown> {
20
+ return {
21
+ orderId: this.order.orderId,
22
+ total: (this.order.amount / 100).toFixed(2),
23
+ currency: this.order.currency,
24
+ items: this.order.items,
25
+ };
26
+ }
27
+ }
@@ -0,0 +1,28 @@
1
+ <!doctype html>
2
+ <html>
3
+ <body style="font-family: system-ui, sans-serif; color: #222;">
4
+ <h1>Thanks for your order!</h1>
5
+ <p>Order <strong><%= orderId %></strong> has been confirmed.</p>
6
+ <table cellpadding="6" style="border-collapse: collapse;">
7
+ <thead>
8
+ <tr><th align="left">Item</th><th align="right">Qty</th><th align="right">Price</th></tr>
9
+ </thead>
10
+ <tbody>
11
+ <% items.forEach(function (item) { %>
12
+ <tr>
13
+ <td><%= item.name %></td>
14
+ <td align="right"><%= item.qty %></td>
15
+ <td align="right"><%= (item.price / 100).toFixed(2) %></td>
16
+ </tr>
17
+ <% }); %>
18
+ </tbody>
19
+ <tfoot>
20
+ <tr>
21
+ <td colspan="2" align="right"><strong>Total</strong></td>
22
+ <td align="right"><strong><%= currency %> <%= total %></strong></td>
23
+ </tr>
24
+ </tfoot>
25
+ </table>
26
+ <p style="color: #666; font-size: 0.85rem;">This is a demo receipt — no real charge was made.</p>
27
+ </body>
28
+ </html>
@@ -0,0 +1,14 @@
1
+ import { model, Schema } from '@bhooai/nexus-data';
2
+
3
+ const orderSchema = new Schema({
4
+ orderId: { type: String, required: true, index: true },
5
+ email: { type: String, required: true },
6
+ items: { type: Array, default: () => [] },
7
+ amount: { type: Number, required: true }, // smallest currency unit (paise)
8
+ currency: { type: String, default: 'INR' },
9
+ status: { type: String, default: 'created', enum: ['created', 'paid', 'failed'] },
10
+ receipt: { type: String },
11
+ createdAt: { type: Date, default: () => new Date(), index: true },
12
+ });
13
+
14
+ export const Order = model('orders', orderSchema);
@@ -0,0 +1,74 @@
1
+ import { createHmac, randomUUID } from 'node:crypto';
2
+ import { defineRoutes } from '@bhooai/nexus-core';
3
+ import { Order } from '../models/Order.js';
4
+ import { OrderConfirmationMail } from '../mail/mailables/OrderConfirmationMail.js';
5
+
6
+ interface CartItem { name: string; qty: number; price: number }
7
+ interface OrderDoc {
8
+ orderId: string;
9
+ email: string;
10
+ amount: number;
11
+ currency: string;
12
+ items: CartItem[];
13
+ }
14
+
15
+ const WEBHOOK_SECRET = process.env.NEXUS_PAYMENTS_WEBHOOK_SECRET ?? 'demo-webhook-secret';
16
+
17
+ export default defineRoutes([
18
+ {
19
+ // Create a fake provider order. With a real provider you'd call
20
+ // razorpay.orders.create({ amount, currency, receipt }) here instead.
21
+ method: 'POST',
22
+ path: '/api/checkout/order',
23
+ handler: async (ctx) => {
24
+ const body = (ctx.body ?? {}) as { email?: string; items?: CartItem[] };
25
+ if (!body.email || !Array.isArray(body.items) || body.items.length === 0) {
26
+ return ctx.json({ error: 'email and items are required' }, 422);
27
+ }
28
+ const amount = body.items.reduce((sum, i) => sum + i.price * i.qty, 0);
29
+ const orderId = `order_${randomUUID().slice(0, 12)}`;
30
+ const [order] = await Order.create({
31
+ orderId,
32
+ email: body.email,
33
+ items: body.items,
34
+ amount,
35
+ receipt: `rcpt_${Date.now()}`,
36
+ });
37
+ const doc = order as unknown as OrderDoc;
38
+ ctx.json({ orderId: doc.orderId, amount, currency: 'INR' }, 201);
39
+ },
40
+ },
41
+ {
42
+ // Provider webhook: verify the signature, mark paid, send the receipt.
43
+ method: 'POST',
44
+ path: '/api/checkout/webhook',
45
+ handler: async (ctx) => {
46
+ const body = (ctx.body ?? {}) as { orderId?: string; event?: string; status?: string };
47
+ const signature = String(ctx.headers['x-webhook-signature'] ?? '');
48
+ const expected = createHmac('sha256', WEBHOOK_SECRET)
49
+ .update(JSON.stringify(ctx.body ?? {}))
50
+ .digest('hex');
51
+ if (signature !== expected) {
52
+ return ctx.json({ error: 'invalid signature' }, 401);
53
+ }
54
+ if (!body.orderId) return ctx.json({ error: 'orderId required' }, 422);
55
+
56
+ const status = body.status === 'captured' ? 'paid' : 'failed';
57
+ const order = await Order.findOneAndUpdate(
58
+ { orderId: body.orderId },
59
+ { $set: { status } },
60
+ );
61
+ if (order && status === 'paid') {
62
+ const doc = order as unknown as OrderDoc;
63
+ await OrderConfirmationMail.to(doc.email, {
64
+ orderId: doc.orderId,
65
+ email: doc.email,
66
+ amount: doc.amount,
67
+ currency: doc.currency,
68
+ items: doc.items,
69
+ }).queue().catch((err: unknown) => console.error('[checkout] mail failed:', err));
70
+ }
71
+ ctx.json({ received: true });
72
+ },
73
+ },
74
+ ]);
@@ -0,0 +1,85 @@
1
+ import React, { useMemo, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+
4
+ interface Item { name: string; qty: number; price: number }
5
+
6
+ const CATALOG: Item[] = [
7
+ { name: 'Nexus mug', qty: 1, price: 49900 },
8
+ { name: 'Nexus tee', qty: 1, price: 89900 },
9
+ { name: 'Sticker pack', qty: 1, price: 14900 },
10
+ ];
11
+
12
+ function App() {
13
+ const [email, setEmail] = useState('you@example.com');
14
+ const [cart, setCart] = useState<Item[]>([]);
15
+ const [status, setStatus] = useState('');
16
+ const [orderId, setOrderId] = useState('');
17
+
18
+ const total = useMemo(() => cart.reduce((s, i) => s + i.price * i.qty, 0), [cart]);
19
+
20
+ const add = (item: Item) => {
21
+ setCart((c) => {
22
+ const found = c.find((i) => i.name === item.name);
23
+ if (found) return c.map((i) => (i.name === item.name ? { ...i, qty: i.qty + 1 } : i));
24
+ return [...c, { ...item }];
25
+ });
26
+ };
27
+
28
+ const checkout = async () => {
29
+ setStatus('creating order...');
30
+ try {
31
+ const res = await fetch('/api/checkout/order', {
32
+ method: 'POST',
33
+ headers: { 'content-type': 'application/json' },
34
+ body: JSON.stringify({ email, items: cart }),
35
+ });
36
+ const data = await res.json();
37
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
38
+ setOrderId(data.orderId);
39
+ // Real integration would open the Razorpay modal here with the orderId.
40
+ // Demo just pretends the payment succeeded.
41
+ setStatus(`order ${data.orderId} created — payment modal would open here (mocked as paid)`);
42
+ setCart([]);
43
+ } catch (err) {
44
+ setStatus(`error: ${(err as Error).message}`);
45
+ }
46
+ };
47
+
48
+ return (
49
+ <main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
50
+ <h1>Checkout demo</h1>
51
+
52
+ <h2 style={{ fontSize: '1rem' }}>Catalog</h2>
53
+ <div style={{ display: 'flex', gap: '0.75rem', flexWrap: 'wrap', marginBottom: '1.5rem' }}>
54
+ {CATALOG.map((item) => (
55
+ <div key={item.name} style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.75rem 1rem' }}>
56
+ <strong>{item.name}</strong>
57
+ <div style={{ color: '#666', fontSize: '0.85rem' }}>INR {(item.price / 100).toFixed(2)}</div>
58
+ <button onClick={() => add(item)} style={{ marginTop: '0.5rem' }}>Add to cart</button>
59
+ </div>
60
+ ))}
61
+ </div>
62
+
63
+ <h2 style={{ fontSize: '1rem' }}>Cart ({cart.reduce((s, i) => s + i.qty, 0)})</h2>
64
+ {cart.length === 0 && <p style={{ color: '#666' }}>Cart is empty.</p>}
65
+ <ul>
66
+ {cart.map((i) => (
67
+ <li key={i.name}>{i.qty} x {i.name} — INR {((i.price * i.qty) / 100).toFixed(2)}</li>
68
+ ))}
69
+ </ul>
70
+
71
+ <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', marginBottom: '1rem' }}>
72
+ <input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" style={{ flex: 1, padding: '0.5rem' }} />
73
+ <button onClick={checkout} disabled={cart.length === 0}>
74
+ Pay INR {(total / 100).toFixed(2)}
75
+ </button>
76
+ </div>
77
+
78
+ {status && <p style={{ color: '#444' }}>{status}</p>}
79
+ {orderId && <p style={{ color: '#666', fontSize: '0.85rem' }}>Last order: {orderId} — webhook flips it to paid and queues a confirmation email.</p>}
80
+ </main>
81
+ );
82
+ }
83
+
84
+ const el = document.getElementById('root');
85
+ if (el) createRoot(el).render(<App />);
@@ -0,0 +1,26 @@
1
+ # Dashboard example
2
+
3
+ Custom metrics page with zero chart dependencies: ASCII bars, monospace tables,
4
+ and CSS divs. The backend records requests to its own demo endpoints in memory
5
+ and serves aggregates as JSON.
6
+
7
+ ## What's here
8
+
9
+ - `apps/backend/src/routes/metrics.ts` — `GET /api/metrics` (requests today, avg latency, top paths, hourly buckets) plus `/api/ping` and `/api/slow` to generate traffic
10
+ - `apps/frontend/src/main.tsx` — KPI cards, monospace bar chart for top paths, CSS-div hourly histogram
11
+
12
+ ## Notable
13
+
14
+ - Daily counters roll over at midnight (`rollDay`).
15
+ - `/api/slow` sleeps 120-320ms so you can watch the avg-latency number move.
16
+ - The page polls every 5 seconds — no WS needed for a dashboard.
17
+
18
+ ## Run
19
+
20
+ ```bash
21
+ npx nexus dev
22
+ ```
23
+
24
+ Open http://localhost:3000 and press the *Hit ...* buttons a few times to see
25
+ the charts fill in. In a real app you'd source these numbers from
26
+ `nexus-telemetry` instead of in-process counters.
@@ -0,0 +1,76 @@
1
+ import { defineRoutes } from '@bhooai/nexus-core';
2
+
3
+ /**
4
+ * In-memory request metrics demo. A real deployment would aggregate from
5
+ * nexus-telemetry / cluster logs; here we track requests to this server's
6
+ * own API to show the shape of the data.
7
+ */
8
+
9
+ interface PathStats { count: number; totalMs: number }
10
+ const stats = new Map<string, PathStats>();
11
+ let todayRequests = 0;
12
+ let dayStamp = new Date().toDateString();
13
+
14
+ function rollDay() {
15
+ const now = new Date().toDateString();
16
+ if (now !== dayStamp) {
17
+ dayStamp = now;
18
+ todayRequests = 0;
19
+ stats.clear();
20
+ }
21
+ }
22
+
23
+ /** Cheap middleware-style recorder — call from route handlers you want to count. */
24
+ export function recordRequest(path: string, ms: number) {
25
+ rollDay();
26
+ todayRequests += 1;
27
+ const s = stats.get(path) ?? { count: 0, totalMs: 0 };
28
+ s.count += 1;
29
+ s.totalMs += ms;
30
+ stats.set(path, s);
31
+ }
32
+
33
+ export default defineRoutes([
34
+ {
35
+ method: 'GET',
36
+ path: '/api/metrics',
37
+ handler: async (ctx) => {
38
+ rollDay();
39
+ const topPaths = [...stats.entries()]
40
+ .map(([path, s]) => ({ path, count: s.count, avgMs: Math.round(s.totalMs / s.count) }))
41
+ .sort((a, b) => b.count - a.count)
42
+ .slice(0, 8);
43
+ const totalMs = [...stats.values()].reduce((n, s) => n + s.totalMs, 0);
44
+ ctx.json({
45
+ date: dayStamp,
46
+ requestsToday: todayRequests,
47
+ avgLatencyMs: todayRequests > 0 ? Math.round(totalMs / todayRequests) : 0,
48
+ topPaths,
49
+ // Sparkline-ish fake hourly buckets so the frontend has a series to draw.
50
+ hourly: Array.from({ length: 24 }, (_, h) =>
51
+ h <= new Date().getHours() ? Math.max(0, Math.round(todayRequests / (new Date().getHours() + 1) + (h % 3) * 2)) : 0),
52
+ });
53
+ },
54
+ },
55
+ {
56
+ // A couple of endpoints to generate traffic worth charting.
57
+ method: 'GET',
58
+ path: '/api/ping',
59
+ handler: async (ctx) => {
60
+ const start = performance.now();
61
+ await new Promise((r) => setTimeout(r, Math.floor(Math.random() * 40)));
62
+ recordRequest('/api/ping', performance.now() - start);
63
+ ctx.json({ pong: true });
64
+ },
65
+ },
66
+ {
67
+ method: 'GET',
68
+ path: '/api/slow',
69
+ handler: async (ctx) => {
70
+ const start = performance.now();
71
+ await new Promise((r) => setTimeout(r, 120 + Math.floor(Math.random() * 200)));
72
+ recordRequest('/api/slow', performance.now() - start);
73
+ ctx.json({ ok: true });
74
+ },
75
+ },
76
+ ]);
@@ -0,0 +1,100 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+
4
+ interface TopPath { path: string; count: number; avgMs: number }
5
+ interface Metrics {
6
+ date: string;
7
+ requestsToday: number;
8
+ avgLatencyMs: number;
9
+ topPaths: TopPath[];
10
+ hourly: number[];
11
+ }
12
+
13
+ function Bar({ value, max, width = 24 }: { value: number; max: number; width?: number }) {
14
+ const filled = max > 0 ? Math.round((value / max) * width) : 0;
15
+ return <code style={{ fontFamily: 'ui-monospace, monospace', color: '#0a0', whiteSpace: 'pre' }}>
16
+ {'#'.repeat(filled)}{'.'.repeat(Math.max(0, width - filled))}
17
+ </code>;
18
+ }
19
+
20
+ function App() {
21
+ const [metrics, setMetrics] = useState<Metrics | null>(null);
22
+ const [error, setError] = useState('');
23
+
24
+ const load = async () => {
25
+ try {
26
+ const res = await fetch('/api/metrics');
27
+ if (!res.ok) throw new Error(`HTTP ${res.status}`);
28
+ setMetrics(await res.json());
29
+ } catch (err) { setError((err as Error).message); }
30
+ };
31
+ useEffect(() => {
32
+ load();
33
+ const t = setInterval(load, 5000);
34
+ return () => clearInterval(t);
35
+ }, []);
36
+
37
+ const hit = (path: string) => fetch(path).catch(() => {});
38
+
39
+ if (error) return <main style={{ fontFamily: 'system-ui', margin: '2rem' }}><p style={{ color: '#c00' }}>{error}</p></main>;
40
+ if (!metrics) return <main style={{ fontFamily: 'system-ui', margin: '2rem' }}>Loading metrics...</main>;
41
+
42
+ const maxPath = Math.max(1, ...metrics.topPaths.map((p) => p.count));
43
+ const maxHour = Math.max(1, ...metrics.hourly);
44
+
45
+ return (
46
+ <main style={{ fontFamily: 'system-ui', maxWidth: 760, margin: '2rem auto', padding: '0 1rem' }}>
47
+ <h1>Dashboard — {metrics.date}</h1>
48
+
49
+ <div style={{ display: 'flex', gap: '1rem', marginBottom: '1.5rem' }}>
50
+ <div style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.75rem 1.25rem' }}>
51
+ <div style={{ fontSize: '1.75rem', fontWeight: 700 }}>{metrics.requestsToday}</div>
52
+ <div style={{ color: '#666', fontSize: '0.85rem' }}>requests today</div>
53
+ </div>
54
+ <div style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.75rem 1.25rem' }}>
55
+ <div style={{ fontSize: '1.75rem', fontWeight: 700 }}>{metrics.avgLatencyMs}ms</div>
56
+ <div style={{ color: '#666', fontSize: '0.85rem' }}>avg latency</div>
57
+ </div>
58
+ <div style={{ alignSelf: 'center', display: 'flex', gap: '0.5rem' }}>
59
+ <button onClick={() => hit('/api/ping')}>Hit /api/ping</button>
60
+ <button onClick={() => hit('/api/slow')}>Hit /api/slow</button>
61
+ </div>
62
+ </div>
63
+
64
+ <h2 style={{ fontSize: '1rem' }}>Top paths</h2>
65
+ {metrics.topPaths.length === 0 && <p style={{ color: '#666' }}>No traffic yet. Hit an endpoint above.</p>}
66
+ <table cellPadding={4} style={{ borderCollapse: 'collapse', fontFamily: 'ui-monospace, monospace', fontSize: '0.85rem' }}>
67
+ <tbody>
68
+ {metrics.topPaths.map((p) => (
69
+ <tr key={p.path}>
70
+ <td style={{ paddingRight: 12 }}>{p.path}</td>
71
+ <td style={{ paddingRight: 12, textAlign: 'right' }}>{p.count}</td>
72
+ <td style={{ paddingRight: 12, textAlign: 'right', color: '#666' }}>{p.avgMs}ms</td>
73
+ <td><Bar value={p.count} max={maxPath} /></td>
74
+ </tr>
75
+ ))}
76
+ </tbody>
77
+ </table>
78
+
79
+ <h2 style={{ fontSize: '1rem', marginTop: '1.5rem' }}>Hourly requests</h2>
80
+ <div style={{ display: 'flex', alignItems: 'flex-end', gap: 2, height: 80 }}>
81
+ {metrics.hourly.map((v, h) => (
82
+ <div
83
+ key={h}
84
+ title={`${h}:00 — ${v}`}
85
+ style={{
86
+ width: 24,
87
+ height: Math.max(2, (v / maxHour) * 80),
88
+ background: h === new Date().getHours() ? '#06c' : '#9ec5fe',
89
+ borderRadius: '2px 2px 0 0',
90
+ }}
91
+ />
92
+ ))}
93
+ </div>
94
+ <div style={{ fontSize: '0.7rem', color: '#999' }}>0h {'-'.repeat(30)} 23h (auto-refreshes every 5s)</div>
95
+ </main>
96
+ );
97
+ }
98
+
99
+ const el = document.getElementById('root');
100
+ if (el) createRoot(el).render(<App />);