@ereo/rpc 0.1.6
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/README.md +357 -0
- package/dist/client.d.ts +11 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +433 -0
- package/dist/src/client.d.ts +56 -0
- package/dist/src/client.d.ts.map +1 -0
- package/dist/src/context-bridge.d.ts +79 -0
- package/dist/src/context-bridge.d.ts.map +1 -0
- package/dist/src/hooks.d.ts +80 -0
- package/dist/src/hooks.d.ts.map +1 -0
- package/dist/src/index.d.ts +18 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +616 -0
- package/dist/src/middleware.d.ts +123 -0
- package/dist/src/middleware.d.ts.map +1 -0
- package/dist/src/plugin.d.ts +62 -0
- package/dist/src/plugin.d.ts.map +1 -0
- package/dist/src/procedure.d.ts +98 -0
- package/dist/src/procedure.d.ts.map +1 -0
- package/dist/src/router.d.ts +66 -0
- package/dist/src/router.d.ts.map +1 -0
- package/dist/src/types.d.ts +166 -0
- package/dist/src/types.d.ts.map +1 -0
- package/package.json +50 -0
package/README.md
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# @ereo/rpc
|
|
2
|
+
|
|
3
|
+
Typed RPC layer for EreoJS with chainable middleware and Bun WebSocket subscriptions.
|
|
4
|
+
|
|
5
|
+
## Features
|
|
6
|
+
|
|
7
|
+
- **End-to-end type inference** - Define once on server, get types on client
|
|
8
|
+
- **Chainable middleware** - Build reusable procedure pipelines (`procedure.use(auth).use(logging)`)
|
|
9
|
+
- **WebSocket subscriptions** - Real-time data with Bun's native WebSocket support
|
|
10
|
+
- **Auto-reconnect** - Client automatically reconnects with exponential backoff
|
|
11
|
+
- **React hooks** - `useQuery`, `useMutation`, `useSubscription`
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
### 1. Define procedures with middleware
|
|
16
|
+
|
|
17
|
+
```typescript
|
|
18
|
+
// api/procedures.ts
|
|
19
|
+
import { procedure, errors } from '@ereo/rpc';
|
|
20
|
+
|
|
21
|
+
// Base procedure - no middleware
|
|
22
|
+
export const publicProcedure = procedure;
|
|
23
|
+
|
|
24
|
+
// Protected procedure - requires authentication
|
|
25
|
+
export const protectedProcedure = procedure.use(async ({ ctx, next }) => {
|
|
26
|
+
const user = ctx.ctx.user;
|
|
27
|
+
if (!user) {
|
|
28
|
+
return {
|
|
29
|
+
ok: false,
|
|
30
|
+
error: { code: 'UNAUTHORIZED', message: 'Must be logged in' },
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
// Extend context with user
|
|
34
|
+
return next({ ...ctx, user });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
// Admin procedure - requires admin role
|
|
38
|
+
export const adminProcedure = protectedProcedure.use(async ({ ctx, next }) => {
|
|
39
|
+
if (ctx.user.role !== 'admin') {
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: { code: 'FORBIDDEN', message: 'Admin access required' },
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
return next(ctx);
|
|
46
|
+
});
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
### 2. Create router
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// api/router.ts
|
|
53
|
+
import { createRouter } from '@ereo/rpc';
|
|
54
|
+
import { z } from 'zod';
|
|
55
|
+
import { publicProcedure, protectedProcedure, adminProcedure } from './procedures';
|
|
56
|
+
import { db, postEvents } from './db';
|
|
57
|
+
|
|
58
|
+
export const api = createRouter({
|
|
59
|
+
health: publicProcedure.query(() => ({ status: 'ok', time: Date.now() })),
|
|
60
|
+
|
|
61
|
+
users: {
|
|
62
|
+
me: protectedProcedure.query(({ user }) => user),
|
|
63
|
+
|
|
64
|
+
list: adminProcedure.query(async () => {
|
|
65
|
+
return db.user.findMany();
|
|
66
|
+
}),
|
|
67
|
+
},
|
|
68
|
+
|
|
69
|
+
posts: {
|
|
70
|
+
list: publicProcedure.query(async () => {
|
|
71
|
+
return db.post.findMany({ orderBy: { createdAt: 'desc' } });
|
|
72
|
+
}),
|
|
73
|
+
|
|
74
|
+
create: protectedProcedure.mutation(
|
|
75
|
+
z.object({ title: z.string().min(1), content: z.string() }),
|
|
76
|
+
async ({ input, user }) => {
|
|
77
|
+
const post = await db.post.create({
|
|
78
|
+
data: { ...input, authorId: user.id },
|
|
79
|
+
});
|
|
80
|
+
postEvents.emit('created', post);
|
|
81
|
+
return post;
|
|
82
|
+
}
|
|
83
|
+
),
|
|
84
|
+
|
|
85
|
+
// Real-time subscription
|
|
86
|
+
onCreated: protectedProcedure.subscription(async function* ({ user }) {
|
|
87
|
+
console.log(`User ${user.id} subscribed to post updates`);
|
|
88
|
+
|
|
89
|
+
for await (const post of postEvents.on('created')) {
|
|
90
|
+
yield post;
|
|
91
|
+
}
|
|
92
|
+
}),
|
|
93
|
+
},
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
export type Api = typeof api;
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
### 3. Configure server
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
// server.ts
|
|
103
|
+
import { createContext } from '@ereo/core';
|
|
104
|
+
import { rpcPlugin } from '@ereo/rpc';
|
|
105
|
+
import { api } from './api/router';
|
|
106
|
+
|
|
107
|
+
const rpc = rpcPlugin({ router: api, endpoint: '/api/rpc' });
|
|
108
|
+
|
|
109
|
+
Bun.serve({
|
|
110
|
+
port: 3000,
|
|
111
|
+
|
|
112
|
+
fetch(request, server) {
|
|
113
|
+
const ctx = createContext(request);
|
|
114
|
+
|
|
115
|
+
// Handle WebSocket upgrade for subscriptions
|
|
116
|
+
if (rpc.upgradeToWebSocket(server, request, ctx)) {
|
|
117
|
+
return; // Upgraded to WebSocket
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Handle HTTP requests
|
|
121
|
+
const url = new URL(request.url);
|
|
122
|
+
if (url.pathname === '/api/rpc') {
|
|
123
|
+
return api.handler(request, ctx);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return new Response('Not Found', { status: 404 });
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
// WebSocket handlers from RPC plugin
|
|
130
|
+
websocket: rpc.getWebSocketConfig(),
|
|
131
|
+
});
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### 4. Use on client
|
|
135
|
+
|
|
136
|
+
```typescript
|
|
137
|
+
// client.ts
|
|
138
|
+
import { createClient } from '@ereo/rpc/client';
|
|
139
|
+
import type { Api } from './api/router';
|
|
140
|
+
|
|
141
|
+
export const rpc = createClient<Api>({
|
|
142
|
+
httpEndpoint: '/api/rpc',
|
|
143
|
+
wsEndpoint: 'ws://localhost:3000/api/rpc',
|
|
144
|
+
reconnect: {
|
|
145
|
+
enabled: true,
|
|
146
|
+
maxAttempts: 10,
|
|
147
|
+
delayMs: 1000,
|
|
148
|
+
},
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// Queries (GET, cacheable)
|
|
152
|
+
const health = await rpc.health.query();
|
|
153
|
+
const me = await rpc.users.me.query();
|
|
154
|
+
const posts = await rpc.posts.list.query();
|
|
155
|
+
|
|
156
|
+
// Mutations (POST)
|
|
157
|
+
const newPost = await rpc.posts.create.mutate({
|
|
158
|
+
title: 'Hello World',
|
|
159
|
+
content: 'My first post',
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
// Subscriptions (WebSocket with auto-reconnect)
|
|
163
|
+
const unsubscribe = rpc.posts.onCreated.subscribe({
|
|
164
|
+
onData: (post) => console.log('New post:', post),
|
|
165
|
+
onError: (err) => console.error('Subscription error:', err),
|
|
166
|
+
onComplete: () => console.log('Subscription ended'),
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
// Later: unsubscribe()
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
### 5. React hooks
|
|
173
|
+
|
|
174
|
+
```tsx
|
|
175
|
+
import { useQuery, useMutation, useSubscription } from '@ereo/rpc/client';
|
|
176
|
+
import { rpc } from './client';
|
|
177
|
+
|
|
178
|
+
function PostList() {
|
|
179
|
+
// Query with auto-refetch
|
|
180
|
+
const { data: posts, isLoading, refetch } = useQuery(rpc.posts.list, {
|
|
181
|
+
refetchInterval: 30000, // Refetch every 30s
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
// Mutation with optimistic updates
|
|
185
|
+
const { mutate: createPost, isPending } = useMutation(rpc.posts.create, {
|
|
186
|
+
onSuccess: () => refetch(),
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
// Real-time subscription
|
|
190
|
+
const { data: latestPost, status } = useSubscription(rpc.posts.onCreated);
|
|
191
|
+
|
|
192
|
+
if (isLoading) return <div>Loading...</div>;
|
|
193
|
+
|
|
194
|
+
return (
|
|
195
|
+
<div>
|
|
196
|
+
<button
|
|
197
|
+
onClick={() => createPost({ title: 'New Post', content: '...' })}
|
|
198
|
+
disabled={isPending}
|
|
199
|
+
>
|
|
200
|
+
Create Post
|
|
201
|
+
</button>
|
|
202
|
+
|
|
203
|
+
{latestPost && (
|
|
204
|
+
<div className="notification">
|
|
205
|
+
New post: {latestPost.title}
|
|
206
|
+
</div>
|
|
207
|
+
)}
|
|
208
|
+
|
|
209
|
+
<ul>
|
|
210
|
+
{posts?.map((post) => (
|
|
211
|
+
<li key={post.id}>{post.title}</li>
|
|
212
|
+
))}
|
|
213
|
+
</ul>
|
|
214
|
+
</div>
|
|
215
|
+
);
|
|
216
|
+
}
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
## Middleware
|
|
220
|
+
|
|
221
|
+
Middleware functions can:
|
|
222
|
+
1. **Transform context** - Add data for downstream procedures
|
|
223
|
+
2. **Short-circuit** - Return an error to stop execution
|
|
224
|
+
3. **Chain** - Compose multiple middleware together
|
|
225
|
+
|
|
226
|
+
```typescript
|
|
227
|
+
import { procedure, type MiddlewareFn, type BaseContext } from '@ereo/rpc';
|
|
228
|
+
|
|
229
|
+
// Type-safe middleware that adds `user` to context
|
|
230
|
+
type AuthContext = BaseContext & { user: User };
|
|
231
|
+
|
|
232
|
+
const authMiddleware: MiddlewareFn<BaseContext, AuthContext> = async ({ ctx, next }) => {
|
|
233
|
+
const token = ctx.request.headers.get('Authorization');
|
|
234
|
+
const user = await verifyToken(token);
|
|
235
|
+
|
|
236
|
+
if (!user) {
|
|
237
|
+
return { ok: false, error: { code: 'UNAUTHORIZED', message: 'Invalid token' } };
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
return next({ ...ctx, user });
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
// Logging middleware
|
|
244
|
+
const logMiddleware: MiddlewareFn<BaseContext, BaseContext> = async ({ ctx, next }) => {
|
|
245
|
+
const start = performance.now();
|
|
246
|
+
const result = await next(ctx);
|
|
247
|
+
console.log(`Request took ${performance.now() - start}ms`);
|
|
248
|
+
return result;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Compose middleware
|
|
252
|
+
const protectedProcedure = procedure
|
|
253
|
+
.use(logMiddleware)
|
|
254
|
+
.use(authMiddleware);
|
|
255
|
+
|
|
256
|
+
// Now all procedures using `protectedProcedure` have `user` in context
|
|
257
|
+
const api = createRouter({
|
|
258
|
+
me: protectedProcedure.query(({ user }) => user), // `user` is typed!
|
|
259
|
+
});
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
## Subscriptions
|
|
263
|
+
|
|
264
|
+
Subscriptions use async generators and Bun's native WebSocket:
|
|
265
|
+
|
|
266
|
+
```typescript
|
|
267
|
+
// Server: Define subscription
|
|
268
|
+
const api = createRouter({
|
|
269
|
+
countdown: procedure.subscription(
|
|
270
|
+
z.object({ from: z.number() }),
|
|
271
|
+
async function* ({ input }) {
|
|
272
|
+
for (let i = input.from; i >= 0; i--) {
|
|
273
|
+
yield { count: i };
|
|
274
|
+
await new Promise((r) => setTimeout(r, 1000));
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
),
|
|
278
|
+
|
|
279
|
+
// Event-based subscription
|
|
280
|
+
notifications: protectedProcedure.subscription(async function* ({ user }) {
|
|
281
|
+
const channel = pubsub.subscribe(`user:${user.id}:notifications`);
|
|
282
|
+
try {
|
|
283
|
+
for await (const notification of channel) {
|
|
284
|
+
yield notification;
|
|
285
|
+
}
|
|
286
|
+
} finally {
|
|
287
|
+
channel.unsubscribe();
|
|
288
|
+
}
|
|
289
|
+
}),
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// Client: Subscribe with input
|
|
293
|
+
const unsub = rpc.countdown.subscribe(
|
|
294
|
+
{ from: 10 },
|
|
295
|
+
{
|
|
296
|
+
onData: ({ count }) => console.log(count),
|
|
297
|
+
onComplete: () => console.log('Done!'),
|
|
298
|
+
}
|
|
299
|
+
);
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
## Error Handling
|
|
303
|
+
|
|
304
|
+
```typescript
|
|
305
|
+
import { errors, RPCError } from '@ereo/rpc';
|
|
306
|
+
|
|
307
|
+
// Built-in errors
|
|
308
|
+
throw errors.unauthorized('Must be logged in');
|
|
309
|
+
throw errors.forbidden('Admin only');
|
|
310
|
+
throw errors.notFound('Post not found');
|
|
311
|
+
throw errors.badRequest('Invalid input');
|
|
312
|
+
|
|
313
|
+
// Custom errors
|
|
314
|
+
throw new RPCError('RATE_LIMITED', 'Too many requests', 429);
|
|
315
|
+
|
|
316
|
+
// Client-side
|
|
317
|
+
try {
|
|
318
|
+
await rpc.posts.create.mutate({ title: '' });
|
|
319
|
+
} catch (error) {
|
|
320
|
+
if (error.code === 'VALIDATION_ERROR') {
|
|
321
|
+
// Handle validation error
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
## Protocol
|
|
327
|
+
|
|
328
|
+
### HTTP (Queries & Mutations)
|
|
329
|
+
|
|
330
|
+
```
|
|
331
|
+
GET /api/rpc?path=posts.list&input={"limit":10}
|
|
332
|
+
POST /api/rpc { "path": ["posts", "create"], "type": "mutation", "input": {...} }
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### WebSocket (Subscriptions)
|
|
336
|
+
|
|
337
|
+
```typescript
|
|
338
|
+
// Client → Server
|
|
339
|
+
{ "type": "subscribe", "id": "sub_1", "path": ["posts", "onCreated"], "input": {} }
|
|
340
|
+
{ "type": "unsubscribe", "id": "sub_1" }
|
|
341
|
+
|
|
342
|
+
// Server → Client
|
|
343
|
+
{ "type": "data", "id": "sub_1", "data": { "id": "1", "title": "..." } }
|
|
344
|
+
{ "type": "error", "id": "sub_1", "error": { "code": "...", "message": "..." } }
|
|
345
|
+
{ "type": "complete", "id": "sub_1" }
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
## Design Decisions
|
|
349
|
+
|
|
350
|
+
| Decision | Rationale |
|
|
351
|
+
|----------|-----------|
|
|
352
|
+
| Bun WebSocket | Native performance, no external dependencies |
|
|
353
|
+
| Async generators | Clean subscription API, automatic cleanup |
|
|
354
|
+
| Chainable middleware | Composable, type-safe context extension |
|
|
355
|
+
| GET for queries | Browser/CDN cacheable |
|
|
356
|
+
| Separate client entry | Tree-shaking keeps server code out |
|
|
357
|
+
| Auto-reconnect | Production-ready subscriptions out of the box |
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @ereo/rpc/client - Client-side exports
|
|
3
|
+
*
|
|
4
|
+
* Separate entry point for client bundles (tree-shaking)
|
|
5
|
+
*/
|
|
6
|
+
export { createClient } from './src/client';
|
|
7
|
+
export type { RPCClientOptions, RPCClientError } from './src/client';
|
|
8
|
+
export { useQuery, useMutation, useSubscription } from './src/hooks';
|
|
9
|
+
export type { UseQueryOptions, UseQueryResult, UseMutationOptions, UseMutationResult, UseSubscriptionOptions, UseSubscriptionResult, SubscriptionStatus, } from './src/hooks';
|
|
10
|
+
export type { InferClient, RouterDef, SubscriptionCallbacks, Unsubscribe, } from './src/types';
|
|
11
|
+
//# sourceMappingURL=client.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"client.d.ts","sourceRoot":"","sources":["../client.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAGH,OAAO,EAAE,YAAY,EAAE,MAAM,cAAc,CAAC;AAC5C,YAAY,EAAE,gBAAgB,EAAE,cAAc,EAAE,MAAM,cAAc,CAAC;AAGrE,OAAO,EAAE,QAAQ,EAAE,WAAW,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACrE,YAAY,EACV,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,iBAAiB,EACjB,sBAAsB,EACtB,qBAAqB,EACrB,kBAAkB,GACnB,MAAM,aAAa,CAAC;AAGrB,YAAY,EACV,WAAW,EACX,SAAS,EACT,qBAAqB,EACrB,WAAW,GACZ,MAAM,aAAa,CAAC"}
|