@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,48 @@
1
+ # File storage example
2
+
3
+ Uploads to two disks: a public `uploads` disk (plain URLs) and a `private`
4
+ disk (signed URLs only). All through `storage.disk(...)` from
5
+ `@bhooai/nexus-core`.
6
+
7
+ ## What's here
8
+
9
+ - `apps/backend/src/routes/files.ts`
10
+ - `POST /api/files` — accept multipart upload onto the `uploads` disk
11
+ - `POST /api/files/private` — accept multipart upload onto the `private` disk
12
+ - `GET /api/files/:id/signed` — mint an HMAC-signed URL (5 min TTL) for any file
13
+ - `GET /api/files`, `DELETE /api/files/:id` — list + delete
14
+ - `apps/frontend/src/main.tsx` — file input, list, inline preview for public images
15
+
16
+ ## Multipart parsing
17
+
18
+ The Nexus body parser collects `multipart/form-data` file parts into
19
+ `ctx.state.files`; `uploadedFiles(ctx)` is the typed accessor. No multer, no
20
+ busboy dependency.
21
+
22
+ ## Switching to S3
23
+
24
+ Disks are driver-based. Point a disk at S3 via env vars (the local driver is
25
+ the default):
26
+
27
+ ```env
28
+ NEXUS_STORAGE_DEFAULT=uploads
29
+ NEXUS_STORAGE_DISKS_UPLOADS_DRIVER=s3
30
+ NEXUS_STORAGE_DISKS_UPLOADS_BUCKET=my-bucket
31
+ NEXUS_STORAGE_DISKS_UPLOADS_REGION=ap-south-1
32
+ # optional for S3-compatible (MinIO, R2):
33
+ NEXUS_STORAGE_DISKS_UPLOADS_ENDPOINT=https://...
34
+ NEXUS_STORAGE_DISKS_UPLOADS_CDN_URL=https://cdn.example.com
35
+ ```
36
+
37
+ `signedUrl()` automatically switches to S3 presigned URLs
38
+ (`@aws-sdk/client-s3` and `@aws-sdk/s3-request-presigner` are optional peer
39
+ deps — install them when you enable the S3 driver).
40
+
41
+ ## Run
42
+
43
+ ```bash
44
+ npx nexus dev
45
+ ```
46
+
47
+ Upload a public image and a private file, then mint a signed URL for the
48
+ private one.
@@ -0,0 +1,86 @@
1
+ import { defineRoutes, storage, uploadedFiles } from '@bhooai/nexus-core';
2
+ import { ValidationError, NotFoundError } from '@bhooai/nexus-core';
3
+
4
+ /** Kept in-memory so the demo is self-contained; back with a model in real apps. */
5
+ export interface FileEntry {
6
+ id: string;
7
+ disk: 'uploads' | 'private';
8
+ path: string;
9
+ url: string;
10
+ originalName: string;
11
+ mime: string;
12
+ size: number;
13
+ }
14
+ export const files = new Map<string, FileEntry>();
15
+
16
+ export default defineRoutes([
17
+ {
18
+ method: 'GET',
19
+ path: '/api/files',
20
+ handler: async (ctx) => {
21
+ ctx.json({ files: [...files.values()] });
22
+ },
23
+ },
24
+ {
25
+ // Public upload — goes to the 'uploads' disk and gets a plain public URL.
26
+ method: 'POST',
27
+ path: '/api/files',
28
+ handler: async (ctx) => {
29
+ const [file] = uploadedFiles(ctx);
30
+ if (!file) throw new ValidationError('a file is required');
31
+ const stored = await storage.disk('uploads').put('media', {
32
+ buffer: file.data,
33
+ originalName: file.filename,
34
+ mime: file.contentType,
35
+ });
36
+ const entry: FileEntry = {
37
+ id: stored.id, disk: 'uploads', path: stored.path, url: stored.url,
38
+ originalName: stored.originalName, mime: stored.mime, size: stored.size,
39
+ };
40
+ files.set(entry.id, entry);
41
+ ctx.json({ file: entry }, 201);
42
+ },
43
+ },
44
+ {
45
+ // Private upload — no public URL; access goes through signed URLs.
46
+ method: 'POST',
47
+ path: '/api/files/private',
48
+ handler: async (ctx) => {
49
+ const [file] = uploadedFiles(ctx);
50
+ if (!file) throw new ValidationError('a file is required');
51
+ const stored = await storage.disk('private').put('secure', {
52
+ buffer: file.data,
53
+ originalName: file.filename,
54
+ mime: file.contentType,
55
+ });
56
+ const entry: FileEntry = {
57
+ id: stored.id, disk: 'private', path: stored.path, url: '',
58
+ originalName: stored.originalName, mime: stored.mime, size: stored.size,
59
+ };
60
+ files.set(entry.id, entry);
61
+ ctx.json({ file: entry }, 201);
62
+ },
63
+ },
64
+ {
65
+ // Mint a signed URL (default TTL 5 minutes) for a file on any disk.
66
+ method: 'GET',
67
+ path: '/api/files/:id/signed',
68
+ handler: async (ctx) => {
69
+ const entry = files.get(ctx.params.id);
70
+ if (!entry) throw new NotFoundError('file not found');
71
+ const url = await storage.disk(entry.disk).signedUrl(entry.path, { ttl: 300 });
72
+ ctx.json({ url, expiresIn: 300 });
73
+ },
74
+ },
75
+ {
76
+ method: 'DELETE',
77
+ path: '/api/files/:id',
78
+ handler: async (ctx) => {
79
+ const entry = files.get(ctx.params.id);
80
+ if (!entry) throw new NotFoundError('file not found');
81
+ await storage.disk(entry.disk).delete(entry.path);
82
+ files.delete(entry.id);
83
+ ctx.json({ ok: true });
84
+ },
85
+ },
86
+ ]);
@@ -0,0 +1,111 @@
1
+ import React, { useEffect, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+
4
+ interface FileEntry {
5
+ id: string;
6
+ disk: 'uploads' | 'private';
7
+ path: string;
8
+ url: string;
9
+ originalName: string;
10
+ mime: string;
11
+ size: number;
12
+ }
13
+
14
+ const fmtSize = (n: number) => (n > 1024 * 1024 ? `${(n / 1024 / 1024).toFixed(1)} MB` : `${Math.ceil(n / 1024)} KB`);
15
+
16
+ function App() {
17
+ const [files, setFiles] = useState<FileEntry[]>([]);
18
+ const [pending, setPending] = useState(false);
19
+ const [error, setError] = useState('');
20
+ const [signed, setSigned] = useState<Record<string, string>>({});
21
+ const [previews, setPreviews] = useState<Record<string, string>>({});
22
+
23
+ const load = async () => {
24
+ try {
25
+ const res = await fetch('/api/files');
26
+ setFiles((await res.json()).files ?? []);
27
+ } catch { setError('backend unreachable'); }
28
+ };
29
+ useEffect(() => { load(); }, []);
30
+
31
+ const upload = async (input: HTMLInputElement, priv: boolean) => {
32
+ const file = input.files?.[0];
33
+ if (!file) return;
34
+ setPending(true);
35
+ setError('');
36
+ try {
37
+ const fd = new FormData();
38
+ fd.append('file', file);
39
+ const res = await fetch(priv ? '/api/files/private' : '/api/files', { method: 'POST', body: fd });
40
+ const data = await res.json();
41
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
42
+ if (file.type.startsWith('image/') && !priv) {
43
+ setPreviews((p) => ({ ...p, [data.file.id]: URL.createObjectURL(file) }));
44
+ }
45
+ await load();
46
+ } catch (err) {
47
+ setError((err as Error).message);
48
+ } finally {
49
+ setPending(false);
50
+ input.value = '';
51
+ }
52
+ };
53
+
54
+ const signUrl = async (id: string) => {
55
+ try {
56
+ const res = await fetch(`/api/files/${id}/signed`);
57
+ const data = await res.json();
58
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
59
+ setSigned((s) => ({ ...s, [id]: data.url }));
60
+ } catch (err) { setError((err as Error).message); }
61
+ };
62
+
63
+ const remove = async (id: string) => {
64
+ await fetch(`/api/files/${id}`, { method: 'DELETE' }).catch(() => {});
65
+ setSigned((s) => { const c = { ...s }; delete c[id]; return c; });
66
+ load();
67
+ };
68
+
69
+ return (
70
+ <main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
71
+ <h1>File storage</h1>
72
+
73
+ <div style={{ display: 'flex', gap: '1rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
74
+ <label style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.5rem 1rem', cursor: 'pointer' }}>
75
+ Upload public
76
+ <input type="file" hidden disabled={pending} onChange={(e) => upload(e.currentTarget, false)} />
77
+ </label>
78
+ <label style={{ border: '1px solid #ccc', borderRadius: 8, padding: '0.5rem 1rem', cursor: 'pointer' }}>
79
+ Upload private
80
+ <input type="file" hidden disabled={pending} onChange={(e) => upload(e.currentTarget, true)} />
81
+ </label>
82
+ {pending && <span style={{ color: '#666' }}>uploading...</span>}
83
+ </div>
84
+ {error && <p style={{ color: '#c00' }}>{error}</p>}
85
+
86
+ {files.length === 0 && <p style={{ color: '#666' }}>No files yet.</p>}
87
+ {files.map((f) => (
88
+ <div key={f.id} style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', border: '1px solid #e4e4e4', borderRadius: 8, padding: '0.5rem 0.75rem', marginBottom: '0.5rem' }}>
89
+ {previews[f.id] && <img src={previews[f.id]} alt={f.originalName} style={{ width: 48, height: 48, objectFit: 'cover', borderRadius: 4 }} />}
90
+ <div style={{ flex: 1, minWidth: 0 }}>
91
+ <div style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
92
+ <strong>{f.originalName}</strong>{' '}
93
+ <span style={{ color: '#666', fontSize: '0.8rem' }}>{fmtSize(f.size)} — {f.disk}</span>
94
+ </div>
95
+ {f.disk === 'uploads' && f.url && (
96
+ <a href={f.url} target="_blank" rel="noreferrer" style={{ fontSize: '0.8rem' }}>{f.url}</a>
97
+ )}
98
+ {signed[f.id] && (
99
+ <div style={{ fontSize: '0.75rem', color: '#060', wordBreak: 'break-all' }}>signed: {signed[f.id]}</div>
100
+ )}
101
+ </div>
102
+ <button onClick={() => signUrl(f.id)}>Signed URL</button>
103
+ <button onClick={() => remove(f.id)}>Delete</button>
104
+ </div>
105
+ ))}
106
+ </main>
107
+ );
108
+ }
109
+
110
+ const el = document.getElementById('root');
111
+ if (el) createRoot(el).render(<App />);
@@ -0,0 +1,34 @@
1
+ # Livestream example
2
+
3
+ One broadcaster, many viewers. Simplified transport: instead of mediasoup, the
4
+ broadcaster draws camera frames to a canvas and ships JPEG data URLs over the
5
+ WS room; the server fans each frame out to viewers.
6
+
7
+ ## What's here
8
+
9
+ - `apps/backend/src/ws/stream.room.ts` — `broadcaster` / `viewer` roles, frame fan-out, stream registry
10
+ - `apps/backend/src/routes/list.ts` — `GET /api/streams` lists active streams
11
+ - `apps/frontend/src/main.tsx` — two tabs: **Broadcast** (camera + go live) and **Watch** (list + view)
12
+
13
+ ## Flow
14
+
15
+ 1. Broadcaster opens the Broadcast tab, names the stream, presses *Go live*.
16
+ Camera frames are captured at ~5 fps, JPEG-encoded, and sent as `frame`
17
+ messages.
18
+ 2. A viewer opens the Watch tab and sees the stream in `GET /api/streams`.
19
+ 3. Clicking *Watch* joins the WS room as a viewer; incoming `frame` messages
20
+ are drawn into an `<img>`.
21
+ 4. When the broadcaster stops (or disconnects), a `stopped` message closes
22
+ every viewer panel.
23
+
24
+ For production you'd swap the canvas-over-WS fan-out for a mediasoup SFU
25
+ (`config.webrtc` is already in the Nexus config schema) and keep this room for
26
+ chat/roster side-channel traffic.
27
+
28
+ ## Run
29
+
30
+ ```bash
31
+ npx nexus dev
32
+ ```
33
+
34
+ Open two browser windows: Broadcast in one, Watch in the other.
@@ -0,0 +1,21 @@
1
+ import { defineRoutes } from '@bhooai/nexus-core';
2
+ import { listStreams } from '../ws/stream.room.js';
3
+
4
+ export default defineRoutes([
5
+ {
6
+ method: 'GET',
7
+ path: '/api/streams',
8
+ handler: async (ctx) => {
9
+ ctx.json({ streams: listStreams() });
10
+ },
11
+ },
12
+ {
13
+ method: 'GET',
14
+ path: '/api/streams/:streamId',
15
+ handler: async (ctx) => {
16
+ const stream = listStreams().find((s) => s.id === ctx.params.streamId);
17
+ if (!stream) return ctx.json({ error: 'stream not found' }, 404);
18
+ ctx.json({ stream });
19
+ },
20
+ },
21
+ ]);
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Livestream room. One broadcaster captures frames; the server fans them out
3
+ * to every viewer of the stream. Simplified: frames are JPEG data URLs sent
4
+ * over WS — no mediasoup wiring in this example.
5
+ *
6
+ * Client protocol:
7
+ * -> start { streamId, title } (become the broadcaster of streamId)
8
+ * -> watch { streamId } (join as a viewer)
9
+ * <- started { streamId, title } (broadcast to everyone; used by stream list)
10
+ * -> frame { streamId, data } (broadcaster -> server)
11
+ * <- frame { data } (server -> viewers)
12
+ * <- stopped { streamId } (when the broadcaster leaves)
13
+ */
14
+ const streams = new Map<string, { title: string; broadcaster: string }>();
15
+
16
+ export function listStreams() {
17
+ return [...streams.entries()].map(([id, s]) => ({ id, title: s.title }));
18
+ }
19
+
20
+ export default {
21
+ name: 'stream',
22
+
23
+ async onJoin(socket: any, payload: { streamId: string; role?: string }) {
24
+ socket.data.streamId = payload.streamId;
25
+ socket.data.role = payload.role === 'broadcaster' ? 'broadcaster' : 'viewer';
26
+ socket.join(`stream:${payload.streamId}`);
27
+ },
28
+
29
+ async onLeave(socket: any, payload: { streamId: string }) {
30
+ if (socket.data?.role === 'broadcaster') {
31
+ streams.delete(payload.streamId);
32
+ socket.to(`stream:${payload.streamId}`).emit('stopped', { streamId: payload.streamId });
33
+ }
34
+ },
35
+
36
+ onMessage: {
37
+ 'start': async (socket: any, payload: { streamId: string; title: string }, ctx: any) => {
38
+ streams.set(payload.streamId, { title: payload.title ?? 'Untitled', broadcaster: socket.id });
39
+ socket.data.role = 'broadcaster';
40
+ ctx.server.emit('started', { streamId: payload.streamId, title: payload.title });
41
+ },
42
+ 'frame': (socket: any, payload: { streamId: string; data: string }, _ctx: any) => {
43
+ if (socket.data?.role !== 'broadcaster') return;
44
+ // Fan the frame out to viewers only (not back to the broadcaster).
45
+ socket.to(`stream:${payload.streamId}`).emit('frame', { data: payload.data });
46
+ },
47
+ 'stop': (socket: any, payload: { streamId: string }, _ctx: any) => {
48
+ if (socket.data?.role !== 'broadcaster') return;
49
+ streams.delete(payload.streamId);
50
+ socket.to(`stream:${payload.streamId}`).emit('stopped', { streamId: payload.streamId });
51
+ },
52
+ },
53
+ };
@@ -0,0 +1,148 @@
1
+ import React, { useEffect, useRef, useState } from 'react';
2
+ import { createRoot } from 'react-dom/client';
3
+
4
+ interface StreamInfo { id: string; title: string }
5
+
6
+ function wsUrl(): string {
7
+ const host = (import.meta as any).env?.VITE_BACKEND_HOST ?? 'localhost';
8
+ const port = (import.meta as any).env?.VITE_BACKEND_PORT ?? '4000';
9
+ return `ws://${host}:${port}/ws`;
10
+ }
11
+
12
+ function Broadcast() {
13
+ const [streamId, setStreamId] = useState('my-stream');
14
+ const [title, setTitle] = useState('My live stream');
15
+ const [live, setLive] = useState(false);
16
+ const videoRef = useRef<HTMLVideoElement>(null);
17
+ const wsRef = useRef<WebSocket | null>(null);
18
+ const timerRef = useRef<number | null>(null);
19
+
20
+ const start = async () => {
21
+ const media = await navigator.mediaDevices.getUserMedia({ video: true });
22
+ if (videoRef.current) videoRef.current.srcObject = media;
23
+
24
+ const ws = new WebSocket(wsUrl());
25
+ wsRef.current = ws;
26
+ ws.onopen = () => {
27
+ ws.send(JSON.stringify({ type: 'join', streamId, role: 'broadcaster' }));
28
+ ws.send(JSON.stringify({ type: 'start', streamId, title }));
29
+ setLive(true);
30
+
31
+ // Draw camera frames to a canvas and ship JPEG data URLs over WS (~5 fps).
32
+ const canvas = document.createElement('canvas');
33
+ canvas.width = 320; canvas.height = 240;
34
+ const g = canvas.getContext('2d')!;
35
+ timerRef.current = window.setInterval(() => {
36
+ const v = videoRef.current;
37
+ if (!v || ws.readyState !== WebSocket.OPEN) return;
38
+ g.drawImage(v, 0, 0, canvas.width, canvas.height);
39
+ ws.send(JSON.stringify({ type: 'frame', streamId, data: canvas.toDataURL('image/jpeg', 0.6) }));
40
+ }, 200);
41
+ };
42
+ };
43
+
44
+ const stop = () => {
45
+ if (timerRef.current) window.clearInterval(timerRef.current);
46
+ wsRef.current?.send(JSON.stringify({ type: 'stop', streamId }));
47
+ wsRef.current?.close();
48
+ wsRef.current = null;
49
+ (videoRef.current?.srcObject as MediaStream | null)?.getTracks().forEach((t) => t.stop());
50
+ setLive(false);
51
+ };
52
+
53
+ useEffect(() => () => stop(), []); // eslint-disable-line react-hooks/exhaustive-deps
54
+
55
+ return (
56
+ <div>
57
+ <div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', flexWrap: 'wrap' }}>
58
+ <input value={streamId} onChange={(e) => setStreamId(e.target.value)} placeholder="stream id" disabled={live} />
59
+ <input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="title" disabled={live} />
60
+ {!live ? <button onClick={start}>Go live</button> : <button onClick={stop}>Stop</button>}
61
+ </div>
62
+ <video ref={videoRef} autoPlay muted playsInline style={{ width: 480, maxWidth: '100%', background: '#000', borderRadius: 8 }} />
63
+ {live && <p style={{ color: '#c00', fontWeight: 600 }}>LIVE — {streamId}</p>}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ function Watch() {
69
+ const [streams, setStreams] = useState<StreamInfo[]>([]);
70
+ const [watching, setWatching] = useState<string | null>(null);
71
+ const imgRef = useRef<HTMLImageElement>(null);
72
+ const wsRef = useRef<WebSocket | null>(null);
73
+
74
+ const refresh = () => {
75
+ fetch('/api/streams').then((r) => r.json()).then((d) => setStreams(d.streams ?? [])).catch(() => {});
76
+ };
77
+ useEffect(refresh, []);
78
+
79
+ const watch = (id: string) => {
80
+ leave();
81
+ const ws = new WebSocket(wsUrl());
82
+ wsRef.current = ws;
83
+ ws.onopen = () => ws.send(JSON.stringify({ type: 'join', streamId: id }));
84
+ ws.onmessage = (ev) => {
85
+ try {
86
+ const data = JSON.parse(ev.data);
87
+ if (data.type === 'frame' && imgRef.current) imgRef.current.src = data.payload.data;
88
+ if (data.type === 'stopped') leave();
89
+ } catch { /* ignore */ }
90
+ };
91
+ setWatching(id);
92
+ };
93
+
94
+ const leave = () => {
95
+ wsRef.current?.close();
96
+ wsRef.current = null;
97
+ setWatching(null);
98
+ if (imgRef.current) imgRef.current.removeAttribute('src');
99
+ };
100
+
101
+ useEffect(() => () => leave(), []); // eslint-disable-line react-hooks/exhaustive-deps
102
+
103
+ return (
104
+ <div>
105
+ <button onClick={refresh} style={{ marginBottom: '0.75rem' }}>Refresh list</button>
106
+ {streams.length === 0 && <p style={{ color: '#666' }}>No live streams right now.</p>}
107
+ <ul>
108
+ {streams.map((s) => (
109
+ <li key={s.id} style={{ marginBottom: '0.25rem' }}>
110
+ <button onClick={() => watch(s.id)} disabled={watching === s.id}>Watch</button>{' '}
111
+ <strong>{s.title}</strong> <span style={{ color: '#666' }}>({s.id})</span>
112
+ </li>
113
+ ))}
114
+ </ul>
115
+ {watching && (
116
+ <div>
117
+ <p>Watching <strong>{watching}</strong> — <button onClick={leave}>Leave</button></p>
118
+ <img ref={imgRef} alt="live stream" style={{ width: 480, maxWidth: '100%', background: '#000', borderRadius: 8 }} />
119
+ </div>
120
+ )}
121
+ </div>
122
+ );
123
+ }
124
+
125
+ function App() {
126
+ const [tab, setTab] = useState<'broadcast' | 'watch'>('watch');
127
+ const tabBtn = (t: 'broadcast' | 'watch') => ({
128
+ padding: '0.5rem 1rem',
129
+ border: '1px solid #ccc',
130
+ borderBottom: tab === t ? 'none' : '1px solid #ccc',
131
+ background: tab === t ? '#fff' : '#f0f0f0',
132
+ cursor: 'pointer',
133
+ } as const);
134
+
135
+ return (
136
+ <main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
137
+ <h1>Livestream</h1>
138
+ <div style={{ display: 'flex', marginBottom: '1rem' }}>
139
+ <button style={tabBtn('broadcast')} onClick={() => setTab('broadcast')}>Broadcast</button>
140
+ <button style={tabBtn('watch')} onClick={() => setTab('watch')}>Watch</button>
141
+ </div>
142
+ {tab === 'broadcast' ? <Broadcast /> : <Watch />}
143
+ </main>
144
+ );
145
+ }
146
+
147
+ const el = document.getElementById('root');
148
+ if (el) createRoot(el).render(<App />);
@@ -0,0 +1,51 @@
1
+ # Multi-app example
2
+
3
+ Nexus as a platform: two backends (`shop` + `api`) and two frontends (`web` +
4
+ `shop`) in one project, with one admin covering all of them.
5
+
6
+ ## Why there's almost nothing in this overlay
7
+
8
+ Multi-app projects are created by `nexus add` — each call scaffolds a new
9
+ `apps/<name>` with its own config, ports, and discovery roots. So this overlay
10
+ ships only a README plus two small route files to look at; the apps themselves
11
+ appear when you run the commands below.
12
+
13
+ ## Set up after `nexus init --example multi-app`
14
+
15
+ ```bash
16
+ npx nexus add backend shop
17
+ npx nexus add backend api
18
+ npx nexus add frontend shop --for backend-shop
19
+ npx nexus add frontend web --for backend
20
+ npx nexus dev
21
+ ```
22
+
23
+ ## What you end up with
24
+
25
+ ```
26
+ apps/
27
+ backend/ # the default backend from init (keep or repurpose)
28
+ backend-shop/ # storefront API -> routes/products.ts (in this overlay)
29
+ backend-api/ # shared API -> routes/users.ts (in this overlay)
30
+ frontend/ # default frontend from init
31
+ frontend-shop/ # storefront UI, proxies to backend-shop
32
+ frontend-web/ # marketing/web UI, proxies to backend
33
+ ```
34
+
35
+ ## Conventions
36
+
37
+ - Each `apps/backend-*` has its own `src/routes`, `src/ws`, `src/models`, etc.,
38
+ auto-discovered independently by `createNexusApp()`.
39
+ - Backends get distinct ports; `nexus dev` prints the port map.
40
+ - Frontends created with `--for <backend>` have their `VITE_BACKEND_*` env
41
+ pre-wired to that backend.
42
+ - The admin (single instance) aggregates health/metrics across all backends.
43
+
44
+ ## Files in this overlay
45
+
46
+ - `apps/backend-shop/src/routes/products.ts` — sample storefront routes
47
+ - `apps/backend-api/src/routes/users.ts` — sample identity routes
48
+
49
+ Drop them in after `nexus add backend ...` scaffolds the apps, then `npx nexus
50
+ dev` and hit `GET /api/products` on the shop backend and `GET /api/users` on
51
+ the api backend.
@@ -0,0 +1,26 @@
1
+ import { defineRoutes } from '@bhooai/nexus-core';
2
+
3
+ // Mounted on the "api" backend (apps/backend-api) — shared identity/service API.
4
+ const USERS = [
5
+ { id: 'u-1', name: 'Ada Lovelace', email: 'ada@example.com' },
6
+ { id: 'u-2', name: 'Grace Hopper', email: 'grace@example.com' },
7
+ ];
8
+
9
+ export default defineRoutes([
10
+ {
11
+ method: 'GET',
12
+ path: '/api/users',
13
+ handler: async (ctx) => {
14
+ ctx.json({ users: USERS });
15
+ },
16
+ },
17
+ {
18
+ method: 'GET',
19
+ path: '/api/users/:id',
20
+ handler: async (ctx) => {
21
+ const user = USERS.find((u) => u.id === ctx.params.id);
22
+ if (!user) return ctx.json({ error: 'user not found' }, 404);
23
+ ctx.json({ user });
24
+ },
25
+ },
26
+ ]);
@@ -0,0 +1,27 @@
1
+ import { defineRoutes } from '@bhooai/nexus-core';
2
+
3
+ // Mounted on the "shop" backend (apps/backend-shop) — the storefront API.
4
+ const PRODUCTS = [
5
+ { id: 'sku-1', name: 'Nexus mug', priceCents: 49900 },
6
+ { id: 'sku-2', name: 'Nexus tee', priceCents: 89900 },
7
+ { id: 'sku-3', name: 'Sticker pack', priceCents: 14900 },
8
+ ];
9
+
10
+ export default defineRoutes([
11
+ {
12
+ method: 'GET',
13
+ path: '/api/products',
14
+ handler: async (ctx) => {
15
+ ctx.json({ products: PRODUCTS });
16
+ },
17
+ },
18
+ {
19
+ method: 'GET',
20
+ path: '/api/products/:id',
21
+ handler: async (ctx) => {
22
+ const product = PRODUCTS.find((p) => p.id === ctx.params.id);
23
+ if (!product) return ctx.json({ error: 'product not found' }, 404);
24
+ ctx.json({ product });
25
+ },
26
+ },
27
+ ]);
@@ -0,0 +1,37 @@
1
+ # SaaS starter example
2
+
3
+ Teams + projects + members: the skeleton of a multi-tenant SaaS. Auth is
4
+ deliberately stubbed (header-based find-or-create) — swap in `nexus-auth`
5
+ providers for real OAuth.
6
+
7
+ ## What's here
8
+
9
+ | File | Purpose |
10
+ |------|---------|
11
+ | `models/User.ts`, `models/Team.ts`, `models/Membership.ts` | Multi-tenant data model; membership carries the role |
12
+ | `routes/teams.ts` | Sign-in (demo), team CRUD, member list/invite/delete |
13
+ | `routes/projects.ts` | Project CRUD scoped to a team (in-memory for brevity) |
14
+ | `policies/TeamPolicy.ts` | `owner` / `member` / `viewer` abilities |
15
+ | `middleware/auth.ts` | `requireAuth` — trusts `x-user-id` (demo only) |
16
+ | `events/UserRegistered.ts`, `events/TeamCreated.ts` | Domain events |
17
+ | `listeners/OnUserRegistered.ts`, `listeners/OnTeamCreated.ts` | Auto-wired listeners (convention: `On<Foo>` -> `<Foo>`) |
18
+ | `apps/frontend/src/main.tsx` | Sign-in stub + team list/create UI |
19
+
20
+ ## Role abilities
21
+
22
+ | Ability | viewer | member | owner |
23
+ |---------|:------:|:------:|:-----:|
24
+ | view | x | x | x |
25
+ | update | | x | x |
26
+ | invite | | x | x |
27
+ | delete | | | x |
28
+
29
+ ## Run
30
+
31
+ ```bash
32
+ npx nexus dev
33
+ ```
34
+
35
+ Sign in with any email (a user is created on first use), create a team, then
36
+ sign in with a second email in a private window and try inviting that user via
37
+ `POST /api/teams/:teamId/members` with an `x-user-id` header.
@@ -0,0 +1,11 @@
1
+ import { DomainEvent } from '@bhooai/nexus-core';
2
+
3
+ export default class TeamCreated extends DomainEvent {
4
+ constructor(
5
+ public readonly teamId: string,
6
+ public readonly ownerId: string,
7
+ public readonly name: string,
8
+ ) {
9
+ super();
10
+ }
11
+ }
@@ -0,0 +1,10 @@
1
+ import { DomainEvent } from '@bhooai/nexus-core';
2
+
3
+ export default class UserRegistered extends DomainEvent {
4
+ constructor(
5
+ public readonly userId: string,
6
+ public readonly email: string,
7
+ ) {
8
+ super();
9
+ }
10
+ }