@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.
Files changed (42) hide show
  1. package/LICENSE +28 -0
  2. package/README.en.md +612 -0
  3. package/README.hu.md +613 -0
  4. package/README.llm.md +343 -0
  5. package/README.md +25 -0
  6. package/dist/client/client-context.d.ts +45 -0
  7. package/dist/client/client-context.js +48 -0
  8. package/dist/client/create-client.d.ts +9 -0
  9. package/dist/client/create-client.js +277 -0
  10. package/dist/client/logger.d.ts +6 -0
  11. package/dist/client/logger.js +41 -0
  12. package/dist/client/middleware.d.ts +6 -0
  13. package/dist/client/middleware.js +7 -0
  14. package/dist/client/rpc-response.d.ts +27 -0
  15. package/dist/client/rpc-response.js +46 -0
  16. package/dist/client/types.d.ts +151 -0
  17. package/dist/client/types.js +1 -0
  18. package/dist/index.d.ts +7 -0
  19. package/dist/index.js +7 -0
  20. package/dist/server/create-handler.d.ts +18 -0
  21. package/dist/server/create-handler.js +210 -0
  22. package/dist/server/errors.d.ts +10 -0
  23. package/dist/server/errors.js +14 -0
  24. package/dist/server/middleware.d.ts +22 -0
  25. package/dist/server/middleware.js +39 -0
  26. package/dist/server/rpc.d.ts +65 -0
  27. package/dist/server/rpc.js +49 -0
  28. package/dist/server/server-context.d.ts +79 -0
  29. package/dist/server/server-context.js +86 -0
  30. package/dist/server/types.d.ts +30 -0
  31. package/dist/server/types.js +1 -0
  32. package/dist/util/constants.d.ts +1 -0
  33. package/dist/util/constants.js +1 -0
  34. package/dist/util/cookies.d.ts +22 -0
  35. package/dist/util/cookies.js +54 -0
  36. package/dist/util/pipeline.d.ts +23 -0
  37. package/dist/util/pipeline.js +22 -0
  38. package/dist/util/string.d.ts +6 -0
  39. package/dist/util/string.js +11 -0
  40. package/dist/util/types.d.ts +5 -0
  41. package/dist/util/types.js +1 -0
  42. package/package.json +38 -0
package/README.llm.md ADDED
@@ -0,0 +1,343 @@
1
+ # Rpc — LLM Reference
2
+
3
+ Rpc is a full-stack TypeScript RPC framework. **Framework-agnostic** — works with SvelteKit, Next.js, Nuxt, Express, Hono, and any runtime that supports the Web API `Request`/`Response`. It provides end-to-end type safety between server and client using MessagePack as the primary transport protocol.
4
+
5
+ ## Package
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
+ ## Exports
15
+
16
+ ```typescript
17
+ import { createClient, makeClientMiddleware, clientLogger, RpcResponse } from '@atom-forge/rpc'; // client
18
+ import { createCoreHandler, flattenApiDefinition, rpc, rpcFactory, makeServerMiddleware } from '@atom-forge/rpc'; // server
19
+ import { z } from 'zod'; // install zod as a peer dependency in your project
20
+ ```
21
+
22
+ ---
23
+
24
+ ## Server
25
+
26
+ ### Defining endpoints
27
+
28
+ Use the `rpc` singleton (or a typed instance from `rpcFactory<CTX>()`) to define endpoints:
29
+
30
+ ```typescript
31
+ rpc.query(async (args, ctx) => result) // GET /path?args=<msgpack+base64>
32
+ rpc.get(async (args, ctx) => result) // GET /path?key=value (plain strings)
33
+ rpc.command(async (args, ctx) => result) // POST /path (body: msgpack or JSON)
34
+ ```
35
+
36
+ Add Zod validation (import `z` from `zod` directly):
37
+
38
+ ```typescript
39
+ rpc.zod({ id: z.number(), name: z.string() }).query(...)
40
+ rpc.zod({ ... }).command(...)
41
+ rpc.zod({ ... }).get(...)
42
+ ```
43
+
44
+ Add server middleware:
45
+
46
+ ```typescript
47
+ rpc.middleware(mw).query(...)
48
+ rpc.middleware(mw).command(...)
49
+ rpc.middleware(mw).zod({ ... }).command(...)
50
+ rpc.middleware(mw).on(existingObject) // attach to any object/group
51
+ ```
52
+
53
+ ### `flattenApiDefinition` + `createCoreHandler`
54
+
55
+ ```typescript
56
+ const endpointMap = flattenApiDefinition(apiObject);
57
+
58
+ const handle = createCoreHandler(endpointMap, {
59
+ createServerContext?: (args, request: Request, adapterContext: TAdapter) => ServerContext<TAdapter>
60
+ });
61
+
62
+ // handle signature:
63
+ // (request: Request, routeInfo: { path: string }, adapterContext?: TAdapter) => Promise<Response>
64
+ ```
65
+
66
+ - Accepted HTTP methods: `GET` for `query`/`get`, `POST` for `command`.
67
+ - Accepted `Content-Type` for POST: `application/msgpack` (default), `application/json`, `multipart/form-data`. Unknown → `415`.
68
+ - `adapterContext` is passed through as `ctx.adapterContext` in every handler.
69
+
70
+ ### Framework adapter wiring
71
+
72
+ **SvelteKit** — route file `src/routes/rpc/[...path]/+server.ts`:
73
+ ```typescript
74
+ const handle = createCoreHandler(flattenApiDefinition(api));
75
+ export const GET = (event) => handle(event.request, { path: event.params.path }, event);
76
+ export const POST = GET;
77
+ // ctx.adapterContext === RequestEvent
78
+ ```
79
+
80
+ **SvelteKit** — alternative via `src/hooks.server.ts` (no route file needed):
81
+ ```typescript
82
+ const handleRpc = createCoreHandler(flattenApiDefinition(api));
83
+ export const handle = async ({ event, resolve }) => {
84
+ if (event.url.pathname.startsWith('/rpc/')) {
85
+ return handleRpc(event.request, { path: event.url.pathname.slice('/rpc/'.length) }, event);
86
+ }
87
+ return resolve(event);
88
+ };
89
+ ```
90
+
91
+ **Next.js App Router** — `app/rpc/[...path]/route.ts`:
92
+ ```typescript
93
+ const handle = createCoreHandler(flattenApiDefinition(api));
94
+ export async function GET(request: Request, { params }: { params: Promise<{ path: string[] }> }) {
95
+ const { path } = await params; // params is a Promise in Next.js 15+
96
+ return handle(request, { path: path.join('.') }, { request, params });
97
+ }
98
+ export const POST = GET;
99
+ ```
100
+
101
+ **Nuxt 3** — `server/routes/rpc/[...path].ts`:
102
+ ```typescript
103
+ import { getRouterParam, toWebRequest } from 'h3';
104
+ const handle = createCoreHandler(flattenApiDefinition(api));
105
+ export default defineEventHandler(async (event) => {
106
+ return handle(toWebRequest(event), { path: getRouterParam(event, 'path') ?? '' }, event);
107
+ });
108
+ ```
109
+
110
+ **Express**:
111
+ ```typescript
112
+ const handle = createCoreHandler(flattenApiDefinition(api));
113
+ app.all('/rpc/:path', async (req, res) => {
114
+ const request = new Request(`${req.protocol}://${req.get('host')}${req.originalUrl}`,
115
+ { method: req.method, headers: req.headers as any, body: req.method !== 'GET' ? req : null });
116
+ const response = await handle(request, { path: req.params.path }, { req, res });
117
+ res.status(response.status);
118
+ response.headers.forEach((v, k) => res.setHeader(k, v));
119
+ res.send(Buffer.from(await response.arrayBuffer()));
120
+ });
121
+ ```
122
+
123
+ **Hono**:
124
+ ```typescript
125
+ const handle = createCoreHandler(flattenApiDefinition(api));
126
+ app.all('/rpc/:path', (c) => handle(c.req.raw, { path: c.req.param('path') }, c));
127
+ ```
128
+
129
+ ### `rpcFactory`
130
+
131
+ Creates a typed `rpc` instance bound to a custom context type:
132
+
133
+ ```typescript
134
+ const rpc = rpcFactory<AppContext>();
135
+ ```
136
+
137
+ ### `makeServerMiddleware`
138
+
139
+ > ⚠️ Always `return await next()` — omitting the `return` silently drops the handler's result.
140
+
141
+ ```typescript
142
+ const mw = makeServerMiddleware(
143
+ async (ctx, next) => {
144
+ // early exit without calling next() is valid:
145
+ // ctx.status.unauthorized(); return { error: '...' };
146
+ return await next(); // ✅ must return
147
+ },
148
+ { isAdmin: (ctx) => (ctx.adapterContext as RequestEvent).locals.user?.role === 'admin' }
149
+ );
150
+
151
+ // Accessor functions are attached to the middleware function object itself:
152
+ const api = {
153
+ admin: {
154
+ deletePost: rpc.middleware(mw).command(async ({ id }, ctx) => {
155
+ if (!mw.isAdmin(ctx)) { ctx.status.forbidden(); return { error: 'Admin only' }; }
156
+ }),
157
+ },
158
+ };
159
+ ```
160
+
161
+ ### `ServerContext<TAdapter>` — `ctx` properties
162
+
163
+ | Property | Type | Description |
164
+ |---|---|---|
165
+ | `ctx.request` | `Request` | Standard Web API Request object |
166
+ | `ctx.adapterContext` | `TAdapter` | Framework-specific context (e.g. `RequestEvent`, Hono `Context`) |
167
+ | `ctx.args` | `Map<string, any>` | Parsed request arguments |
168
+ | `ctx.getArgs()` | `() => Record<string, any>` | Args as plain object |
169
+ | `ctx.cookies` | `CookieManager` | `get(name)`, `set(name, value, opts?)`, `delete(name, opts?)`, `getAll()` |
170
+ | `ctx.headers.request` | `Headers` | Incoming request headers |
171
+ | `ctx.headers.response` | `Headers` | Mutable outgoing response headers |
172
+ | `ctx.cache.set(n)` | `(seconds: number) => void` | Set `Cache-Control` max-age (GET only) |
173
+ | `ctx.cache.get()` | `() => number` | Get current cache value |
174
+ | `ctx.status.set(n)` | `(code: number) => void` | Set response status code |
175
+ | `ctx.status.<shortcut>()` | `() => void` | e.g. `notFound()`, `unauthorized()`, `created()` |
176
+ | `ctx.env` | `Map<string\|symbol, any>` | Shared state across middlewares |
177
+ | `ctx.elapsedTime` | `number` | ms since context was created |
178
+
179
+ **All status shortcuts:** `continue`, `switchingProtocols`, `processing`, `ok`, `created`, `accepted`, `noContent`, `resetContent`, `partialContent`, `multipleChoices`, `movedPermanently`, `found`, `seeOther`, `notModified`, `temporaryRedirect`, `permanentRedirect`, `badRequest`, `unauthorized`, `paymentRequired`, `forbidden`, `notFound`, `methodNotAllowed`, `notAcceptable`, `conflict`, `gone`, `lengthRequired`, `preconditionFailed`, `payloadTooLarge`, `uriTooLong`, `badContent`, `rangeNotSatisfiable`, `expectationFailed`, `tooManyRequests`, `serverError`, `notImplemented`, `badGateway`, `serviceUnavailable`, `gatewayTimeout`, `httpVersionNotSupported`.
180
+
181
+ ### Response headers sent by the server
182
+
183
+ | Header | When |
184
+ |---|---|
185
+ | `x-atom-forge-rpc-exec-time` | Always — server-side execution time in ms |
186
+ | `Content-Type` | `application/msgpack` or `application/json` (based on `Accept` header) |
187
+ | `Cache-Control` | Only on GET when `ctx.cache.set(n)` was called |
188
+ | `Set-Cookie` | When `ctx.cookies.set()` or `ctx.cookies.delete()` is called |
189
+
190
+ ### Zod validation errors
191
+
192
+ Zod failures are returned as application-level errors (status `200 OK`):
193
+
194
+ - Body: `{ "atomforge.rpc.error": "INVALID_ARGUMENT", issues: ZodIssue[] }`
195
+
196
+ ### Error helpers
197
+
198
+ Return these from handlers to signal application-level errors. All produce a `200 OK` response with the `atomforge.rpc.error` key set.
199
+
200
+ ```typescript
201
+ return rpc.error.invalidArgument({ message: 'Title too short' }) // code: "INVALID_ARGUMENT"
202
+ return rpc.error.permissionDenied({ message: 'Admins only' }) // code: "PERMISSION_DENIED"
203
+ return rpc.error.internalError() // code: "INTERNAL_ERROR", auto correlationId
204
+ return rpc.error.make('POST_ALREADY_EXISTS', 'Already exists', { slug: post.slug }) // custom
205
+ ```
206
+
207
+ ---
208
+
209
+ ## Client
210
+
211
+ ### `createClient`
212
+
213
+ ```typescript
214
+ const [api, cfg] = createClient<typeof apiDefinition>(
215
+ baseUrl: string = '/api'
216
+ );
217
+ ```
218
+
219
+ - `api`: recursive proxy matching the server API shape. Use `typeof api` (the server-side api object) as the generic.
220
+ - `cfg`: middleware configuration proxy.
221
+
222
+ ### Calling endpoints
223
+
224
+ Every call returns a `RpcResponse`. Use `isOK()` / `isError()` to branch:
225
+
226
+ ```typescript
227
+ const res = await api.posts.list.$query(args, options?)
228
+ const res = await api.posts.create.$command(args, options?)
229
+ const res = await api.posts.getById.$get(args, options?)
230
+
231
+ if (res.isOK()) {
232
+ const data = res.result // typed success data
233
+ } else if (res.isError('INVALID_ARGUMENT')) {
234
+ console.log(res.result) // error details
235
+ } else if (res.isError('HTTP:401')) {
236
+ // transport-level error
237
+ } else {
238
+ console.log(res.status, res.result)
239
+ }
240
+ ```
241
+
242
+ ### `RpcResponse<TSuccess, TError>`
243
+
244
+ | Member | Description |
245
+ |---|---|
246
+ | `res.isOK()` | `true` if the call succeeded |
247
+ | `res.isError(code?)` | `true` if error; optional specific code check |
248
+ | `res.status` / `res.getStatus()` | `'OK'` on success, error code string otherwise |
249
+ | `res.result` / `res.getResult()` | Typed success data or error details |
250
+ | `res.ctx` / `res.getCtx()` | The full `ClientContext` for this call |
251
+
252
+ **Error code format:**
253
+ - Application-level errors: `'INVALID_ARGUMENT'`, `'PERMISSION_DENIED'`, `'NOT_FOUND'`, etc.
254
+ - Transport errors: `'HTTP:401'`, `'HTTP:404'`, `'HTTP:500'`, etc.
255
+ - Network errors: `'NETWORK_ERROR'`
256
+
257
+ ### `CallOptions`
258
+
259
+ ```typescript
260
+ type CallOptions = {
261
+ abortSignal?: AbortSignal;
262
+ onProgress?: (p: { loaded: number; total: number; percent: number; phase: 'upload' | 'download' }) => void;
263
+ headers?: Headers;
264
+ }
265
+ ```
266
+
267
+ - When `onProgress` is provided, the request uses **XHR** instead of `fetch`.
268
+
269
+ ### File uploads
270
+
271
+ Pass `File` or `File[]` as an argument value in a `$command` call. Rpc automatically switches to `multipart/form-data`. For arrays, suffix the key with `[]`:
272
+
273
+ ```typescript
274
+ await api.media.upload.$command({ 'files[]': fileArray });
275
+ ```
276
+
277
+ ### `ClientContext` properties
278
+
279
+ | Property | Type | Description |
280
+ |---|---|---|
281
+ | `ctx.result` | `T \| undefined` | The typed result |
282
+ | `ctx.response` | `Response \| undefined` | The raw Response object |
283
+ | `ctx.path` | `string[]` | Request path segments |
284
+ | `ctx.args` | `Map<string, any>` | Arguments map |
285
+ | `ctx.getArgs()` | `() => Record<string, any>` | Args as plain object |
286
+ | `ctx.rpcType` | `'query' \| 'command' \| 'get'` | RPC method type |
287
+ | `ctx.elapsedTime` | `number` | ms since context was created |
288
+ | `ctx.env` | `Map<string\|symbol, any>` | Shared state across middlewares |
289
+ | `ctx.abortSignal` | `AbortSignal \| undefined` | The abort signal if provided |
290
+ | `ctx.onProgress` | `OnProgress \| undefined` | The progress callback if provided |
291
+ | `ctx.request.headers` | `Headers` | Outgoing request headers |
292
+
293
+ ### Applying client middleware
294
+
295
+ ```typescript
296
+ cfg.$ = mw // global (all routes)
297
+ cfg.posts.$ = mw // all endpoints under /posts
298
+ cfg.posts.create = mw // single endpoint /posts/create
299
+ cfg.posts.create = [mw1, mw2] // multiple middlewares
300
+ ```
301
+
302
+ ### `clientLogger`
303
+
304
+ Built-in debug middleware. Logs path, args, result, timing, and HTTP status to the browser console.
305
+
306
+ ```typescript
307
+ const [api, cfg] = createClient<typeof apiDefinition>('/rpc');
308
+ cfg.$ = clientLogger('/rpc'); // baseUrl must match createClient's baseUrl
309
+ ```
310
+
311
+ ### `makeClientMiddleware`
312
+
313
+ > ⚠️ Always `return await next()` — omitting the `return` silently drops the response.
314
+
315
+ ```typescript
316
+ const mw = makeClientMiddleware(async (ctx, next) => {
317
+ // before request
318
+ const result = await next(); // ✅ must return
319
+ // after request — ctx.result is available
320
+ return result;
321
+ });
322
+ ```
323
+
324
+ ---
325
+
326
+ ## Protocol details
327
+
328
+ | RPC type | HTTP method | Args encoding | Body |
329
+ |---|---|---|---|
330
+ | `get` | GET | `?key=value` (plain strings) | — |
331
+ | `query` | GET | `?args=<base64url(msgpack(args))>` | — |
332
+ | `command` (no files) | POST | — | `msgpack(args)` or `JSON(args)` |
333
+ | `command` (with files) | POST | — | `multipart/form-data` (args blob + file parts) |
334
+
335
+ Response body is `msgpack` by default. Send `Accept: application/json` to get JSON instead.
336
+
337
+ ---
338
+
339
+ ## URL format
340
+
341
+ Client-side calls generate dot-separated, fully kebab-case paths. For example, `api.posts.getById.$query(...)` maps to `/rpc/posts.get-by-id`.
342
+
343
+ Use `[...path]` (catch-all) in your framework's router so the dot-separated path is treated as a single segment. With Next.js (array params), join with `.`: `params.path.join('.')`.
package/README.md ADDED
@@ -0,0 +1,25 @@
1
+ # @atom-forge/rpc
2
+
3
+ Type-safe RPC framework for TypeScript with Zod validation and middleware support. Framework-agnostic — works with SvelteKit, Next.js, Nuxt, Express, Hono, and any runtime that supports the Web API `Request`/`Response`.
4
+
5
+ ```bash
6
+ npm install @atom-forge/rpc
7
+ pnpm add @atom-forge/rpc
8
+ yarn add @atom-forge/rpc
9
+ bun add @atom-forge/rpc
10
+ ```
11
+
12
+ ## Documentation
13
+
14
+ - [English](README.en.md)
15
+ - [Hungarian](README.hu.md)
16
+ - [LLM Reference](README.llm.md)
17
+
18
+ ## License
19
+
20
+ AtomForge — Patron License. Copyright (c) 2024-present Elvis Szabo.
21
+
22
+ - **Non-commercial use** (personal projects, open source, non-profits): free of charge.
23
+ - **Commercial use** (for-profit, SaaS, client work): requires an active [GitHub Sponsors](https://github.com/sponsors/atom-forge) subscription. The license is perpetual for projects started during the active support period.
24
+
25
+ See the [LICENSE](./LICENSE) file for the full license text.
@@ -0,0 +1,45 @@
1
+ import type { CallOptions, OnProgress } from "./types.js";
2
+ /**
3
+ * Represents the context for a client-side operation, holding metadata and options related to the operation.
4
+ * @internal
5
+ */
6
+ export declare class ClientContext<RESULT = any> {
7
+ /** A Map of custom environment variables relevant to the client context. */
8
+ readonly env: Map<string | symbol, any>;
9
+ /** An object with methods to set and get cache duration. */
10
+ readonly abortSignal?: AbortSignal;
11
+ /** An object with methods to set and get cache duration. */
12
+ readonly onProgress?: OnProgress;
13
+ /** A Map object representing arguments passed to the server */
14
+ readonly args: Map<string, any>;
15
+ /** The request path segments */
16
+ readonly path: string[];
17
+ protected _headers: Headers;
18
+ protected _response?: Response;
19
+ /** The result of the operation */
20
+ result?: RESULT;
21
+ /** The response */
22
+ get response(): Response | undefined;
23
+ /** The request */
24
+ readonly request: {
25
+ headers: Headers;
26
+ };
27
+ /** The RPC type */
28
+ readonly rpcType: "command" | "query" | "get";
29
+ private readonly start;
30
+ /** Indicates the time elapsed since the creation of the ClientContext instance */
31
+ get elapsedTime(): number;
32
+ /**
33
+ * Creates a new ClientContext instance.
34
+ * @param path - The path segments of the RPC method.
35
+ * @param args - The arguments to be passed to the RPC method.
36
+ * @param rpcType - The type of the RPC call (command, query, or get).
37
+ * @param options - Optional configuration for the call.
38
+ */
39
+ constructor(path: string[], args: Record<string, any> | undefined, rpcType: "command" | "query" | "get", options?: CallOptions);
40
+ getArgs(): Record<string, any>;
41
+ }
42
+ /** @internal */
43
+ export declare class WritableClientContext extends ClientContext {
44
+ _response: Response | undefined;
45
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * Represents the context for a client-side operation, holding metadata and options related to the operation.
3
+ * @internal
4
+ */
5
+ export class ClientContext {
6
+ /** The response */
7
+ get response() {
8
+ return this._response;
9
+ }
10
+ /** Indicates the time elapsed since the creation of the ClientContext instance */
11
+ get elapsedTime() {
12
+ return performance.now() - this.start;
13
+ }
14
+ /**
15
+ * Creates a new ClientContext instance.
16
+ * @param path - The path segments of the RPC method.
17
+ * @param args - The arguments to be passed to the RPC method.
18
+ * @param rpcType - The type of the RPC call (command, query, or get).
19
+ * @param options - Optional configuration for the call.
20
+ */
21
+ constructor(path, args, rpcType, options = {}) {
22
+ /** A Map of custom environment variables relevant to the client context. */
23
+ this.env = new Map();
24
+ this.start = performance.now();
25
+ this.rpcType = rpcType;
26
+ this.path = path;
27
+ this.args = new Map(Object.entries(args || {}));
28
+ const _headers = options.headers ? options.headers : new Headers();
29
+ _headers.set("accept", "application/msgpack");
30
+ this.request = {
31
+ get headers() {
32
+ return _headers;
33
+ },
34
+ };
35
+ this._headers = _headers;
36
+ this.abortSignal = options.abortSignal;
37
+ this.onProgress = options.onProgress;
38
+ }
39
+ getArgs() {
40
+ const obj = {};
41
+ for (const [key, value] of this.args)
42
+ obj[key] = value;
43
+ return obj;
44
+ }
45
+ }
46
+ /** @internal */
47
+ export class WritableClientContext extends ClientContext {
48
+ }
@@ -0,0 +1,9 @@
1
+ import type { ApiClientDefinition, MiddlewareConfig } from "./types.js";
2
+ /**
3
+ * Creates an API client and a corresponding middleware configuration object.
4
+ *
5
+ * @template T
6
+ * @param {string} [baseUrl='/api'] - The base URL for the API client.
7
+ * @returns {[ApiClientDefinition<T>, MiddlewareConfig<T>]} A tuple containing the API client and the middleware config object.
8
+ */
9
+ export declare function createClient<T>(baseUrl?: string): [ApiClientDefinition<T>, MiddlewareConfig<T>];