@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,11 @@
|
|
|
1
|
+
import { Listener } from '@bhooai/nexus-core';
|
|
2
|
+
import type TeamCreated from '../events/TeamCreated.js';
|
|
3
|
+
import { Membership } from '../models/Membership.js';
|
|
4
|
+
|
|
5
|
+
/** Make the team creator an owner and log the event. */
|
|
6
|
+
export default class OnTeamCreated extends Listener<TeamCreated> {
|
|
7
|
+
async handle(event: TeamCreated) {
|
|
8
|
+
await Membership.create({ teamId: event.teamId, userId: event.ownerId, role: 'owner' });
|
|
9
|
+
console.log(`[events] team created: "${event.name}" by ${event.ownerId}`);
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { Listener } from '@bhooai/nexus-core';
|
|
2
|
+
import type UserRegistered from '../events/UserRegistered.js';
|
|
3
|
+
|
|
4
|
+
/** Convention: On<Foo> listens for <Foo> — auto-wired by createNexusApp(). */
|
|
5
|
+
export default class OnUserRegistered extends Listener<UserRegistered> {
|
|
6
|
+
async handle(event: UserRegistered) {
|
|
7
|
+
console.log(`[events] user registered: ${event.email} (${event.userId})`);
|
|
8
|
+
// Production: send a welcome email, seed default preferences, etc.
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Middleware } from '@bhooai/nexus-core';
|
|
2
|
+
import { AuthenticationError } from '@bhooai/nexus-core';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Demo auth middleware: trusts an `x-user-id` header instead of verifying a
|
|
6
|
+
* real session/JWT. Swap for nexus-auth's jwt middleware in production.
|
|
7
|
+
*/
|
|
8
|
+
export const requireAuth: Middleware = async (ctx) => {
|
|
9
|
+
const userId = ctx.headers['x-user-id'];
|
|
10
|
+
if (typeof userId !== 'string' || userId.length === 0) {
|
|
11
|
+
throw new AuthenticationError('Sign in required (x-user-id header in this demo)');
|
|
12
|
+
}
|
|
13
|
+
ctx.state.userId = userId;
|
|
14
|
+
};
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
export const ROLES = ['owner', 'member', 'viewer'] as const;
|
|
4
|
+
export type Role = (typeof ROLES)[number];
|
|
5
|
+
|
|
6
|
+
const membershipSchema = new Schema({
|
|
7
|
+
teamId: { type: String, required: true, index: true },
|
|
8
|
+
userId: { type: String, required: true, index: true },
|
|
9
|
+
role: { type: String, required: true, enum: [...ROLES], default: 'member' },
|
|
10
|
+
createdAt: { type: Date, default: () => new Date() },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const Membership = model('memberships', membershipSchema);
|
|
14
|
+
|
|
15
|
+
/** Role of a user within a team, or null when not a member. */
|
|
16
|
+
export async function roleInTeam(teamId: string, userId: string): Promise<Role | null> {
|
|
17
|
+
const m = await Membership.findOne({ teamId, userId });
|
|
18
|
+
const role = (m as unknown as { role?: string } | null)?.role;
|
|
19
|
+
return ROLES.includes(role as Role) ? (role as Role) : null;
|
|
20
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
const teamSchema = new Schema({
|
|
4
|
+
name: { type: String, required: true },
|
|
5
|
+
slug: { type: String, required: true, index: true },
|
|
6
|
+
ownerId: { type: String, required: true },
|
|
7
|
+
createdAt: { type: Date, default: () => new Date() },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const Team = model('teams', teamSchema);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
const userSchema = new Schema({
|
|
4
|
+
name: { type: String, required: true },
|
|
5
|
+
email: { type: String, required: true, index: true },
|
|
6
|
+
createdAt: { type: Date, default: () => new Date() },
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
export const User = model('users', userSchema);
|
|
10
|
+
|
|
11
|
+
/** A hydrated User document (property reads proxy to the underlying doc). */
|
|
12
|
+
export type UserDoc = InstanceType<typeof User>;
|
|
13
|
+
|
|
14
|
+
/** Look up a user by email, or null. */
|
|
15
|
+
export async function findUserByEmail(email: string): Promise<UserDoc | null> {
|
|
16
|
+
return User.findOne({ email });
|
|
17
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { Policy } from '@bhooai/nexus-core';
|
|
2
|
+
import { roleInTeam, type Role } from '../models/Membership.js';
|
|
3
|
+
|
|
4
|
+
interface Actor { userId: string }
|
|
5
|
+
interface TeamResource { id: string }
|
|
6
|
+
|
|
7
|
+
const RANK: Record<Role, number> = { viewer: 0, member: 1, owner: 2 };
|
|
8
|
+
|
|
9
|
+
async function hasRole(actor: Actor, teamId: string, min: Role): Promise<boolean> {
|
|
10
|
+
const role = await roleInTeam(teamId, actor.userId);
|
|
11
|
+
return role !== null && RANK[role] >= RANK[min];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* TeamPolicy — abilities by role:
|
|
16
|
+
* view : viewer+ (any member can look)
|
|
17
|
+
* update : member+ (edit name/settings, manage projects)
|
|
18
|
+
* delete : owner (destructive)
|
|
19
|
+
* invite : member+ (add people)
|
|
20
|
+
*/
|
|
21
|
+
export default class TeamPolicy extends Policy<Actor, TeamResource> {
|
|
22
|
+
async view(actor: Actor, team: TeamResource) { return hasRole(actor, team.id, 'viewer'); }
|
|
23
|
+
async update(actor: Actor, team: TeamResource) { return hasRole(actor, team.id, 'member'); }
|
|
24
|
+
async invite(actor: Actor, team: TeamResource) { return hasRole(actor, team.id, 'member'); }
|
|
25
|
+
async delete(actor: Actor, team: TeamResource) { return hasRole(actor, team.id, 'owner'); }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export type TeamAbility = 'view' | 'update' | 'invite' | 'delete';
|
|
29
|
+
|
|
30
|
+
/** Direct check used by routes (policyRegistry.check needs a ResourceCtor
|
|
31
|
+
* match, so a helper is simpler for this example). */
|
|
32
|
+
export function canTeam(userId: string, ability: TeamAbility, teamId: string): Promise<boolean> {
|
|
33
|
+
const policy = new TeamPolicy();
|
|
34
|
+
return policy[ability]({ userId }, { id: teamId });
|
|
35
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { defineRoutes, AuthorizationError } from '@bhooai/nexus-core';
|
|
3
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
4
|
+
import { canTeam } from '../policies/TeamPolicy.js';
|
|
5
|
+
|
|
6
|
+
// Projects are intentionally in-memory here — the demo is about the CRUD +
|
|
7
|
+
// policy wiring, not about one more ODM collection.
|
|
8
|
+
interface Project { id: string; teamId: string; name: string; status: 'active' | 'archived' }
|
|
9
|
+
const projects: Project[] = [];
|
|
10
|
+
|
|
11
|
+
export default defineRoutes([
|
|
12
|
+
{
|
|
13
|
+
method: 'GET',
|
|
14
|
+
path: '/api/teams/:teamId/projects',
|
|
15
|
+
middleware: [requireAuth],
|
|
16
|
+
handler: async (ctx) => {
|
|
17
|
+
if (!(await canTeam(String(ctx.state.userId), 'view', ctx.params.teamId))) {
|
|
18
|
+
throw new AuthorizationError('not a member of this team');
|
|
19
|
+
}
|
|
20
|
+
ctx.json({ projects: projects.filter((p) => p.teamId === ctx.params.teamId) });
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
method: 'POST',
|
|
25
|
+
path: '/api/teams/:teamId/projects',
|
|
26
|
+
middleware: [requireAuth],
|
|
27
|
+
handler: async (ctx) => {
|
|
28
|
+
if (!(await canTeam(String(ctx.state.userId), 'update', ctx.params.teamId))) {
|
|
29
|
+
throw new AuthorizationError('viewer cannot create projects');
|
|
30
|
+
}
|
|
31
|
+
const body = (ctx.body ?? {}) as { name?: string };
|
|
32
|
+
if (!body.name?.trim()) return ctx.json({ error: 'name is required' }, 422);
|
|
33
|
+
const project: Project = { id: randomUUID(), teamId: ctx.params.teamId, name: body.name.trim(), status: 'active' };
|
|
34
|
+
projects.push(project);
|
|
35
|
+
ctx.json({ project }, 201);
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
method: 'DELETE',
|
|
40
|
+
path: '/api/teams/:teamId/projects/:projectId',
|
|
41
|
+
middleware: [requireAuth],
|
|
42
|
+
handler: async (ctx) => {
|
|
43
|
+
if (!(await canTeam(String(ctx.state.userId), 'update', ctx.params.teamId))) {
|
|
44
|
+
throw new AuthorizationError('viewer cannot delete projects');
|
|
45
|
+
}
|
|
46
|
+
const idx = projects.findIndex((p) => p.id === ctx.params.projectId && p.teamId === ctx.params.teamId);
|
|
47
|
+
if (idx === -1) return ctx.json({ error: 'project not found' }, 404);
|
|
48
|
+
projects.splice(idx, 1);
|
|
49
|
+
ctx.json({ ok: true });
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { defineRoutes } from '@bhooai/nexus-core';
|
|
2
|
+
import { eventBus, AuthorizationError, NotFoundError } from '@bhooai/nexus-core';
|
|
3
|
+
import { Team } from '../models/Team.js';
|
|
4
|
+
import { User, findUserByEmail } from '../models/User.js';
|
|
5
|
+
import { Membership } from '../models/Membership.js';
|
|
6
|
+
import { requireAuth } from '../middleware/auth.js';
|
|
7
|
+
import { canTeam } from '../policies/TeamPolicy.js';
|
|
8
|
+
import UserRegistered from '../events/UserRegistered.js';
|
|
9
|
+
import TeamCreated from '../events/TeamCreated.js';
|
|
10
|
+
|
|
11
|
+
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
12
|
+
|
|
13
|
+
export default defineRoutes([
|
|
14
|
+
{
|
|
15
|
+
// Demo sign-in: find-or-create by email (no password/OAuth in this example).
|
|
16
|
+
method: 'POST',
|
|
17
|
+
path: '/api/auth/signin',
|
|
18
|
+
handler: async (ctx) => {
|
|
19
|
+
const body = (ctx.body ?? {}) as { email?: string; name?: string };
|
|
20
|
+
if (!body.email) return ctx.json({ error: 'email is required' }, 422);
|
|
21
|
+
let user = await findUserByEmail(body.email);
|
|
22
|
+
if (!user) {
|
|
23
|
+
[user] = await User.create({ email: body.email, name: body.name ?? body.email.split('@')[0] });
|
|
24
|
+
const created = user as unknown as { _id: unknown; email: string };
|
|
25
|
+
await eventBus.dispatch(new UserRegistered(String(created._id), created.email));
|
|
26
|
+
}
|
|
27
|
+
const doc = user as unknown as { _id: unknown; name: string; email: string };
|
|
28
|
+
ctx.json({ user: { id: String(doc._id), name: doc.name, email: doc.email } });
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
method: 'GET',
|
|
33
|
+
path: '/api/teams',
|
|
34
|
+
middleware: [requireAuth],
|
|
35
|
+
handler: async (ctx) => {
|
|
36
|
+
const userId = String(ctx.state.userId);
|
|
37
|
+
const memberships = await Membership.find({ userId });
|
|
38
|
+
const teams = [];
|
|
39
|
+
for (const m of memberships) {
|
|
40
|
+
const mem = m as unknown as { teamId: string; role: string };
|
|
41
|
+
const team = await Team.findById(mem.teamId);
|
|
42
|
+
if (team) {
|
|
43
|
+
const t = team as unknown as { _id: unknown; name: string; slug: string };
|
|
44
|
+
teams.push({ id: String(t._id), name: t.name, slug: t.slug, role: mem.role });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
ctx.json({ teams });
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
method: 'POST',
|
|
52
|
+
path: '/api/teams',
|
|
53
|
+
middleware: [requireAuth],
|
|
54
|
+
handler: async (ctx) => {
|
|
55
|
+
const body = (ctx.body ?? {}) as { name?: string };
|
|
56
|
+
if (!body.name?.trim()) return ctx.json({ error: 'name is required' }, 422);
|
|
57
|
+
const userId = String(ctx.state.userId);
|
|
58
|
+
const [team] = await Team.create({ name: body.name.trim(), slug: slugify(body.name), ownerId: userId });
|
|
59
|
+
const t = team as unknown as { _id: unknown; name: string; slug: string };
|
|
60
|
+
await eventBus.dispatch(new TeamCreated(String(t._id), userId, t.name));
|
|
61
|
+
ctx.json({ team: { id: String(t._id), name: t.name, slug: t.slug } }, 201);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
method: 'GET',
|
|
66
|
+
path: '/api/teams/:teamId/members',
|
|
67
|
+
middleware: [requireAuth],
|
|
68
|
+
handler: async (ctx) => {
|
|
69
|
+
const userId = String(ctx.state.userId);
|
|
70
|
+
if (!(await canTeam(userId, 'view', ctx.params.teamId))) {
|
|
71
|
+
throw new AuthorizationError('not a member of this team');
|
|
72
|
+
}
|
|
73
|
+
const members = await Membership.find({ teamId: ctx.params.teamId });
|
|
74
|
+
ctx.json({ members: members.map((m) => {
|
|
75
|
+
const mem = m as unknown as { userId: string; role: string };
|
|
76
|
+
return { userId: mem.userId, role: mem.role };
|
|
77
|
+
}) });
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
method: 'POST',
|
|
82
|
+
path: '/api/teams/:teamId/members',
|
|
83
|
+
middleware: [requireAuth],
|
|
84
|
+
handler: async (ctx) => {
|
|
85
|
+
const userId = String(ctx.state.userId);
|
|
86
|
+
if (!(await canTeam(userId, 'invite', ctx.params.teamId))) {
|
|
87
|
+
throw new AuthorizationError('member role required to invite');
|
|
88
|
+
}
|
|
89
|
+
const body = (ctx.body ?? {}) as { email?: string; role?: string };
|
|
90
|
+
const user = await findUserByEmail(body.email ?? '');
|
|
91
|
+
if (!user) throw new NotFoundError('no user with that email');
|
|
92
|
+
const role = body.role === 'viewer' || body.role === 'owner' ? body.role : 'member';
|
|
93
|
+
const doc = user as unknown as { _id: unknown };
|
|
94
|
+
await Membership.create({ teamId: ctx.params.teamId, userId: String(doc._id), role });
|
|
95
|
+
ctx.json({ ok: true }, 201);
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
method: 'DELETE',
|
|
100
|
+
path: '/api/teams/:teamId',
|
|
101
|
+
middleware: [requireAuth],
|
|
102
|
+
handler: async (ctx) => {
|
|
103
|
+
const userId = String(ctx.state.userId);
|
|
104
|
+
if (!(await canTeam(userId, 'delete', ctx.params.teamId))) {
|
|
105
|
+
throw new AuthorizationError('only the owner can delete a team');
|
|
106
|
+
}
|
|
107
|
+
await Membership.deleteMany({ teamId: ctx.params.teamId });
|
|
108
|
+
await Team.deleteOne({ _id: ctx.params.teamId });
|
|
109
|
+
ctx.json({ ok: true });
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
]);
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
interface User { id: string; name: string; email: string }
|
|
5
|
+
interface Team { id: string; name: string; slug: string; role: string }
|
|
6
|
+
|
|
7
|
+
function App() {
|
|
8
|
+
const [user, setUser] = useState<User | null>(null);
|
|
9
|
+
const [email, setEmail] = useState('you@example.com');
|
|
10
|
+
const [teams, setTeams] = useState<Team[]>([]);
|
|
11
|
+
const [teamName, setTeamName] = useState('');
|
|
12
|
+
const [error, setError] = useState('');
|
|
13
|
+
|
|
14
|
+
// Demo auth: the backend trusts an x-user-id header instead of a session.
|
|
15
|
+
const api = async (path: string, init: RequestInit = {}) => {
|
|
16
|
+
const res = await fetch(path, {
|
|
17
|
+
...init,
|
|
18
|
+
headers: {
|
|
19
|
+
'content-type': 'application/json',
|
|
20
|
+
...(user ? { 'x-user-id': user.id } : {}),
|
|
21
|
+
...(init.headers ?? {}),
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
const data = await res.json().catch(() => ({}));
|
|
25
|
+
if (!res.ok) throw new Error((data as { error?: string }).error ?? `HTTP ${res.status}`);
|
|
26
|
+
return data;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const signIn = async () => {
|
|
30
|
+
setError('');
|
|
31
|
+
try {
|
|
32
|
+
const data = await api('/api/auth/signin', { method: 'POST', body: JSON.stringify({ email }) });
|
|
33
|
+
setUser(data.user);
|
|
34
|
+
} catch (err) { setError((err as Error).message); }
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const loadTeams = async () => {
|
|
38
|
+
try {
|
|
39
|
+
const data = await api('/api/teams');
|
|
40
|
+
setTeams(data.teams ?? []);
|
|
41
|
+
} catch (err) { setError((err as Error).message); }
|
|
42
|
+
};
|
|
43
|
+
useEffect(() => { if (user) loadTeams(); }, [user]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
44
|
+
|
|
45
|
+
const createTeam = async () => {
|
|
46
|
+
if (!teamName.trim()) return;
|
|
47
|
+
try {
|
|
48
|
+
await api('/api/teams', { method: 'POST', body: JSON.stringify({ name: teamName }) });
|
|
49
|
+
setTeamName('');
|
|
50
|
+
loadTeams();
|
|
51
|
+
} catch (err) { setError((err as Error).message); }
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
if (!user) {
|
|
55
|
+
// Stub only — wire real OAuth via nexus-auth providers in production.
|
|
56
|
+
return (
|
|
57
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 420, margin: '4rem auto', padding: '0 1rem' }}>
|
|
58
|
+
<h1>SaaS starter</h1>
|
|
59
|
+
<p style={{ color: '#666' }}>Demo sign-in (find-or-create by email). No password, no OAuth.</p>
|
|
60
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
61
|
+
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="email" style={{ flex: 1, padding: '0.5rem' }} />
|
|
62
|
+
<button onClick={signIn}>Sign in</button>
|
|
63
|
+
</div>
|
|
64
|
+
{error && <p style={{ color: '#c00' }}>{error}</p>}
|
|
65
|
+
</main>
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
|
|
71
|
+
<h1>SaaS starter</h1>
|
|
72
|
+
<p>Signed in as <strong>{user.name}</strong> ({user.email}) <button onClick={() => setUser(null)}>Sign out</button></p>
|
|
73
|
+
|
|
74
|
+
<h2 style={{ fontSize: '1rem' }}>Your teams</h2>
|
|
75
|
+
{teams.length === 0 && <p style={{ color: '#666' }}>No teams yet — create one below.</p>}
|
|
76
|
+
<ul>
|
|
77
|
+
{teams.map((t) => (
|
|
78
|
+
<li key={t.id}>
|
|
79
|
+
<strong>{t.name}</strong> <span style={{ color: '#666' }}>/{t.slug}</span>{' '}
|
|
80
|
+
<em style={{ fontSize: '0.85rem' }}>({t.role})</em>
|
|
81
|
+
</li>
|
|
82
|
+
))}
|
|
83
|
+
</ul>
|
|
84
|
+
|
|
85
|
+
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
|
|
86
|
+
<input value={teamName} onChange={(e) => setTeamName(e.target.value)} placeholder="New team name" style={{ flex: 1, padding: '0.5rem' }} />
|
|
87
|
+
<button onClick={createTeam}>Create team</button>
|
|
88
|
+
</div>
|
|
89
|
+
{error && <p style={{ color: '#c00' }}>{error}</p>}
|
|
90
|
+
</main>
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const el = document.getElementById('root');
|
|
95
|
+
if (el) createRoot(el).render(<App />);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Video call example
|
|
2
|
+
|
|
3
|
+
1:1 WebRTC call with signaling over the Nexus WS layer. No SFU — media flows
|
|
4
|
+
peer-to-peer; the backend only relays SDP + ICE.
|
|
5
|
+
|
|
6
|
+
## What's here
|
|
7
|
+
|
|
8
|
+
- `apps/backend/src/ws/signaling.room.ts` — WS room relaying `offer` / `answer` / `candidate`
|
|
9
|
+
- `apps/frontend/src/main.tsx` — camera capture + `RTCPeerConnection` + two `<video>` elements
|
|
10
|
+
|
|
11
|
+
## Call flow
|
|
12
|
+
|
|
13
|
+
1. Both clients open a WS connection and `join` the same room.
|
|
14
|
+
2. First client in becomes the **caller**, second the **callee**.
|
|
15
|
+
3. Caller creates an `RTCPeerConnection`, adds local tracks, sends an SDP `offer`.
|
|
16
|
+
4. Callee sets the remote description, answers — SDP `answer` goes back.
|
|
17
|
+
5. Both sides trickle `candidate` (ICE) messages until a path is found.
|
|
18
|
+
6. `ontrack` fires on each side and the remote stream is attached to its video element.
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npx nexus dev
|
|
24
|
+
# Frontend: http://localhost:3000
|
|
25
|
+
# Backend : http://localhost:4000
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Open **two browser windows** (camera permission needed in each), pick the same
|
|
29
|
+
room name in both, and press *Join call*. The second window to join triggers
|
|
30
|
+
the offer/answer exchange automatically.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WebRTC signaling room. Auto-discovered by createNexusApp() from ws/*.room.ts.
|
|
3
|
+
*
|
|
4
|
+
* The server never touches media — it only relays SDP offers/answers and ICE
|
|
5
|
+
* candidates between the two peers in a call room.
|
|
6
|
+
*
|
|
7
|
+
* Client protocol:
|
|
8
|
+
* -> join { roomId } (first client = caller, second = callee)
|
|
9
|
+
* <- peer-joined { role, peers } (sent on join + whenever the room fills)
|
|
10
|
+
* -> offer { roomId, sdp }
|
|
11
|
+
* -> answer { roomId, sdp }
|
|
12
|
+
* -> candidate { roomId, candidate }
|
|
13
|
+
* <- offer/answer/candidate (relayed to the *other* peer)
|
|
14
|
+
* <- peer-left {} (when the other peer disconnects)
|
|
15
|
+
*/
|
|
16
|
+
export default {
|
|
17
|
+
name: 'signaling',
|
|
18
|
+
|
|
19
|
+
async onJoin(socket: any, payload: { roomId: string }) {
|
|
20
|
+
const room = `call:${payload.roomId}`;
|
|
21
|
+
socket.data.roomId = payload.roomId;
|
|
22
|
+
socket.join(room);
|
|
23
|
+
|
|
24
|
+
const peers = await socket.in(room).fetchSockets();
|
|
25
|
+
// Caller is the first to arrive; the second is the callee. The `peers`
|
|
26
|
+
// count lets the caller offer only once someone is actually listening.
|
|
27
|
+
const role = peers.length <= 1 ? 'caller' : 'callee';
|
|
28
|
+
socket.emit('peer-joined', { role, peers: peers.length });
|
|
29
|
+
socket.to(room).emit('peer-joined', { role: role === 'caller' ? 'callee' : 'caller', peers: peers.length });
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
async onLeave(socket: any, payload: { roomId: string }) {
|
|
33
|
+
socket.to(`call:${payload.roomId}`).emit('peer-left', {});
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
onMessage: {
|
|
37
|
+
'offer': (socket: any, payload: { roomId: string; sdp: unknown }, _ctx: any) => {
|
|
38
|
+
socket.to(`call:${payload.roomId}`).emit('offer', { sdp: payload.sdp });
|
|
39
|
+
},
|
|
40
|
+
'answer': (socket: any, payload: { roomId: string; sdp: unknown }, _ctx: any) => {
|
|
41
|
+
socket.to(`call:${payload.roomId}`).emit('answer', { sdp: payload.sdp });
|
|
42
|
+
},
|
|
43
|
+
'candidate': (socket: any, payload: { roomId: string; candidate: unknown }, _ctx: any) => {
|
|
44
|
+
socket.to(`call:${payload.roomId}`).emit('candidate', { candidate: payload.candidate });
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
};
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import React, { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
type Role = 'caller' | 'callee';
|
|
5
|
+
|
|
6
|
+
function App() {
|
|
7
|
+
const [roomId, setRoomId] = useState('demo');
|
|
8
|
+
const [status, setStatus] = useState('idle');
|
|
9
|
+
const [connected, setConnected] = useState(false);
|
|
10
|
+
const localRef = useRef<HTMLVideoElement>(null);
|
|
11
|
+
const remoteRef = useRef<HTMLVideoElement>(null);
|
|
12
|
+
const wsRef = useRef<WebSocket | null>(null);
|
|
13
|
+
const pcRef = useRef<RTCPeerConnection | null>(null);
|
|
14
|
+
const roleRef = useRef<Role>('caller');
|
|
15
|
+
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
return () => hangUp();
|
|
18
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
19
|
+
}, []);
|
|
20
|
+
|
|
21
|
+
const connect = async () => {
|
|
22
|
+
const stream = await navigator.mediaDevices.getUserMedia({ video: true, audio: true });
|
|
23
|
+
if (localRef.current) localRef.current.srcObject = stream;
|
|
24
|
+
|
|
25
|
+
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
|
26
|
+
pcRef.current = pc;
|
|
27
|
+
for (const track of stream.getTracks()) pc.addTrack(track, stream);
|
|
28
|
+
pc.ontrack = (ev) => {
|
|
29
|
+
if (remoteRef.current) remoteRef.current.srcObject = ev.streams[0];
|
|
30
|
+
};
|
|
31
|
+
pc.onicecandidate = (ev) => {
|
|
32
|
+
if (ev.candidate) send('candidate', { roomId, candidate: ev.candidate });
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const host = (import.meta as any).env?.VITE_BACKEND_HOST ?? 'localhost';
|
|
36
|
+
const port = (import.meta as any).env?.VITE_BACKEND_PORT ?? '4000';
|
|
37
|
+
const ws = new WebSocket(`ws://${host}:${port}/ws`);
|
|
38
|
+
wsRef.current = ws;
|
|
39
|
+
|
|
40
|
+
ws.onopen = () => {
|
|
41
|
+
setConnected(true);
|
|
42
|
+
setStatus('waiting for peer...');
|
|
43
|
+
ws.send(JSON.stringify({ type: 'join', roomId }));
|
|
44
|
+
};
|
|
45
|
+
ws.onclose = () => { setConnected(false); setStatus('disconnected'); };
|
|
46
|
+
ws.onmessage = async (ev) => {
|
|
47
|
+
try {
|
|
48
|
+
const data = JSON.parse(ev.data);
|
|
49
|
+
const pc = pcRef.current;
|
|
50
|
+
if (!pc) return;
|
|
51
|
+
if (data.type === 'peer-joined') {
|
|
52
|
+
roleRef.current = data.payload.role;
|
|
53
|
+
setStatus(`peer joined — you are the ${data.payload.role}`);
|
|
54
|
+
// The caller offers only once a second peer is actually in the room.
|
|
55
|
+
if (data.payload.role === 'caller' && data.payload.peers >= 2) {
|
|
56
|
+
await pc.setLocalDescription(await pc.createOffer());
|
|
57
|
+
send('offer', { roomId, sdp: pc.localDescription });
|
|
58
|
+
}
|
|
59
|
+
} else if (data.type === 'offer') {
|
|
60
|
+
await pc.setRemoteDescription(data.payload.sdp);
|
|
61
|
+
await pc.setLocalDescription(await pc.createAnswer());
|
|
62
|
+
send('answer', { roomId, sdp: pc.localDescription });
|
|
63
|
+
setStatus('in call');
|
|
64
|
+
} else if (data.type === 'answer') {
|
|
65
|
+
await pc.setRemoteDescription(data.payload.sdp);
|
|
66
|
+
setStatus('in call');
|
|
67
|
+
} else if (data.type === 'candidate') {
|
|
68
|
+
await pc.addIceCandidate(data.payload.candidate);
|
|
69
|
+
} else if (data.type === 'peer-left') {
|
|
70
|
+
setStatus('peer left');
|
|
71
|
+
if (remoteRef.current) remoteRef.current.srcObject = null;
|
|
72
|
+
}
|
|
73
|
+
} catch { /* ignore */ }
|
|
74
|
+
};
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const send = (type: string, payload: Record<string, unknown>) => {
|
|
78
|
+
wsRef.current?.send(JSON.stringify({ type, ...payload }));
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
const hangUp = () => {
|
|
82
|
+
pcRef.current?.close();
|
|
83
|
+
pcRef.current = null;
|
|
84
|
+
wsRef.current?.close();
|
|
85
|
+
wsRef.current = null;
|
|
86
|
+
(localRef.current?.srcObject as MediaStream | null)?.getTracks().forEach((t) => t.stop());
|
|
87
|
+
setConnected(false);
|
|
88
|
+
setStatus('idle');
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 900, margin: '2rem auto', padding: '0 1rem' }}>
|
|
93
|
+
<h1>Video call</h1>
|
|
94
|
+
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '1rem', alignItems: 'center' }}>
|
|
95
|
+
<input value={roomId} onChange={(e) => setRoomId(e.target.value)} placeholder="room" disabled={connected} />
|
|
96
|
+
{!connected
|
|
97
|
+
? <button onClick={connect}>Join call</button>
|
|
98
|
+
: <button onClick={hangUp}>Hang up</button>}
|
|
99
|
+
<span style={{ color: '#666' }}>{connected ? '*' : 'o'} {status}</span>
|
|
100
|
+
</div>
|
|
101
|
+
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '1rem' }}>
|
|
102
|
+
<figure style={{ margin: 0 }}>
|
|
103
|
+
<video ref={localRef} autoPlay muted playsInline style={{ width: '100%', background: '#000', borderRadius: 8 }} />
|
|
104
|
+
<figcaption style={{ fontSize: '0.85rem', color: '#666' }}>You (local)</figcaption>
|
|
105
|
+
</figure>
|
|
106
|
+
<figure style={{ margin: 0 }}>
|
|
107
|
+
<video ref={remoteRef} autoPlay playsInline style={{ width: '100%', background: '#000', borderRadius: 8 }} />
|
|
108
|
+
<figcaption style={{ fontSize: '0.85rem', color: '#666' }}>Peer (remote)</figcaption>
|
|
109
|
+
</figure>
|
|
110
|
+
</div>
|
|
111
|
+
</main>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const el = document.getElementById('root');
|
|
116
|
+
if (el) createRoot(el).render(<App />);
|
package/package.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@bhooai/nexus-examples",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Working examples for BhooAI Nexus v2 — each demos a slice of the framework.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./src/index.ts",
|
|
8
|
+
"scripts": {
|
|
9
|
+
"typecheck": "tsc --noEmit"
|
|
10
|
+
}
|
|
11
|
+
}
|