@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.
- package/examples/ai-chat/README.md +37 -0
- package/examples/ai-chat/apps/backend/src/routes/chat.ts +40 -0
- package/examples/ai-chat/apps/frontend/src/main.tsx +105 -0
- package/examples/blog-crud/README.md +45 -0
- package/examples/blog-crud/apps/backend/src/graphql/post.graph.ts +99 -0
- package/examples/blog-crud/apps/backend/src/models/Author.ts +10 -0
- package/examples/blog-crud/apps/backend/src/models/Comment.ts +10 -0
- package/examples/blog-crud/apps/backend/src/models/Post.ts +13 -0
- package/examples/blog-crud/apps/backend/src/routes/posts.ts +119 -0
- package/examples/blog-crud/apps/frontend/src/main.tsx +102 -0
- package/examples/chat/README.md +20 -0
- package/examples/chat/apps/backend/src/models/Message.ts +11 -0
- package/examples/chat/apps/backend/src/routes/chat.ts +17 -0
- package/examples/chat/apps/backend/src/ws/chat.room.ts +56 -0
- package/examples/chat/apps/frontend/src/main.tsx +97 -0
- package/examples/checkout/README.md +40 -0
- package/examples/checkout/apps/backend/src/mail/mailables/OrderConfirmationMail.ts +27 -0
- package/examples/checkout/apps/backend/src/mail/templates/order-confirmation.ejs +28 -0
- package/examples/checkout/apps/backend/src/models/Order.ts +14 -0
- package/examples/checkout/apps/backend/src/routes/checkout.ts +74 -0
- package/examples/checkout/apps/frontend/src/main.tsx +85 -0
- package/examples/dashboard/README.md +26 -0
- package/examples/dashboard/apps/backend/src/routes/metrics.ts +76 -0
- package/examples/dashboard/apps/frontend/src/main.tsx +100 -0
- package/examples/file-storage/README.md +48 -0
- package/examples/file-storage/apps/backend/src/routes/files.ts +86 -0
- package/examples/file-storage/apps/frontend/src/main.tsx +111 -0
- package/examples/livestream/README.md +34 -0
- package/examples/livestream/apps/backend/src/routes/list.ts +21 -0
- package/examples/livestream/apps/backend/src/ws/stream.room.ts +53 -0
- package/examples/livestream/apps/frontend/src/main.tsx +148 -0
- package/examples/multi-app/README.md +51 -0
- package/examples/multi-app/apps/backend-api/src/routes/users.ts +26 -0
- package/examples/multi-app/apps/backend-shop/src/routes/products.ts +27 -0
- package/examples/saas-starter/README.md +37 -0
- package/examples/saas-starter/apps/backend/src/events/TeamCreated.ts +11 -0
- package/examples/saas-starter/apps/backend/src/events/UserRegistered.ts +10 -0
- package/examples/saas-starter/apps/backend/src/listeners/OnTeamCreated.ts +11 -0
- package/examples/saas-starter/apps/backend/src/listeners/OnUserRegistered.ts +10 -0
- package/examples/saas-starter/apps/backend/src/middleware/auth.ts +14 -0
- package/examples/saas-starter/apps/backend/src/models/Membership.ts +20 -0
- package/examples/saas-starter/apps/backend/src/models/Team.ts +10 -0
- package/examples/saas-starter/apps/backend/src/models/User.ts +17 -0
- package/examples/saas-starter/apps/backend/src/policies/TeamPolicy.ts +35 -0
- package/examples/saas-starter/apps/backend/src/routes/projects.ts +52 -0
- package/examples/saas-starter/apps/backend/src/routes/teams.ts +112 -0
- package/examples/saas-starter/apps/frontend/src/main.tsx +95 -0
- package/examples/video-call/README.md +30 -0
- package/examples/video-call/apps/backend/src/ws/signaling.room.ts +47 -0
- package/examples/video-call/apps/frontend/src/main.tsx +116 -0
- package/package.json +11 -0
- 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.
|