@atom-forge/rpc 0.3.2
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/LICENSE +28 -0
- package/README.en.md +612 -0
- package/README.hu.md +613 -0
- package/README.llm.md +343 -0
- package/README.md +25 -0
- package/dist/client/client-context.d.ts +45 -0
- package/dist/client/client-context.js +48 -0
- package/dist/client/create-client.d.ts +9 -0
- package/dist/client/create-client.js +277 -0
- package/dist/client/logger.d.ts +6 -0
- package/dist/client/logger.js +41 -0
- package/dist/client/middleware.d.ts +6 -0
- package/dist/client/middleware.js +7 -0
- package/dist/client/rpc-response.d.ts +27 -0
- package/dist/client/rpc-response.js +46 -0
- package/dist/client/types.d.ts +151 -0
- package/dist/client/types.js +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.js +7 -0
- package/dist/server/create-handler.d.ts +18 -0
- package/dist/server/create-handler.js +210 -0
- package/dist/server/errors.d.ts +10 -0
- package/dist/server/errors.js +14 -0
- package/dist/server/middleware.d.ts +22 -0
- package/dist/server/middleware.js +39 -0
- package/dist/server/rpc.d.ts +65 -0
- package/dist/server/rpc.js +49 -0
- package/dist/server/server-context.d.ts +79 -0
- package/dist/server/server-context.js +86 -0
- package/dist/server/types.d.ts +30 -0
- package/dist/server/types.js +1 -0
- package/dist/util/constants.d.ts +1 -0
- package/dist/util/constants.js +1 -0
- package/dist/util/cookies.d.ts +22 -0
- package/dist/util/cookies.js +54 -0
- package/dist/util/pipeline.d.ts +23 -0
- package/dist/util/pipeline.js +22 -0
- package/dist/util/string.d.ts +6 -0
- package/dist/util/string.js +11 -0
- package/dist/util/types.d.ts +5 -0
- package/dist/util/types.js +1 -0
- package/package.json +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
AtomForge — Patron License
|
|
2
|
+
Copyright (c) 2024-present Elvis Szabo
|
|
3
|
+
|
|
4
|
+
Permission is hereby granted, free of charge, to any individual or
|
|
5
|
+
non-commercial organization obtaining a copy of this software (the "Software"),
|
|
6
|
+
to use, copy, modify, and distribute the Software for non-commercial purposes,
|
|
7
|
+
subject to the following conditions:
|
|
8
|
+
|
|
9
|
+
NON-COMMERCIAL USE
|
|
10
|
+
The above rights are granted at no charge for:
|
|
11
|
+
- Personal and hobby projects
|
|
12
|
+
- Open source projects
|
|
13
|
+
- Non-profit organizations
|
|
14
|
+
|
|
15
|
+
COMMERCIAL USE
|
|
16
|
+
Any use of the Software in a for-profit context requires a paid commercial
|
|
17
|
+
license. A commercial license is granted to any individual or entity that
|
|
18
|
+
actively supports the project via GitHub Sponsors
|
|
19
|
+
(https://github.com/sponsors/atom-forge).
|
|
20
|
+
|
|
21
|
+
The license is perpetual for projects initiated during the active support
|
|
22
|
+
period. Stopping support does not revoke the license for projects already
|
|
23
|
+
in production at the time.
|
|
24
|
+
|
|
25
|
+
DISCLAIMER
|
|
26
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
27
|
+
IMPLIED. IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
|
|
28
|
+
OTHER LIABILITY ARISING FROM THE USE OF THE SOFTWARE.
|
package/README.en.md
ADDED
|
@@ -0,0 +1,612 @@
|
|
|
1
|
+
# Rpc
|
|
2
|
+
|
|
3
|
+
Rpc is a full-stack RPC (Remote Procedure Call) framework for TypeScript projects. It simplifies the communication between the client and the server by providing a type-safe API. **Framework-agnostic** — works with any Node.js or edge runtime (SvelteKit, Express, Hono, Next.js, Nuxt, etc.).
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install @atom-forge/rpc
|
|
9
|
+
pnpm add @atom-forge/rpc
|
|
10
|
+
yarn add @atom-forge/rpc
|
|
11
|
+
bun add @atom-forge/rpc
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Core Concept: End-to-End Type Safety
|
|
15
|
+
|
|
16
|
+
Rpc's main feature is providing end-to-end type safety between your server and client. You define your API on the server, then share the type of that definition with the client. This gives you autocompletion and type checking for your API calls.
|
|
17
|
+
|
|
18
|
+
**1. Define your API on the server:**
|
|
19
|
+
|
|
20
|
+
```typescript
|
|
21
|
+
// api.ts (shared API definition)
|
|
22
|
+
import { rpc } from '@atom-forge/rpc';
|
|
23
|
+
|
|
24
|
+
export const api = {
|
|
25
|
+
posts: {
|
|
26
|
+
list: rpc.query(async ({ page }: { page: number }, ctx) => {
|
|
27
|
+
// ... fetch posts
|
|
28
|
+
return { posts: [{ id: 1, title: 'Hello' }] };
|
|
29
|
+
}),
|
|
30
|
+
create: rpc.command(async ({ title }: { title: string }) => {
|
|
31
|
+
// ... create post
|
|
32
|
+
return { success: true };
|
|
33
|
+
}),
|
|
34
|
+
},
|
|
35
|
+
};
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
```typescript
|
|
39
|
+
// SvelteKit: src/routes/rpc/[...path]/+server.ts
|
|
40
|
+
import { createCoreHandler, flattenApiDefinition } from '@atom-forge/rpc';
|
|
41
|
+
import { api } from '$lib/api';
|
|
42
|
+
|
|
43
|
+
const handle = createCoreHandler(flattenApiDefinition(api));
|
|
44
|
+
|
|
45
|
+
export const GET = (event) => handle(event.request, { path: event.params.path }, event);
|
|
46
|
+
export const POST = GET;
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
**2. Use the type on the client:**
|
|
50
|
+
|
|
51
|
+
```typescript
|
|
52
|
+
// src/lib/client/rpc.ts
|
|
53
|
+
import { createClient } from '@atom-forge/rpc';
|
|
54
|
+
import type { api } from '$lib/api';
|
|
55
|
+
|
|
56
|
+
const [client, cfg] = createClient<typeof api>('/rpc');
|
|
57
|
+
|
|
58
|
+
// Every call returns a RpcResponse
|
|
59
|
+
const res = await client.posts.list.$query({ page: 1 });
|
|
60
|
+
if (res.isOK()) {
|
|
61
|
+
console.log(res.result); // typed as { posts: { id: number, title: string }[] }
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await client.posts.create.$command({ title: 'My New Post' });
|
|
65
|
+
|
|
66
|
+
export default client;
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## Framework Adapters
|
|
70
|
+
|
|
71
|
+
The `createCoreHandler` function works on standard `Request` → `Response`. Each framework needs ~2–5 lines of adapter code.
|
|
72
|
+
|
|
73
|
+
### SvelteKit
|
|
74
|
+
|
|
75
|
+
```typescript
|
|
76
|
+
// src/routes/rpc/[...path]/+server.ts
|
|
77
|
+
import { createCoreHandler, flattenApiDefinition } from '@atom-forge/rpc';
|
|
78
|
+
import { api } from '$lib/api';
|
|
79
|
+
|
|
80
|
+
const handle = createCoreHandler(flattenApiDefinition(api));
|
|
81
|
+
|
|
82
|
+
export const GET = (event) => handle(event.request, { path: event.params.path }, event);
|
|
83
|
+
export const POST = GET;
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
In SvelteKit, `ctx.adapterContext` is the `RequestEvent`, giving access to `locals`, `platform`, etc.:
|
|
87
|
+
|
|
88
|
+
```typescript
|
|
89
|
+
// ctx.adapterContext type: RequestEvent
|
|
90
|
+
const user = (ctx.adapterContext as RequestEvent).locals.user;
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
**Alternative: `hooks.server.ts`**
|
|
94
|
+
|
|
95
|
+
Instead of a route file, you can intercept RPC requests directly in the server hook — useful if you already have a `hooks.server.ts` or prefer to keep all server logic in one place:
|
|
96
|
+
|
|
97
|
+
```typescript
|
|
98
|
+
// src/hooks.server.ts
|
|
99
|
+
import { createCoreHandler, flattenApiDefinition } from '@atom-forge/rpc';
|
|
100
|
+
import { api } from '$lib/api';
|
|
101
|
+
|
|
102
|
+
const handleRpc = createCoreHandler(flattenApiDefinition(api));
|
|
103
|
+
|
|
104
|
+
export const handle = async ({ event, resolve }) => {
|
|
105
|
+
if (event.url.pathname.startsWith('/rpc/')) {
|
|
106
|
+
const path = event.url.pathname.slice('/rpc/'.length);
|
|
107
|
+
return handleRpc(event.request, { path }, event);
|
|
108
|
+
}
|
|
109
|
+
return resolve(event);
|
|
110
|
+
};
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
No route file needed. The hook runs before SvelteKit's router, so it's marginally faster and doesn't require a `src/routes/rpc/` directory.
|
|
114
|
+
|
|
115
|
+
### Express
|
|
116
|
+
|
|
117
|
+
```typescript
|
|
118
|
+
import { createCoreHandler, flattenApiDefinition } from '@atom-forge/rpc';
|
|
119
|
+
import { api } from './api';
|
|
120
|
+
|
|
121
|
+
const handle = createCoreHandler(flattenApiDefinition(api));
|
|
122
|
+
|
|
123
|
+
app.all('/rpc/:path', async (req, res) => {
|
|
124
|
+
const request = new Request(
|
|
125
|
+
`${req.protocol}://${req.get('host')}${req.originalUrl}`,
|
|
126
|
+
{ method: req.method, headers: req.headers as any, body: req.method !== 'GET' ? req : null }
|
|
127
|
+
);
|
|
128
|
+
const response = await handle(request, { path: req.params.path }, { req, res });
|
|
129
|
+
res.status(response.status);
|
|
130
|
+
response.headers.forEach((v, k) => res.setHeader(k, v));
|
|
131
|
+
res.send(Buffer.from(await response.arrayBuffer()));
|
|
132
|
+
});
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
### Hono
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import { createCoreHandler, flattenApiDefinition } from '@atom-forge/rpc';
|
|
139
|
+
import { api } from './api';
|
|
140
|
+
|
|
141
|
+
const handle = createCoreHandler(flattenApiDefinition(api));
|
|
142
|
+
|
|
143
|
+
app.all('/rpc/:path', (c) => handle(c.req.raw, { path: c.req.param('path') }, c));
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
### Next.js (App Router)
|
|
147
|
+
|
|
148
|
+
```typescript
|
|
149
|
+
// app/rpc/[...path]/route.ts
|
|
150
|
+
import { createCoreHandler, flattenApiDefinition } from '@atom-forge/rpc';
|
|
151
|
+
import { api } from '@/lib/api';
|
|
152
|
+
|
|
153
|
+
const handle = createCoreHandler(flattenApiDefinition(api));
|
|
154
|
+
|
|
155
|
+
export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
|
|
156
|
+
const { path } = await params;
|
|
157
|
+
return handle(request, { path: path.join('.') }, { request, params });
|
|
158
|
+
}
|
|
159
|
+
export const POST = GET;
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
> `path.join('.')` reassembles URL segments (`['users', 'get-all']`) into the dot-separated path (`'users.get-all'`). In Next.js 15+, `params` is a Promise — hence the `await`.
|
|
163
|
+
|
|
164
|
+
### Nuxt 3
|
|
165
|
+
|
|
166
|
+
```typescript
|
|
167
|
+
// server/routes/rpc/[...path].ts
|
|
168
|
+
import { createCoreHandler, flattenApiDefinition } from '@atom-forge/rpc';
|
|
169
|
+
import { getRouterParam, toWebRequest } from 'h3';
|
|
170
|
+
import { api } from '~/lib/api';
|
|
171
|
+
|
|
172
|
+
const handle = createCoreHandler(flattenApiDefinition(api));
|
|
173
|
+
|
|
174
|
+
export default defineEventHandler(async (event) => {
|
|
175
|
+
const path = getRouterParam(event, 'path') ?? '';
|
|
176
|
+
return handle(toWebRequest(event), { path }, event);
|
|
177
|
+
});
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
> `toWebRequest()` converts the h3 event into a standard `Request`. `defineEventHandler` natively accepts a `Response` return value.
|
|
181
|
+
|
|
182
|
+
## Communication Protocol
|
|
183
|
+
|
|
184
|
+
Rpc uses **MessagePack** as its primary communication protocol for efficiency and performance. For clients that do not support MessagePack, it can fall back to **JSON**.
|
|
185
|
+
|
|
186
|
+
- **`$command`**: Sends data in the request body, encoded with MessagePack (`application/msgpack`). Plain JSON (`application/json`) is also accepted by the server.
|
|
187
|
+
- **`$query`**: Sends data in the URL's query string, encoded with MessagePack and Base64. This is the recommended method for queries.
|
|
188
|
+
- **`$get`**: Sends data as plain text in the URL's query string. Useful for clients that do not support MessagePack, or for simple non-complex queries.
|
|
189
|
+
|
|
190
|
+
The server automatically detects the client's `Accept` header and responds with either MessagePack or JSON.
|
|
191
|
+
|
|
192
|
+
### Response Headers
|
|
193
|
+
|
|
194
|
+
Every response includes the following headers:
|
|
195
|
+
|
|
196
|
+
| Header | Description |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `x-atom-forge-rpc-exec-time` | Server-side execution time in milliseconds. |
|
|
199
|
+
|
|
200
|
+
## Client-side Usage
|
|
201
|
+
|
|
202
|
+
### `createClient`
|
|
203
|
+
|
|
204
|
+
The `createClient` function creates a new API client. The way you call an endpoint on the client (`$query` or `$get`) must match how it was defined on the server (`rpc.query` or `rpc.get`).
|
|
205
|
+
|
|
206
|
+
```typescript
|
|
207
|
+
import { createClient } from '@atom-forge/rpc';
|
|
208
|
+
import type { api } from './api';
|
|
209
|
+
|
|
210
|
+
const [client, cfg] = createClient<typeof api>('/rpc');
|
|
211
|
+
|
|
212
|
+
// If the server endpoint is defined with rpc.query:
|
|
213
|
+
const result = await client.posts.list.$query({ page: 1 });
|
|
214
|
+
|
|
215
|
+
// Command call
|
|
216
|
+
await client.posts.create.$command({ title: 'Hello World' });
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
### Call Options
|
|
220
|
+
|
|
221
|
+
Every RPC method (`$command`, `$query`, `$get`) accepts an optional second argument with per-call options:
|
|
222
|
+
|
|
223
|
+
```typescript
|
|
224
|
+
const result = await client.posts.list.$query({ page: 1 }, {
|
|
225
|
+
// Abort the request using an AbortController
|
|
226
|
+
abortSignal: controller.signal,
|
|
227
|
+
|
|
228
|
+
// Track upload/download progress (uses XHR internally)
|
|
229
|
+
onProgress: ({ loaded, total, percent, phase }) => {
|
|
230
|
+
console.log(`${phase}: ${percent}%`);
|
|
231
|
+
},
|
|
232
|
+
|
|
233
|
+
// Add custom request headers for this call only
|
|
234
|
+
headers: new Headers({ 'X-Custom-Header': 'value' }),
|
|
235
|
+
});
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
### `RpcResponse`
|
|
239
|
+
|
|
240
|
+
Every RPC call returns a `RpcResponse` with these members:
|
|
241
|
+
|
|
242
|
+
| Member | Description |
|
|
243
|
+
|---|---|
|
|
244
|
+
| `res.isOK()` | `true` if the call succeeded |
|
|
245
|
+
| `res.isError(code?)` | `true` if error; optionally checks a specific code |
|
|
246
|
+
| `res.status` | `'OK'` on success, or the error code string |
|
|
247
|
+
| `res.result` | Typed success data, or error details |
|
|
248
|
+
| `res.ctx` | The full `ClientContext` for this call |
|
|
249
|
+
|
|
250
|
+
**Error code format:**
|
|
251
|
+
- Application-level errors: `'INVALID_ARGUMENT'`, `'PERMISSION_DENIED'`, custom codes, etc.
|
|
252
|
+
- Transport errors: `'HTTP:401'`, `'HTTP:404'`, `'HTTP:500'`, etc.
|
|
253
|
+
- Network errors: `'NETWORK_ERROR'`
|
|
254
|
+
|
|
255
|
+
```typescript
|
|
256
|
+
const res = await client.posts.create.$command({ title: 'Hello' });
|
|
257
|
+
|
|
258
|
+
if (res.isOK()) {
|
|
259
|
+
console.log(res.result); // typed result
|
|
260
|
+
} else if (res.isError('INVALID_ARGUMENT')) {
|
|
261
|
+
console.log(res.result.message); // error details
|
|
262
|
+
} else if (res.isError('HTTP:401')) {
|
|
263
|
+
// redirect to login
|
|
264
|
+
} else {
|
|
265
|
+
console.log(res.status, res.result); // any other error
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// Access context (response headers, elapsed time, etc.)
|
|
269
|
+
console.log(res.ctx.response?.status);
|
|
270
|
+
console.log(res.ctx.elapsedTime);
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
### File Uploads
|
|
274
|
+
|
|
275
|
+
`$command` endpoints automatically detect `File` or `File[]` values in the arguments and switch to a `multipart/form-data` request. You can combine file uploads with regular arguments and track progress.
|
|
276
|
+
|
|
277
|
+
```typescript
|
|
278
|
+
// Server-side
|
|
279
|
+
const api = {
|
|
280
|
+
posts: {
|
|
281
|
+
create: rpc.command(async ({ title, cover }: { title: string; cover: File }) => {
|
|
282
|
+
// cover is a File object
|
|
283
|
+
}),
|
|
284
|
+
},
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
// Client-side
|
|
288
|
+
const coverFile = document.querySelector('input[type=file]').files[0];
|
|
289
|
+
|
|
290
|
+
await client.posts.create.$command(
|
|
291
|
+
{ title: 'Hello', cover: coverFile },
|
|
292
|
+
{
|
|
293
|
+
onProgress: ({ percent, phase }) => console.log(`${phase}: ${percent}%`),
|
|
294
|
+
}
|
|
295
|
+
);
|
|
296
|
+
```
|
|
297
|
+
|
|
298
|
+
For multiple files, use an array and suffix the key with `[]`:
|
|
299
|
+
|
|
300
|
+
```typescript
|
|
301
|
+
// Server-side
|
|
302
|
+
const api = {
|
|
303
|
+
media: {
|
|
304
|
+
upload: rpc.command(async ({ files }: { files: File[] }) => { ... }),
|
|
305
|
+
},
|
|
306
|
+
};
|
|
307
|
+
|
|
308
|
+
// Client-side
|
|
309
|
+
await client.media.upload.$command({ 'files[]': selectedFiles });
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
### `clientLogger`
|
|
313
|
+
|
|
314
|
+
`clientLogger` is a built-in client middleware that logs RPC call details to the browser console — including the request path, arguments, response, timing, and HTTP status code.
|
|
315
|
+
|
|
316
|
+
```typescript
|
|
317
|
+
import { createClient, clientLogger } from '@atom-forge/rpc';
|
|
318
|
+
|
|
319
|
+
const [client, cfg] = createClient<typeof api>('/rpc');
|
|
320
|
+
cfg.$ = clientLogger('/rpc'); // apply globally
|
|
321
|
+
```
|
|
322
|
+
|
|
323
|
+
### `makeClientMiddleware`
|
|
324
|
+
|
|
325
|
+
The `makeClientMiddleware` function is used to create a client-side middleware.
|
|
326
|
+
|
|
327
|
+
> ⚠️ **Always `return await next()`** in your middleware. If you call `next()` without returning its result, the response will be lost and the caller will receive `undefined`.
|
|
328
|
+
|
|
329
|
+
```typescript
|
|
330
|
+
import { makeClientMiddleware } from '@atom-forge/rpc';
|
|
331
|
+
|
|
332
|
+
const loggerMiddleware = makeClientMiddleware(async (ctx, next) => {
|
|
333
|
+
console.log('Request:', ctx.path, ctx.getArgs());
|
|
334
|
+
const result = await next(); // ✅ always return the result of next()
|
|
335
|
+
console.log('Response:', ctx.result);
|
|
336
|
+
return result;
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
// Apply middleware to all routes
|
|
340
|
+
cfg.$ = loggerMiddleware;
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
## Server-side Usage
|
|
344
|
+
|
|
345
|
+
### `createCoreHandler` and `flattenApiDefinition`
|
|
346
|
+
|
|
347
|
+
`createCoreHandler` creates a framework-agnostic handler that works on standard `Request` → `Response`. `flattenApiDefinition` prepares the API definition for the handler.
|
|
348
|
+
|
|
349
|
+
```typescript
|
|
350
|
+
import { createCoreHandler, flattenApiDefinition, rpc } from '@atom-forge/rpc';
|
|
351
|
+
|
|
352
|
+
const api = {
|
|
353
|
+
posts: {
|
|
354
|
+
// expects $query from the client
|
|
355
|
+
list: rpc.query(async ({ page }, ctx) => {
|
|
356
|
+
ctx.cache.set(60);
|
|
357
|
+
return { posts: [] };
|
|
358
|
+
}),
|
|
359
|
+
// expects $get from the client
|
|
360
|
+
getById: rpc.get(async ({ id }, ctx) => {
|
|
361
|
+
return { id, title: 'Example Post' };
|
|
362
|
+
}),
|
|
363
|
+
// expects $command from the client
|
|
364
|
+
create: rpc.command(async ({ title }) => {
|
|
365
|
+
// create a new post
|
|
366
|
+
}),
|
|
367
|
+
},
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
const handle = createCoreHandler(flattenApiDefinition(api));
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
#### Custom Server Context
|
|
374
|
+
|
|
375
|
+
You can provide a custom server context factory to inject your own properties (e.g. authenticated user) into every handler:
|
|
376
|
+
|
|
377
|
+
```typescript
|
|
378
|
+
import { createCoreHandler, flattenApiDefinition, ServerContext } from '@atom-forge/rpc';
|
|
379
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
380
|
+
|
|
381
|
+
class AppContext extends ServerContext<RequestEvent> {
|
|
382
|
+
get user() {
|
|
383
|
+
return this.adapterContext.locals.user;
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
const handle = createCoreHandler(flattenApiDefinition(api), {
|
|
388
|
+
createServerContext: (args, request, adapterContext) =>
|
|
389
|
+
new AppContext(args, request, adapterContext),
|
|
390
|
+
});
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
### The `rpc` object
|
|
394
|
+
|
|
395
|
+
The `rpc` object provides methods for defining your API endpoints. The method you use on the server determines how the client must call the endpoint.
|
|
396
|
+
|
|
397
|
+
* `rpc.query`: Defines a query endpoint that expects arguments encoded with MessagePack. The client must use **`$query`**.
|
|
398
|
+
* `rpc.get`: Defines a query endpoint that expects arguments as plain text in the URL. The client must use **`$get`**.
|
|
399
|
+
* `rpc.command`: Defines a command endpoint. The client must use **`$command`**.
|
|
400
|
+
|
|
401
|
+
#### `rpcFactory`
|
|
402
|
+
|
|
403
|
+
If you use a custom server context (see above), use `rpcFactory` to create a typed `rpc` instance so that `ctx` is properly typed in your handlers:
|
|
404
|
+
|
|
405
|
+
```typescript
|
|
406
|
+
import { rpcFactory } from '@atom-forge/rpc';
|
|
407
|
+
|
|
408
|
+
const rpc = rpcFactory<AppContext>();
|
|
409
|
+
|
|
410
|
+
const api = {
|
|
411
|
+
posts: {
|
|
412
|
+
list: rpc.query(async ({ page }, ctx) => {
|
|
413
|
+
// ctx is typed as AppContext
|
|
414
|
+
const user = ctx.user;
|
|
415
|
+
return { posts: [] };
|
|
416
|
+
}),
|
|
417
|
+
},
|
|
418
|
+
};
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
### Server Context (`ctx`)
|
|
422
|
+
|
|
423
|
+
Every handler and server-side middleware receives a `ctx` object with the following members:
|
|
424
|
+
|
|
425
|
+
| Member | Description |
|
|
426
|
+
|---|---|
|
|
427
|
+
| `ctx.request` | The standard Web API `Request` object. |
|
|
428
|
+
| `ctx.adapterContext` | The framework-specific context (SvelteKit: `RequestEvent`, Hono: `Context`, etc.). |
|
|
429
|
+
| `ctx.getArgs()` | Returns all arguments as a plain object. |
|
|
430
|
+
| `ctx.args` | The arguments as a `Map<string, any>`. |
|
|
431
|
+
| `ctx.cookies` | Cookie manager: `get(name)`, `set(name, value, opts?)`, `delete(name, opts?)`, `getAll()`. |
|
|
432
|
+
| `ctx.headers.request` | The incoming request headers. |
|
|
433
|
+
| `ctx.headers.response` | The mutable response headers. |
|
|
434
|
+
| `ctx.cache.set(seconds)` | Sets the `Cache-Control` max-age for GET responses. |
|
|
435
|
+
| `ctx.cache.get()` | Returns the current cache duration. |
|
|
436
|
+
| `ctx.status.set(code)` | Sets the HTTP response status code. |
|
|
437
|
+
| `ctx.status.notFound()` | Shorthand for common HTTP codes (see below). |
|
|
438
|
+
| `ctx.env` | A `Map<string\|symbol, any>` for passing data between middlewares. |
|
|
439
|
+
| `ctx.elapsedTime` | Server-side elapsed time in milliseconds. |
|
|
440
|
+
|
|
441
|
+
**Status shortcuts:** `ok`, `created`, `accepted`, `noContent`, `badRequest`, `unauthorized`, `forbidden`, `notFound`, `methodNotAllowed`, `conflict`, `tooManyRequests`, `serverError`, `serviceUnavailable`, and more.
|
|
442
|
+
|
|
443
|
+
### Caching
|
|
444
|
+
|
|
445
|
+
Rpc supports server-side caching for `GET` requests (both `rpc.query` and `rpc.get`). Set the cache duration in seconds using `ctx.cache.set()` within your endpoint implementation.
|
|
446
|
+
|
|
447
|
+
```typescript
|
|
448
|
+
const api = {
|
|
449
|
+
posts: {
|
|
450
|
+
list: rpc.query(async ({ page }, ctx) => {
|
|
451
|
+
ctx.cache.set(60); // Cache the response for 60 seconds
|
|
452
|
+
return { posts: [] };
|
|
453
|
+
}),
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
### Error Handling
|
|
459
|
+
|
|
460
|
+
Use the built-in error helpers to return application-level errors from handlers. These always produce a `200 OK` response with the `atomforge.rpc.error` key, so the client receives a typed `RpcResponse`.
|
|
461
|
+
|
|
462
|
+
```typescript
|
|
463
|
+
import { rpc } from '@atom-forge/rpc';
|
|
464
|
+
|
|
465
|
+
const api = {
|
|
466
|
+
posts: {
|
|
467
|
+
create: rpc.command(async ({ title }, ctx) => {
|
|
468
|
+
// SvelteKit: (ctx.adapterContext as RequestEvent).locals.user
|
|
469
|
+
if (!ctx.adapterContext?.locals?.user) return rpc.error.permissionDenied();
|
|
470
|
+
if (title.length < 3) return rpc.error.invalidArgument({ message: 'Title too short' });
|
|
471
|
+
// ...
|
|
472
|
+
return { id: 1, title };
|
|
473
|
+
}),
|
|
474
|
+
},
|
|
475
|
+
};
|
|
476
|
+
```
|
|
477
|
+
|
|
478
|
+
Use `rpc.error.make` for custom error codes:
|
|
479
|
+
|
|
480
|
+
```typescript
|
|
481
|
+
return rpc.error.make('POST_ALREADY_EXISTS', 'This slug already exists', { slug: post.slug });
|
|
482
|
+
```
|
|
483
|
+
|
|
484
|
+
| Method | Error code | Use when |
|
|
485
|
+
|---|---|---|
|
|
486
|
+
| `rpc.error.invalidArgument(details?)` | `INVALID_ARGUMENT` | Business logic validation (beyond Zod) |
|
|
487
|
+
| `rpc.error.permissionDenied(details?)` | `PERMISSION_DENIED` | Authorization failure |
|
|
488
|
+
| `rpc.error.internalError(details?)` | `INTERNAL_ERROR` | Handled internal failure (auto `correlationId`) |
|
|
489
|
+
| `rpc.error.make(code, message?, result?)` | custom | Any custom error code |
|
|
490
|
+
|
|
491
|
+
### `zod` integration
|
|
492
|
+
|
|
493
|
+
Rpc has built-in support for `zod` for input validation. Install `zod` as a dependency of your project and import it directly.
|
|
494
|
+
|
|
495
|
+
If validation fails, Rpc automatically returns an application-level error (`200 OK`) with code `INVALID_ARGUMENT` and the `ZodIssue` array in the `issues` field. The handler does not run.
|
|
496
|
+
|
|
497
|
+
```typescript
|
|
498
|
+
// Server-side
|
|
499
|
+
import { rpc } from '@atom-forge/rpc';
|
|
500
|
+
import { z } from 'zod';
|
|
501
|
+
|
|
502
|
+
const api = {
|
|
503
|
+
posts: {
|
|
504
|
+
create: rpc.zod({
|
|
505
|
+
title: z.string().min(3, "Title must be at least 3 characters long."),
|
|
506
|
+
content: z.string().min(10),
|
|
507
|
+
}).command(async ({ title, content }) => {
|
|
508
|
+
// This code only runs if validation passes
|
|
509
|
+
}),
|
|
510
|
+
},
|
|
511
|
+
};
|
|
512
|
+
```
|
|
513
|
+
|
|
514
|
+
`rpc.zod` also works with `query` and `get`:
|
|
515
|
+
|
|
516
|
+
```typescript
|
|
517
|
+
rpc.zod({ id: z.number() }).query(async ({ id }, ctx) => { ... })
|
|
518
|
+
rpc.zod({ id: z.number() }).get(async ({ id }, ctx) => { ... })
|
|
519
|
+
```
|
|
520
|
+
|
|
521
|
+
Handle validation errors on the client via `RpcResponse`:
|
|
522
|
+
|
|
523
|
+
```typescript
|
|
524
|
+
// Client-side
|
|
525
|
+
const res = await client.posts.create.$command({ title: 'Hi' });
|
|
526
|
+
if (res.isError('INVALID_ARGUMENT')) {
|
|
527
|
+
console.log(res.result.issues); // ZodIssue[]
|
|
528
|
+
}
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
### `makeServerMiddleware`
|
|
532
|
+
|
|
533
|
+
The `makeServerMiddleware` function is used to create server-side middleware. An optional second argument lets you attach accessor functions to the middleware, which is useful for creating reusable, self-contained middleware with helpers.
|
|
534
|
+
|
|
535
|
+
> ⚠️ **Always `return await next()`** in your middleware. If you call `next()` without returning its result, the handler's return value will be lost and the client will receive `undefined`.
|
|
536
|
+
|
|
537
|
+
```typescript
|
|
538
|
+
import { makeServerMiddleware } from '@atom-forge/rpc';
|
|
539
|
+
import type { RequestEvent } from '@sveltejs/kit';
|
|
540
|
+
|
|
541
|
+
const authMiddleware = makeServerMiddleware(
|
|
542
|
+
async (ctx, next) => {
|
|
543
|
+
const user = (ctx.adapterContext as RequestEvent).locals.user;
|
|
544
|
+
if (!user) {
|
|
545
|
+
ctx.status.unauthorized();
|
|
546
|
+
return { error: 'Unauthorized' }; // ✅ early return, no next() call needed
|
|
547
|
+
}
|
|
548
|
+
return await next(); // ✅ always return the result of next()
|
|
549
|
+
},
|
|
550
|
+
// Optional accessors attached to the middleware function itself
|
|
551
|
+
{
|
|
552
|
+
isAdmin: (ctx) => (ctx.adapterContext as RequestEvent).locals.user?.role === 'admin',
|
|
553
|
+
}
|
|
554
|
+
);
|
|
555
|
+
```
|
|
556
|
+
|
|
557
|
+
The accessor functions are attached directly to the middleware function object, keeping the middleware and its associated helpers co-located. Call them from within endpoint implementations by passing `ctx`:
|
|
558
|
+
|
|
559
|
+
```typescript
|
|
560
|
+
const api = {
|
|
561
|
+
admin: {
|
|
562
|
+
deletePost: rpc.middleware(authMiddleware).command(async ({ id }, ctx) => {
|
|
563
|
+
if (!authMiddleware.isAdmin(ctx)) {
|
|
564
|
+
ctx.status.forbidden();
|
|
565
|
+
return { error: 'Admin only' };
|
|
566
|
+
}
|
|
567
|
+
// proceed...
|
|
568
|
+
}),
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
```
|
|
572
|
+
|
|
573
|
+
This pattern keeps the middleware's knowledge — what constitutes an `isAdmin` check — in one place rather than repeating the logic in every endpoint.
|
|
574
|
+
|
|
575
|
+
### Applying Middleware with `rpc.middleware`
|
|
576
|
+
|
|
577
|
+
Use `rpc.middleware()` to attach one or more server middlewares to an endpoint:
|
|
578
|
+
|
|
579
|
+
```typescript
|
|
580
|
+
import { rpc } from '@atom-forge/rpc';
|
|
581
|
+
import { z } from 'zod';
|
|
582
|
+
|
|
583
|
+
// Apply middleware to a specific endpoint
|
|
584
|
+
const api = {
|
|
585
|
+
posts: {
|
|
586
|
+
create: rpc.middleware(authMiddleware).command(async ({ title }) => {
|
|
587
|
+
// ...
|
|
588
|
+
}),
|
|
589
|
+
// Combine middleware with zod validation
|
|
590
|
+
update: rpc.middleware(authMiddleware).zod({
|
|
591
|
+
id: z.number(),
|
|
592
|
+
title: z.string(),
|
|
593
|
+
}).command(async ({ id, title }) => {
|
|
594
|
+
// ...
|
|
595
|
+
}),
|
|
596
|
+
},
|
|
597
|
+
};
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
You can also attach middleware to any existing object with `.on()`:
|
|
601
|
+
|
|
602
|
+
```typescript
|
|
603
|
+
const postsApi = {
|
|
604
|
+
list: rpc.query(async () => { ... }),
|
|
605
|
+
create: rpc.command(async () => { ... }),
|
|
606
|
+
};
|
|
607
|
+
|
|
608
|
+
// Attach authMiddleware to the whole postsApi group
|
|
609
|
+
rpc.middleware(authMiddleware).on(postsApi);
|
|
610
|
+
|
|
611
|
+
const api = { posts: postsApi };
|
|
612
|
+
```
|