@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,37 @@
|
|
|
1
|
+
# AI chat example
|
|
2
|
+
|
|
3
|
+
Streaming LLM chat. The backend proxies the OpenAI-compatible Nexus AI server
|
|
4
|
+
with `nexus-ai-client` and re-emits token deltas as SSE; the frontend consumes
|
|
5
|
+
the stream with `fetch` + `ReadableStream`.
|
|
6
|
+
|
|
7
|
+
## What's here
|
|
8
|
+
|
|
9
|
+
- `apps/backend/src/routes/chat.ts` — `POST /api/ai/chat` SSE endpoint backed by `AiClient.chatStream()`
|
|
10
|
+
- `apps/frontend/src/main.tsx` — chat UI that appends streamed deltas to the last bubble
|
|
11
|
+
|
|
12
|
+
## Flow
|
|
13
|
+
|
|
14
|
+
1. Frontend posts `{ messages }` to `/api/ai/chat`.
|
|
15
|
+
2. Backend calls `ai.chatStream({ model, messages })` against
|
|
16
|
+
`NEXUS_AI_URL` (default `http://localhost:8000/v1`).
|
|
17
|
+
3. Each chunk's delta is written as `data: {"delta":"..."}` SSE events;
|
|
18
|
+
termination is `data: [DONE]`.
|
|
19
|
+
4. The frontend's reader splits on `\n\n`, parses each event, and appends
|
|
20
|
+
deltas to the in-progress assistant message. *Stop* aborts the fetch.
|
|
21
|
+
|
|
22
|
+
## Configuration
|
|
23
|
+
|
|
24
|
+
| Env var | Default | Purpose |
|
|
25
|
+
|------------------|-----------------------------|----------------------|
|
|
26
|
+
| `NEXUS_AI_URL` | `http://localhost:8000/v1` | AI server base URL |
|
|
27
|
+
| `NEXUS_AI_MODEL` | `default` | Model name to use |
|
|
28
|
+
|
|
29
|
+
## Run
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
# Start the AI server first (or set NEXUS_AI_URL to a reachable one)
|
|
33
|
+
npx nexus dev
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
If no AI server is reachable the endpoint returns an SSE error event and the
|
|
37
|
+
UI shows it above the input box.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { defineRoutes } from '@bhooai/nexus-core';
|
|
2
|
+
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
3
|
+
|
|
4
|
+
// Points at the local Nexus AI server (OpenAI-compatible FastAPI).
|
|
5
|
+
const ai = new AiClient({ serverUrl: process.env.NEXUS_AI_URL ?? 'http://localhost:8000/v1' });
|
|
6
|
+
|
|
7
|
+
interface ChatMessage { role: 'system' | 'user' | 'assistant'; content: string }
|
|
8
|
+
|
|
9
|
+
export default defineRoutes([
|
|
10
|
+
{
|
|
11
|
+
// SSE proxy: pipes the AI server's streamed chat completion to the browser.
|
|
12
|
+
method: 'POST',
|
|
13
|
+
path: '/api/ai/chat',
|
|
14
|
+
handler: async (ctx) => {
|
|
15
|
+
const body = (ctx.body ?? {}) as { messages?: ChatMessage[] };
|
|
16
|
+
if (!Array.isArray(body.messages) || body.messages.length === 0) {
|
|
17
|
+
return ctx.json({ error: 'messages[] is required' }, 422);
|
|
18
|
+
}
|
|
19
|
+
ctx.setHeader('content-type', 'text/event-stream');
|
|
20
|
+
ctx.setHeader('cache-control', 'no-cache');
|
|
21
|
+
ctx.setHeader('connection', 'keep-alive');
|
|
22
|
+
ctx.res.write(': connected\n\n');
|
|
23
|
+
try {
|
|
24
|
+
const stream = ai.chatStream({
|
|
25
|
+
model: process.env.NEXUS_AI_MODEL ?? 'default',
|
|
26
|
+
messages: body.messages,
|
|
27
|
+
});
|
|
28
|
+
for await (const chunk of stream) {
|
|
29
|
+
const delta = chunk?.choices?.[0]?.delta?.content ?? '';
|
|
30
|
+
if (delta) ctx.res.write(`data: ${JSON.stringify({ delta })}\n\n`);
|
|
31
|
+
}
|
|
32
|
+
ctx.res.write('data: [DONE]\n\n');
|
|
33
|
+
} catch (err) {
|
|
34
|
+
ctx.res.write(`data: ${JSON.stringify({ error: (err as Error).message })}\n\n`);
|
|
35
|
+
} finally {
|
|
36
|
+
ctx.res.end();
|
|
37
|
+
}
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
]);
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React, { useRef, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
interface Msg { role: 'user' | 'assistant'; content: string }
|
|
5
|
+
|
|
6
|
+
function App() {
|
|
7
|
+
const [messages, setMessages] = useState<Msg[]>([]);
|
|
8
|
+
const [draft, setDraft] = useState('');
|
|
9
|
+
const [streaming, setStreaming] = useState(false);
|
|
10
|
+
const [error, setError] = useState('');
|
|
11
|
+
const abortRef = useRef<AbortController | null>(null);
|
|
12
|
+
|
|
13
|
+
const send = async () => {
|
|
14
|
+
const text = draft.trim();
|
|
15
|
+
if (!text || streaming) return;
|
|
16
|
+
setDraft('');
|
|
17
|
+
setError('');
|
|
18
|
+
|
|
19
|
+
const history = [...messages, { role: 'user', content: text } as Msg];
|
|
20
|
+
setMessages([...history, { role: 'assistant', content: '' }]);
|
|
21
|
+
setStreaming(true);
|
|
22
|
+
|
|
23
|
+
const controller = new AbortController();
|
|
24
|
+
abortRef.current = controller;
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const res = await fetch('/api/ai/chat', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: { 'content-type': 'application/json' },
|
|
30
|
+
body: JSON.stringify({ messages: history }),
|
|
31
|
+
signal: controller.signal,
|
|
32
|
+
});
|
|
33
|
+
if (!res.ok || !res.body) throw new Error(`HTTP ${res.status}`);
|
|
34
|
+
|
|
35
|
+
// Consume the SSE stream manually with ReadableStream.
|
|
36
|
+
const reader = res.body.getReader();
|
|
37
|
+
const decoder = new TextDecoder();
|
|
38
|
+
let buffer = '';
|
|
39
|
+
for (;;) {
|
|
40
|
+
const { done, value } = await reader.read();
|
|
41
|
+
if (done) break;
|
|
42
|
+
buffer += decoder.decode(value, { stream: true });
|
|
43
|
+
const events = buffer.split('\n\n');
|
|
44
|
+
buffer = events.pop() ?? '';
|
|
45
|
+
for (const raw of events) {
|
|
46
|
+
const line = raw.replace(/^data: /, '').trim();
|
|
47
|
+
if (!line || line.startsWith(':')) continue;
|
|
48
|
+
if (line === '[DONE]') break;
|
|
49
|
+
const data = JSON.parse(line) as { delta?: string; error?: string };
|
|
50
|
+
if (data.error) setError(data.error);
|
|
51
|
+
if (data.delta) {
|
|
52
|
+
setMessages((m) => {
|
|
53
|
+
const copy = [...m];
|
|
54
|
+
const last = copy[copy.length - 1];
|
|
55
|
+
copy[copy.length - 1] = { ...last, content: last.content + data.delta };
|
|
56
|
+
return copy;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if ((err as Error).name !== 'AbortError') setError((err as Error).message);
|
|
63
|
+
} finally {
|
|
64
|
+
setStreaming(false);
|
|
65
|
+
abortRef.current = null;
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const stop = () => abortRef.current?.abort();
|
|
70
|
+
|
|
71
|
+
return (
|
|
72
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
|
|
73
|
+
<h1>AI chat</h1>
|
|
74
|
+
|
|
75
|
+
<div style={{ border: '1px solid #ccc', borderRadius: 8, padding: '1rem', minHeight: 300, maxHeight: 440, overflow: 'auto', background: '#fafafa', marginBottom: '1rem' }}>
|
|
76
|
+
{messages.length === 0 && <p style={{ color: '#666' }}>Ask anything — responses stream token-by-token.</p>}
|
|
77
|
+
{messages.map((m, i) => (
|
|
78
|
+
<div key={i} style={{ marginBottom: '0.75rem' }}>
|
|
79
|
+
<strong style={{ color: m.role === 'user' ? '#06c' : '#060' }}>
|
|
80
|
+
{m.role === 'user' ? 'You' : 'AI'}
|
|
81
|
+
</strong>
|
|
82
|
+
<div style={{ whiteSpace: 'pre-wrap' }}>{m.content || (streaming && i === messages.length - 1 ? '...' : '')}</div>
|
|
83
|
+
</div>
|
|
84
|
+
))}
|
|
85
|
+
</div>
|
|
86
|
+
|
|
87
|
+
{error && <p style={{ color: '#c00' }}>{error}</p>}
|
|
88
|
+
|
|
89
|
+
<div style={{ display: 'flex', gap: '0.5rem' }}>
|
|
90
|
+
<input
|
|
91
|
+
value={draft}
|
|
92
|
+
onChange={(e) => setDraft(e.target.value)}
|
|
93
|
+
onKeyDown={(e) => e.key === 'Enter' && send()}
|
|
94
|
+
placeholder="Type a message..."
|
|
95
|
+
style={{ flex: 1, padding: '0.5rem' }}
|
|
96
|
+
disabled={streaming}
|
|
97
|
+
/>
|
|
98
|
+
{streaming ? <button onClick={stop}>Stop</button> : <button onClick={send}>Send</button>}
|
|
99
|
+
</div>
|
|
100
|
+
</main>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const el = document.getElementById('root');
|
|
105
|
+
if (el) createRoot(el).render(<App />);
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Blog CRUD example
|
|
2
|
+
|
|
3
|
+
Posts + authors + comments with a REST API and an equivalent GraphQL subgraph.
|
|
4
|
+
Best starting point for the `nexus-data` ODM.
|
|
5
|
+
|
|
6
|
+
## What's here
|
|
7
|
+
|
|
8
|
+
- `models/Post.ts`, `models/Author.ts`, `models/Comment.ts` — ODM documents
|
|
9
|
+
- `routes/posts.ts` — full REST CRUD (`GET/POST /api/posts`, `GET/PUT/DELETE /api/posts/:id`, `POST /api/posts/:id/comments`)
|
|
10
|
+
- `graphql/post.graph.ts` — federation subgraph: `typeDefs` + resolvers for posts, nested author + comments, and a `publishPost` mutation
|
|
11
|
+
- `apps/frontend/src/main.tsx` — post list + create form (REST)
|
|
12
|
+
|
|
13
|
+
## Conventions shown
|
|
14
|
+
|
|
15
|
+
- `model()` + `Schema` from `@bhooai/nexus-data`, with `index: true` fields and
|
|
16
|
+
`default: () => new Date()` timestamps.
|
|
17
|
+
- Slug generation and find-or-create author on post creation.
|
|
18
|
+
- Deleting a post cascades to its comments.
|
|
19
|
+
|
|
20
|
+
## GraphQL
|
|
21
|
+
|
|
22
|
+
The subgraph is auto-discovered from `graphql/*.graph.ts` and serves when the
|
|
23
|
+
GraphQL gateway is enabled in config. Try:
|
|
24
|
+
|
|
25
|
+
```graphql
|
|
26
|
+
query {
|
|
27
|
+
posts(publishedOnly: true) {
|
|
28
|
+
id
|
|
29
|
+
title
|
|
30
|
+
author { name }
|
|
31
|
+
comments { name text }
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
mutation { publishPost(id: "<id>") { id published } }
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Run
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npx nexus dev
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Create a post, toggle publish, delete — all through the UI or directly against
|
|
45
|
+
`/api/posts`.
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GraphQL subgraph for the blog. Auto-discovered from graphql/*.graph.ts and
|
|
3
|
+
* composed into the gateway when `config.graphql` is enabled.
|
|
4
|
+
*/
|
|
5
|
+
import { defineSubgraph } from '@bhooai/nexus-graphql';
|
|
6
|
+
import { Post } from '../models/Post.js';
|
|
7
|
+
import { Author } from '../models/Author.js';
|
|
8
|
+
import { Comment } from '../models/Comment.js';
|
|
9
|
+
|
|
10
|
+
export const typeDefs = /* GraphQL */ `
|
|
11
|
+
type Author @key(fields: "id") {
|
|
12
|
+
id: ID!
|
|
13
|
+
name: String!
|
|
14
|
+
email: String!
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
type Comment {
|
|
18
|
+
id: ID!
|
|
19
|
+
name: String!
|
|
20
|
+
text: String!
|
|
21
|
+
createdAt: String!
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
type Post @key(fields: "id") {
|
|
25
|
+
id: ID!
|
|
26
|
+
title: String!
|
|
27
|
+
slug: String!
|
|
28
|
+
body: String!
|
|
29
|
+
published: Boolean!
|
|
30
|
+
author: Author
|
|
31
|
+
comments: [Comment!]!
|
|
32
|
+
createdAt: String!
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
type Query {
|
|
36
|
+
posts(publishedOnly: Boolean): [Post!]!
|
|
37
|
+
post(id: ID!): Post
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
type Mutation {
|
|
41
|
+
publishPost(id: ID!): Post
|
|
42
|
+
}
|
|
43
|
+
`;
|
|
44
|
+
|
|
45
|
+
const toPost = (p: Record<string, any>) => ({
|
|
46
|
+
id: String(p._id),
|
|
47
|
+
title: p.title,
|
|
48
|
+
slug: p.slug,
|
|
49
|
+
body: p.body,
|
|
50
|
+
published: !!p.published,
|
|
51
|
+
authorId: p.authorId,
|
|
52
|
+
createdAt: p.createdAt instanceof Date ? p.createdAt.toISOString() : String(p.createdAt),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
export default defineSubgraph({
|
|
56
|
+
name: 'blog',
|
|
57
|
+
typeDefs,
|
|
58
|
+
resolvers: {
|
|
59
|
+
Query: {
|
|
60
|
+
posts: async (_parent, args) => {
|
|
61
|
+
const filter = args.publishedOnly ? { published: true } : {};
|
|
62
|
+
return (await Post.find(filter, { sort: { createdAt: -1 } })).map(toPost);
|
|
63
|
+
},
|
|
64
|
+
post: async (_parent, args) => {
|
|
65
|
+
const post = await Post.findById(args.id);
|
|
66
|
+
return post ? toPost(post) : null;
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
Mutation: {
|
|
70
|
+
publishPost: async (_parent, args) => {
|
|
71
|
+
const post = await Post.findOneAndUpdate(
|
|
72
|
+
{ _id: args.id },
|
|
73
|
+
{ $set: { published: true, updatedAt: new Date() } },
|
|
74
|
+
);
|
|
75
|
+
return post ? toPost(post) : null;
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
Post: {
|
|
79
|
+
author: async (parent) => {
|
|
80
|
+
const author = await Author.findById(parent.authorId);
|
|
81
|
+
if (!author) return null;
|
|
82
|
+
const a = author as unknown as { _id: unknown; name: string; email: string };
|
|
83
|
+
return { id: String(a._id), name: a.name, email: a.email };
|
|
84
|
+
},
|
|
85
|
+
comments: async (parent) => {
|
|
86
|
+
const comments = await Comment.find({ postId: parent.id }, { sort: { createdAt: 1 } });
|
|
87
|
+
return comments.map((c) => {
|
|
88
|
+
const d = c as unknown as { _id: unknown; name: string; text: string; createdAt: Date | string };
|
|
89
|
+
return {
|
|
90
|
+
id: String(d._id),
|
|
91
|
+
name: d.name,
|
|
92
|
+
text: d.text,
|
|
93
|
+
createdAt: d.createdAt instanceof Date ? d.createdAt.toISOString() : String(d.createdAt),
|
|
94
|
+
};
|
|
95
|
+
});
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
});
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
const authorSchema = new Schema({
|
|
4
|
+
name: { type: String, required: true },
|
|
5
|
+
email: { type: String, required: true, index: true },
|
|
6
|
+
bio: { type: String },
|
|
7
|
+
createdAt: { type: Date, default: () => new Date() },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const Author = model('authors', authorSchema);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
const commentSchema = new Schema({
|
|
4
|
+
postId: { type: String, required: true, index: true },
|
|
5
|
+
name: { type: String, required: true },
|
|
6
|
+
text: { type: String, required: true },
|
|
7
|
+
createdAt: { type: Date, default: () => new Date() },
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
export const Comment = model('comments', commentSchema);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
const postSchema = new Schema({
|
|
4
|
+
title: { type: String, required: true },
|
|
5
|
+
slug: { type: String, required: true, index: true },
|
|
6
|
+
body: { type: String, required: true },
|
|
7
|
+
authorId: { type: String, required: true, index: true },
|
|
8
|
+
published: { type: Boolean, default: false },
|
|
9
|
+
createdAt: { type: Date, default: () => new Date(), index: true },
|
|
10
|
+
updatedAt: { type: Date, default: () => new Date() },
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
export const Post = model('posts', postSchema);
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { defineRoutes, NotFoundError } from '@bhooai/nexus-core';
|
|
2
|
+
import { Post } from '../models/Post.js';
|
|
3
|
+
import { Author } from '../models/Author.js';
|
|
4
|
+
import { Comment } from '../models/Comment.js';
|
|
5
|
+
|
|
6
|
+
const slugify = (s: string) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
7
|
+
|
|
8
|
+
/** Raw shape of a hydrated Post document (property access proxies to _doc). */
|
|
9
|
+
interface PostDoc {
|
|
10
|
+
_id: unknown;
|
|
11
|
+
title: string;
|
|
12
|
+
slug: string;
|
|
13
|
+
body: string;
|
|
14
|
+
authorId: string;
|
|
15
|
+
published: boolean;
|
|
16
|
+
createdAt: Date;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** Serialize a Post document for JSON responses. */
|
|
20
|
+
function toJson(p: unknown) {
|
|
21
|
+
const d = p as PostDoc;
|
|
22
|
+
return {
|
|
23
|
+
id: String(d._id),
|
|
24
|
+
title: d.title,
|
|
25
|
+
slug: d.slug,
|
|
26
|
+
body: d.body,
|
|
27
|
+
authorId: d.authorId,
|
|
28
|
+
published: d.published,
|
|
29
|
+
createdAt: d.createdAt,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function defined(obj: Record<string, unknown>) {
|
|
34
|
+
return Object.fromEntries(Object.entries(obj).filter(([, v]) => v !== undefined));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default defineRoutes([
|
|
38
|
+
{
|
|
39
|
+
method: 'GET',
|
|
40
|
+
path: '/api/posts',
|
|
41
|
+
handler: async (ctx) => {
|
|
42
|
+
const posts = await Post.find({}, { sort: { createdAt: -1 } });
|
|
43
|
+
ctx.json({ posts: posts.map(toJson) });
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
method: 'POST',
|
|
48
|
+
path: '/api/posts',
|
|
49
|
+
handler: async (ctx) => {
|
|
50
|
+
const body = (ctx.body ?? {}) as { title?: string; body?: string; authorName?: string; authorEmail?: string; published?: boolean };
|
|
51
|
+
if (!body.title?.trim() || !body.body?.trim()) {
|
|
52
|
+
return ctx.json({ error: 'title and body are required' }, 422);
|
|
53
|
+
}
|
|
54
|
+
// Find-or-create the author (demo convenience; real apps use auth).
|
|
55
|
+
const email = body.authorEmail ?? 'anon@example.com';
|
|
56
|
+
let author = await Author.findOne({ email });
|
|
57
|
+
if (!author) {
|
|
58
|
+
[author] = await Author.create({ email, name: body.authorName ?? email.split('@')[0] });
|
|
59
|
+
}
|
|
60
|
+
const authorId = String((author as unknown as { _id: unknown })._id);
|
|
61
|
+
const [post] = await Post.create({
|
|
62
|
+
title: body.title.trim(),
|
|
63
|
+
slug: `${slugify(body.title)}-${Date.now().toString(36)}`,
|
|
64
|
+
body: body.body,
|
|
65
|
+
authorId,
|
|
66
|
+
published: !!body.published,
|
|
67
|
+
updatedAt: new Date(),
|
|
68
|
+
});
|
|
69
|
+
ctx.json({ post: toJson(post) }, 201);
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
method: 'GET',
|
|
74
|
+
path: '/api/posts/:id',
|
|
75
|
+
handler: async (ctx) => {
|
|
76
|
+
const post = await Post.findById(ctx.params.id);
|
|
77
|
+
if (!post) throw new NotFoundError('post not found');
|
|
78
|
+
const comments = await Comment.find({ postId: ctx.params.id }, { sort: { createdAt: 1 } });
|
|
79
|
+
ctx.json({ post: toJson(post), comments: comments.map((c) => (c as unknown as { toObject(): Record<string, unknown> }).toObject?.() ?? c) });
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
method: 'PUT',
|
|
84
|
+
path: '/api/posts/:id',
|
|
85
|
+
handler: async (ctx) => {
|
|
86
|
+
const body = (ctx.body ?? {}) as { title?: string; body?: string; published?: boolean };
|
|
87
|
+
const updated = await Post.findOneAndUpdate(
|
|
88
|
+
{ _id: ctx.params.id },
|
|
89
|
+
{ $set: { ...defined({ title: body.title, body: body.body, published: body.published }), updatedAt: new Date() } },
|
|
90
|
+
);
|
|
91
|
+
if (!updated) throw new NotFoundError('post not found');
|
|
92
|
+
ctx.json({ post: toJson(updated) });
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
method: 'DELETE',
|
|
97
|
+
path: '/api/posts/:id',
|
|
98
|
+
handler: async (ctx) => {
|
|
99
|
+
const deleted = await Post.findOneAndDelete({ _id: ctx.params.id });
|
|
100
|
+
if (!deleted) throw new NotFoundError('post not found');
|
|
101
|
+
await Comment.deleteMany({ postId: ctx.params.id });
|
|
102
|
+
ctx.json({ ok: true });
|
|
103
|
+
},
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
method: 'POST',
|
|
107
|
+
path: '/api/posts/:id/comments',
|
|
108
|
+
handler: async (ctx) => {
|
|
109
|
+
const body = (ctx.body ?? {}) as { name?: string; text?: string };
|
|
110
|
+
if (!body.name?.trim() || !body.text?.trim()) {
|
|
111
|
+
return ctx.json({ error: 'name and text are required' }, 422);
|
|
112
|
+
}
|
|
113
|
+
const post = await Post.findById(ctx.params.id);
|
|
114
|
+
if (!post) throw new NotFoundError('post not found');
|
|
115
|
+
const [comment] = await Comment.create({ postId: ctx.params.id, name: body.name, text: body.text });
|
|
116
|
+
ctx.json({ comment: (comment as unknown as { toObject(): Record<string, unknown> }).toObject() }, 201);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
]);
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
import { createRoot } from 'react-dom/client';
|
|
3
|
+
|
|
4
|
+
interface Post {
|
|
5
|
+
id: string;
|
|
6
|
+
title: string;
|
|
7
|
+
slug: string;
|
|
8
|
+
body: string;
|
|
9
|
+
published: boolean;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function App() {
|
|
14
|
+
const [posts, setPosts] = useState<Post[]>([]);
|
|
15
|
+
const [title, setTitle] = useState('');
|
|
16
|
+
const [body, setBody] = useState('');
|
|
17
|
+
const [authorName, setAuthorName] = useState('anonymous');
|
|
18
|
+
const [published, setPublished] = useState(false);
|
|
19
|
+
const [error, setError] = useState('');
|
|
20
|
+
|
|
21
|
+
const load = async () => {
|
|
22
|
+
try {
|
|
23
|
+
const res = await fetch('/api/posts');
|
|
24
|
+
const data = await res.json();
|
|
25
|
+
setPosts(data.posts ?? []);
|
|
26
|
+
} catch (err) { setError((err as Error).message); }
|
|
27
|
+
};
|
|
28
|
+
useEffect(() => { load(); }, []);
|
|
29
|
+
|
|
30
|
+
const create = async () => {
|
|
31
|
+
setError('');
|
|
32
|
+
if (!title.trim() || !body.trim()) { setError('title and body are required'); return; }
|
|
33
|
+
try {
|
|
34
|
+
const res = await fetch('/api/posts', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: { 'content-type': 'application/json' },
|
|
37
|
+
body: JSON.stringify({ title, body, authorName, published }),
|
|
38
|
+
});
|
|
39
|
+
const data = await res.json();
|
|
40
|
+
if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
|
|
41
|
+
setTitle(''); setBody(''); setPublished(false);
|
|
42
|
+
load();
|
|
43
|
+
} catch (err) { setError((err as Error).message); }
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const remove = async (id: string) => {
|
|
47
|
+
await fetch(`/api/posts/${id}`, { method: 'DELETE' }).catch(() => {});
|
|
48
|
+
load();
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const togglePublish = async (p: Post) => {
|
|
52
|
+
await fetch(`/api/posts/${p.id}`, {
|
|
53
|
+
method: 'PUT',
|
|
54
|
+
headers: { 'content-type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ published: !p.published }),
|
|
56
|
+
}).catch(() => {});
|
|
57
|
+
load();
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
<main style={{ fontFamily: 'system-ui', maxWidth: 720, margin: '2rem auto', padding: '0 1rem' }}>
|
|
62
|
+
<h1>Blog CRUD</h1>
|
|
63
|
+
|
|
64
|
+
<section style={{ border: '1px solid #ccc', borderRadius: 8, padding: '1rem', marginBottom: '1.5rem', background: '#fafafa' }}>
|
|
65
|
+
<h2 style={{ fontSize: '1rem', marginTop: 0 }}>New post</h2>
|
|
66
|
+
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
|
|
67
|
+
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder="Title" style={{ flex: 2, padding: '0.5rem' }} />
|
|
68
|
+
<input value={authorName} onChange={(e) => setAuthorName(e.target.value)} placeholder="Author" style={{ flex: 1, padding: '0.5rem' }} />
|
|
69
|
+
</div>
|
|
70
|
+
<textarea value={body} onChange={(e) => setBody(e.target.value)} placeholder="Write something..." rows={4} style={{ width: '100%', padding: '0.5rem', boxSizing: 'border-box' }} />
|
|
71
|
+
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', marginTop: '0.5rem' }}>
|
|
72
|
+
<label style={{ fontSize: '0.9rem' }}>
|
|
73
|
+
<input type="checkbox" checked={published} onChange={(e) => setPublished(e.target.checked)} /> publish
|
|
74
|
+
</label>
|
|
75
|
+
<button onClick={create}>Create post</button>
|
|
76
|
+
</div>
|
|
77
|
+
{error && <p style={{ color: '#c00', marginBottom: 0 }}>{error}</p>}
|
|
78
|
+
</section>
|
|
79
|
+
|
|
80
|
+
{posts.length === 0 && <p style={{ color: '#666' }}>No posts yet.</p>}
|
|
81
|
+
{posts.map((p) => (
|
|
82
|
+
<article key={p.id} style={{ borderBottom: '1px solid #e4e4e4', paddingBottom: '1rem', marginBottom: '1rem' }}>
|
|
83
|
+
<h2 style={{ fontSize: '1.1rem', margin: '0 0 0.25rem' }}>
|
|
84
|
+
{p.title}{' '}
|
|
85
|
+
<span style={{ fontSize: '0.75rem', color: p.published ? '#060' : '#a60', fontWeight: 400 }}>
|
|
86
|
+
{p.published ? 'published' : 'draft'}
|
|
87
|
+
</span>
|
|
88
|
+
</h2>
|
|
89
|
+
<p style={{ margin: '0.25rem 0', whiteSpace: 'pre-wrap' }}>{p.body}</p>
|
|
90
|
+
<div style={{ fontSize: '0.8rem', color: '#666', display: 'flex', gap: '0.75rem' }}>
|
|
91
|
+
<span>{new Date(p.createdAt).toLocaleString()}</span>
|
|
92
|
+
<button onClick={() => togglePublish(p)}>{p.published ? 'Unpublish' : 'Publish'}</button>
|
|
93
|
+
<button onClick={() => remove(p.id)}>Delete</button>
|
|
94
|
+
</div>
|
|
95
|
+
</article>
|
|
96
|
+
))}
|
|
97
|
+
</main>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const el = document.getElementById('root');
|
|
102
|
+
if (el) createRoot(el).render(<App />);
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Chat example
|
|
2
|
+
|
|
3
|
+
Realtime chat rooms demoing `nexus-realtime` + WS auto-discovery.
|
|
4
|
+
|
|
5
|
+
## What's here
|
|
6
|
+
|
|
7
|
+
- `apps/backend/src/ws/chat.room.ts` — WS room with presence + typing indicators
|
|
8
|
+
- `apps/backend/src/routes/chat.ts` — REST history endpoint
|
|
9
|
+
- `apps/backend/src/models/Message.ts` — ODM model for messages
|
|
10
|
+
- `apps/frontend/src/main.tsx` — browser chat UI (replaces the default hello-world)
|
|
11
|
+
|
|
12
|
+
## Run
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
npx nexus dev
|
|
16
|
+
# Frontend: http://localhost:3000
|
|
17
|
+
# Backend : http://localhost:4000
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
Open two browser windows side-by-side, pick a room + username, and chat.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { model, Schema } from '@bhooai/nexus-data';
|
|
2
|
+
|
|
3
|
+
const messageSchema = new Schema({
|
|
4
|
+
roomId: { type: String, required: true, index: true },
|
|
5
|
+
userId: { type: String, required: true },
|
|
6
|
+
username: { type: String, required: true },
|
|
7
|
+
text: { type: String, required: true },
|
|
8
|
+
createdAt: { type: Date, default: () => new Date(), index: true },
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
export const Message = model('messages', messageSchema);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { defineRoutes } from '@bhooai/nexus-core';
|
|
2
|
+
import { Message } from '../models/Message.js';
|
|
3
|
+
|
|
4
|
+
export default defineRoutes([
|
|
5
|
+
{
|
|
6
|
+
method: 'GET',
|
|
7
|
+
path: '/api/chat/rooms/:roomId/messages',
|
|
8
|
+
handler: async (ctx) => {
|
|
9
|
+
const limit = Math.min(parseInt((ctx.query.limit as string) ?? '50', 10), 200);
|
|
10
|
+
const messages = await Message.find(
|
|
11
|
+
{ roomId: ctx.params.roomId },
|
|
12
|
+
{ sort: { createdAt: -1 }, limit },
|
|
13
|
+
);
|
|
14
|
+
ctx.json({ messages: messages.reverse() });
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
]);
|