@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 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 |
@@ -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"}