@thednp/rpc 0.1.0 → 0.2.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.
Files changed (39) hide show
  1. package/AGENTS.md +30 -15
  2. package/CHANGELOG.md +83 -0
  3. package/README.md +35 -29
  4. package/dist/express/express.d.mts +13 -1
  5. package/dist/express/express.d.mts.map +1 -1
  6. package/dist/express/express.mjs +51 -60
  7. package/dist/express/express.mjs.map +1 -1
  8. package/dist/fastify/fastify.d.mts +16 -1
  9. package/dist/fastify/fastify.d.mts.map +1 -1
  10. package/dist/fastify/fastify.mjs +40 -60
  11. package/dist/fastify/fastify.mjs.map +1 -1
  12. package/dist/fastify/plugin/fastify/plugin.d.mts +20 -3
  13. package/dist/fastify/plugin/fastify/plugin.d.mts.map +1 -1
  14. package/dist/fastify/plugin/fastify/plugin.mjs +39 -59
  15. package/dist/fastify/plugin/fastify/plugin.mjs.map +1 -1
  16. package/dist/h3/h3.d.mts +94 -0
  17. package/dist/h3/h3.d.mts.map +1 -0
  18. package/dist/h3/h3.mjs +245 -0
  19. package/dist/h3/h3.mjs.map +1 -0
  20. package/dist/helpers/helpers.d.mts +2 -0
  21. package/dist/helpers/helpers.d.mts.map +1 -1
  22. package/dist/helpers/helpers.mjs.map +1 -1
  23. package/dist/hono/hono.d.mts +14 -40
  24. package/dist/hono/hono.d.mts.map +1 -1
  25. package/dist/hono/hono.mjs +39 -71
  26. package/dist/hono/hono.mjs.map +1 -1
  27. package/dist/index.d.mts +36 -5
  28. package/dist/index.d.mts.map +1 -1
  29. package/dist/index.mjs +6 -1
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/koa/koa.d.mts +12 -1
  32. package/dist/koa/koa.d.mts.map +1 -1
  33. package/dist/koa/koa.mjs +46 -61
  34. package/dist/koa/koa.mjs.map +1 -1
  35. package/dist/server/server.d.mts +104 -7
  36. package/dist/server/server.d.mts.map +1 -1
  37. package/dist/server/server.mjs +94 -16
  38. package/dist/server/server.mjs.map +1 -1
  39. package/package.json +10 -4
package/AGENTS.md CHANGED
@@ -8,6 +8,7 @@ pnpm dev:express # Run examples/express dev server
8
8
  pnpm dev:fastify # Run examples/fastify dev server
9
9
  pnpm dev:hono # Run examples/hono dev server
10
10
  pnpm dev:koa # Run examples/koa dev server
11
+ pnpm dev:react-query # Run examples/react-query dev server
11
12
  pnpm dev:ssr # Run examples/ssr dev server
12
13
  pnpm lint # Lint + typecheck (deno lint + tsc)
13
14
  pnpm test # Run tests with coverage
@@ -32,32 +33,45 @@ pnpm release # Publish npm + jsr (scripts/release.js)
32
33
 
33
34
  ## Examples
34
35
 
35
- The `examples/` directory contains 6 example apps:
36
-
37
- | Example | Adapter | Type | Run Command | Config |
38
- | -----------| -------------------------------------------------------------------| ------| --------------------| ----------------------------------|
39
- | `spa` | Vite dev server (no adapter) | SPA | `pnpm dev` | `examples/spa/rpc.config.ts` |
40
- | `express` | Express | SSR | `pnpm dev:express` | `examples/express/rpc.config.ts` |
41
- | `fastify` | Fastify | SSR | `pnpm dev:fastify` | `examples/fastify/rpc.config.ts` |
42
- | `hono` | Hono | SSR | `pnpm dev:hono` | `examples/hono/rpc.config.ts` |
43
- | `koa` | Koa | SSR | `pnpm dev:koa` | `examples/koa/rpc.config.ts` |
44
- | `ssr` | Custom `http-express.ts` (Express-compatible `node:http` server ) | SSR | `pnpm dev:ssr` | `examples/ssr/rpc.config.ts` |
36
+ The `examples/` directory contains 8 example apps:
37
+
38
+ | Example | Adapter | Type | Run Command | Config |
39
+ | ----------------| -------------------------------------------------------------------| ------| -------------------------| -----------------------------------------|
40
+ | `spa` | Vite dev server (no adapter) | SPA | `pnpm dev` | `examples/spa/rpc.config.ts` |
41
+ | `express` | Express | SSR | `pnpm dev:express` | `examples/express/rpc.config.ts` |
42
+ | `fastify` | Fastify | SSR | `pnpm dev:fastify` | `examples/fastify/rpc.config.ts` |
43
+ | `h3` | h3 | SSR | `pnpm dev:h3` | `examples/h3/rpc.config.ts` |
44
+ | `hono` | Hono | SSR | `pnpm dev:hono` | `examples/hono/rpc.config.ts` |
45
+ | `koa` | Koa | SSR | `pnpm dev:koa` | `examples/koa/rpc.config.ts` |
46
+ | `react-query` | Express (React + @tanstack/react-query SSR) | SSR | `pnpm dev:react-query` | `examples/react-query/rpc.config.ts` |
47
+ | `solid-query` | Express (Solid + @tanstack/solid-query SSR) | SSR | `pnpm dev:solid-query` | `examples/solid-query/rpc.config.ts` |
48
+ | `ssr` | Custom `http-express.ts` (Express-compatible `node:http` server ) | SSR | `pnpm dev:ssr` | `examples/ssr/rpc.config.ts` | `examples/express/rpc.config.ts` |
49
+ | `fastify` | Fastify | SSR | `pnpm dev:fastify` | `examples/fastify/rpc.config.ts` |
50
+ | `hono` | Hono | SSR | `pnpm dev:hono` | `examples/hono/rpc.config.ts` |
51
+ | `koa` | Koa | SSR | `pnpm dev:koa` | `examples/koa/rpc.config.ts` |
52
+ | `react-query` | Express (React + @tanstack/react-query SSR) | SSR | `pnpm dev:react-query` | `examples/react-query/rpc.config.ts` |
53
+ | `solid-query` | Express (Solid + @tanstack/solid-query SSR) | SSR | `pnpm dev:solid-query` | `examples/solid-query/rpc.config.ts` |
54
+ | `ssr` | Custom `http-express.ts` (Express-compatible `node:http` server ) | SSR | `pnpm dev:ssr` | `examples/ssr/rpc.config.ts` |
45
55
 
46
56
  Each example follows the same structure:
47
57
 
48
58
  - `server.js` / `server.ts` — production server with Vite middleware in dev, static serving in prod
49
59
  - `src/api/server.ts` — RPC server functions (auto-scanned by the plugin)
50
- - `src/entry-server.ts` — SSR entry (hydrates on client)
51
- - `src/entry-client.ts` — Client entry
60
+ - `src/entry-server.ts` / `src/entry-server.tsx` — SSR entry (hydrates on client)
61
+ - `src/entry-client.ts` / `src/entry-client.tsx` — Client entry
52
62
  - `vite.config.ts` + `rpc.config.ts` — Configuration files
53
63
 
64
+ > Note: the `react-query` example is SSR with React 19 + `@tanstack/react-query` (Express adapter). It prefetches queries in `entry-server.tsx`, dehydrates them into `window.__REACT_QUERY_STATE__`, and hydrates on the client via `HydrationBoundary`.
65
+ >
66
+ > Note: the `solid-query` example is SSR with Solid + `@tanstack/solid-query` (Express adapter). The greeting is prefetched and serialized with `renderToStringAsync`; the GET form does **not** use a disabled `createQuery` (which would hang SSR — see `wiki/client-usage.md`), it calls `queryClient.fetchQuery()` on submit instead.
67
+
54
68
  ## Key Directories
55
69
 
56
70
  - `src/` — source for all packages (vite plugin, server, express, fastify, hono, koa adapters)
57
71
  - `dist/` — build output (not committed, generated by tsdown)
58
72
  - `tests/` — test files (one per adapter + plugin)
59
73
  - `tests/fixtures/` — test fixtures (config files, vite-mock.ts)
60
- - `examples/` — example apps (spa, express, fastify, hono, koa, ssr)
74
+ - `examples/` — example apps (spa, express, fastify, hono, koa, react-query, solid-query, ssr)
61
75
 
62
76
  ## Build Output (tsdown)
63
77
 
@@ -136,9 +150,10 @@ The framework's security boundary is the **RPC prefix-gated HTTP endpoint**. Inp
136
150
  - `wiki/quickstart.md` — Rebuild the Express SSR example from `create-vite` in under a minute (copy-paste)
137
151
  - `wiki/getting-started.md` — Installation, project structure, auto-scanning, and your first function
138
152
  - `wiki/configuration.md` — Configuration reference (`rpc.config.ts`, `vite.config.ts`, options)
139
- - `wiki/server-functions.md` — `createServerFunction` API, methods, validation
153
+ - `wiki/server-functions.md` — `createServerFunction` API, methods, validation, **request context (`getRequestContext`/`provideRequestContext`)** for per-request data access across async call stacks
154
+ - `wiki/nojs-fallback.md` — native (no-JS) `<form>` fallback / progressive enhancement pattern
140
155
  - `wiki/client-usage.md` — Client-side usage, type safety, react-query integration
141
156
  - `wiki/wire-protocol.md` — HTTP contract, request/response bodies, curl debugging
142
- - `wiki/adapters.md` — Framework adapters (Express, Fastify, Hono, Koa)
157
+ - `wiki/adapters.md` — Framework adapters (Express, Fastify, Hono, Koa, h3)
143
158
  - `wiki/security.md` — Security hardening
144
159
  - `wiki/best-practices.md` — Production patterns (auth, rate limiting, body limits, CSRF)
package/CHANGELOG.md CHANGED
@@ -1,5 +1,88 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.0] - 2026-08-09
4
+
5
+ ### Features
6
+
7
+ - **h3 adapter**: new `@thednp/rpc/h3` adapter with `createRPCMiddleware`, `viteMiddleware`, `attachRPC`, `attachVite`, `readBody`, `redirect` — fully typed, 100% test coverage, SSR example at `examples/h3`
8
+ - **Request Context API**: `provideRequestContext(init, cb)` / `getRequestContext()` — per-request `AsyncLocalStorage` context available to all server function code; `RequestEvent` includes `nativeEvent`, `locals`, adapter-bound `redirect`; works across Express, Fastify, Hono, Koa, and h3
9
+ - **`redirect` helper**: a new `redirect(res, location, status?)` server utility exported from `@thednp/rpc/server` and every adapter. It defaults to `303 See Other` for Post/Redirect/Get flows. The core version accepts an Express `Response` or a raw Node `ServerResponse`, delegating to native `res.redirect()` when available and otherwise writing the status code and `Location` header directly — the raw path is safe on Connect-compatible middlewares and serverless adapters (e.g. Netlify's `serverless-http` mock). Each adapter exports a redirect typed for its response object: Fastify `reply.redirect(location, status)` (v5 URL-first), Koa `ctx.redirect(location)` then `ctx.status = status` (setting status *after*, per koajs/koa#857), and Hono `return redirect(c, location, status)` (the `Response` is returned from the handler)
10
+
11
+ - **`application/x-www-form-urlencoded` content type**: the `contentType` option and `BodyResult` now include urlencoded. The generated client serializes the single object argument with `new URLSearchParams(args[0]).toString()`, so native HTML forms can POST straight to an RPC endpoint without client-side serialization. Adapters parse `key=value&key2=value2` into an object via `URLSearchParams` (every value arrives as a string; repeated keys collapse to the last value) and use the framework's pre-parsed body (`express.urlencoded()`, `@fastify/formbody`, `koa-body`) when one ran first
12
+ - **Content-type enforcement**: the RPC middleware now validates the request's `Content-Type` against the function's declared `contentType` before parsing the body, rejecting mismatches with `415 Unsupported Media Type` on all four adapters. JSON and text functions are enforced strictly (exact match wins after stripping `charset`/`boundary` parameters); form functions (`multipart/form-data` or `application/x-www-form-urlencoded`) are lenient between the two encodings so native urlencoded form submissions keep working on multipart-declared endpoints. Requests without a `Content-Type` header (curl, GET, legacy clients) are exempt from enforcement. The check lives in the new `hasContentTypeMismatch`/`isFormContentType` helpers exported from `@thednp/rpc/server`
13
+
14
+ ### Examples
15
+
16
+ - Add the `h3` example (`examples/h3`): SSR with [h3](https://h3.unjs.io/) using `createRPCMiddleware`, `serveStatic`, `toNodeListener`, and `viteMiddleware`. Dev mode bridges Vite's connect stack; prod serves static assets via `serveStatic` and falls through to SSR.
17
+ - The `demo` form-fallback middleware now uses the new `redirect` helper from `@thednp/rpc/express` instead of hand-writing the `303` + `Location` response
18
+ - Add a nojs fallback to the `demo` contact form (progressive enhancement): the form now carries native `action="/@demo/submit-contact" method="post"` attributes, and a small app-layer `createFormFallback` middleware (mounted before the RPC middleware) intercepts browser form navigations — recognized by `POST` + `application/x-www-form-urlencoded` + `Accept: text/html`, which the JS client never sends. Valid submissions get a `303` redirect to the pre-filled GitHub issue URL; invalid ones get a `303` redirect back to `/?name=..&errors=..`, where the server-rendered page (and hydration) recover the form values and show the same field-level error messages the RPC path would have returned. Validation, issue-URL building, and field lists are shared with the server function via the new `demo/src/lib/contact-form.ts`, and the server function now normalizes both multipart (`{ raw }`) and plain-object (urlencoded/JSON-with-fields) payloads
19
+ - Add the `react-query` example (Express + React 19 + `@tanstack/react-query` SSR): prefetches the greeting in `entry-server.tsx`, dehydrates it into `window.__REACT_QUERY_STATE__`, and hydrates on the client via `HydrationBoundary`
20
+ - Add the `solid-query` example (Express + Solid + `@tanstack/solid-query` SSR): prefetches the greeting, serializes it with `renderToStringAsync`, and documents the disabled-query SSR gotcha — a disabled `createQuery` hangs `renderToStringAsync` because the observer result carries a never-settling `promise` (from `experimental_prefetchInRender`) that seroval awaits forever; the example calls `queryClient.fetchQuery()` on submit instead
21
+
22
+ ### Docs
23
+
24
+ - Document Request Context API (`provideRequestContext`/`getRequestContext`) in `wiki/server-functions.md` — per-request `AsyncLocalStorage` context with `nativeEvent`, `locals`, adapter-bound `redirect` across all 5 adapters
25
+ - Document urlencoded content type in `wiki/server-functions.md` and `wiki/wire-protocol.md` (new `POST + application/x-www-form-urlencoded` section)
26
+ - Document the `415 Unsupported Media Type` response and content-type enforcement rules in `wiki/wire-protocol.md`
27
+ - Document the strict/lenient content-type behavior in `wiki/server-functions.md`
28
+ - Add a "Content-Type Enforcement" section to `wiki/security.md`
29
+ - Document the `redirect` helper in `wiki/server-functions.md` (new "Redirects (`redirect`)" section) and cross-reference it from `wiki/adapters.md`
30
+ - Add h3 adapter section to `wiki/adapters.md` (installation, usage, body limits)
31
+ - Add h3 body limits to `wiki/best-practices.md` (Content-Length fast path + streaming cap via `for await (const chunk of event.req.body)`)
32
+ - Update `llms.txt`, `AGENTS.md`, and `README.md` for the new adapter, examples, content type, enforcement behavior, redirect helper, and request context
33
+
34
+ ### Tests
35
+
36
+ - **100% coverage achieved** across all adapters (express, fastify, h3, hono, koa) — 920/920 statements, 559/559 branches, 144/144 functions, 898/898 lines
37
+ - Add h3 adapter test suite (`tests/h3.test.ts`): viteMiddleware (node/web runtime paths, settle-twice guard), createMiddleware (prefix/path filtering, name deduplication), createRPCMiddleware (dispatch, content-type enforcement, origin check, redirect default 303, cancel on close, error handling)
38
+ - Add redirect default-status tests to Express, Fastify, Hono, and Koa adapter suites (covers `status = 303` default param branch)
39
+ - Add urlencoded `readBody` tests for all four adapters (pre-parsed and raw stream paths), a urlencoded client-module codegen test in `plugin.test.ts`, and an end-to-end RPC middleware dispatch test
40
+ - Add content-type enforcement tests to the Express, Fastify, Hono, and Koa adapter suites (415 on json↔urlencoded/text↔json mismatch, lenient acceptance of urlencoded on multipart-declared functions) and unit tests for `hasContentTypeMismatch`/`isFormContentType` (parameter stripping, case insensitivity, no-header exemption, form leniency both directions)
41
+ - Add `redirect` tests: raw `ServerResponse` write path and native `.redirect()` delegation in `server-helpers.test.ts`, plus per-adapter suites asserting each framework API (Express `res.redirect(status, url)` vs raw write, Fastify URL-first `reply.redirect`, Koa status-after-`ctx.redirect`, Hono returning `c.redirect`'s `Response`)
42
+ - Fix `scripts/dev-test.js` to verify the query-framework examples' dynamically rendered greeting (`Hello Jane!`) instead of only the static `Hello World!` SSR marker; register the `solid-query` example prefix
43
+
44
+ ### Chores
45
+
46
+ - Bump version to `0.2.0`
47
+ - Sync `deno.json` version with `package.json`
48
+
49
+ ### Security
50
+
51
+ - **Content-type check moved before body read**: all four adapters now evaluate `hasContentTypeMismatch` *before* calling `readBody`, so mismatched requests are rejected with `415 Unsupported Media Type` without ever buffering/parsing the body (previously the body was read first, contradicting the documented behavior)
52
+ - **Streaming body-limit in the demo**: `demo/body-limit.ts` no longer buffers the entire body via `readBody` before checking the cap. It enforces the 1MB limit while the request stream is being read (dropping buffered chunks and destroying the request on overflow) and adds a `Content-Length` fast-path for obviously oversized requests — closing a memory-exhaustion gap on the demo's raw `node:http` and Netlify entry points
53
+ - **Netlify URL rewrite tightened**: `demo/netlify/functions/rpc.ts` now matches the `/.netlify/functions/rpc/` marker against the parsed `pathname` only (prefix match), instead of `indexOf` on the raw URL which could match inside a query string and rewrite unintended requests
54
+ - **`@hono/node-server` dedupe**: add a workspace `overrides: { '@hono/node-server': '>=2.0.5' }` in `pnpm-workspace.yaml` to collapse the vulnerable `1.19.17` transitive dependency (via `@hono/vite-dev-server`, which pins `^1.19.11`) onto the already-used patched `2.1.0`. `pnpm audit` is now clean
55
+
56
+ ## [0.1.1] - 2026-08-07
57
+
58
+ ### Security
59
+
60
+ - Unexpected exceptions no longer expose their message in development responses: `formatError` returns the generic `{ error: "Internal Server Error" }` for any non-`RPCError` error in every environment. `RPCError` payloads (developer-authored `message`, `code`, optional `data`) are still included in development so client-side error handling keeps working, while diagnostics stay server-side via the middleware's `console.error` logging (addresses the GitHub CodeQL `js/exception-information-leak` finding)
61
+
62
+ ### Refactor
63
+
64
+ - `scanForServerFiles` lazy-imports Vite inside the scan function instead of importing it statically at the top of the module — the standalone server entry no longer carries a static Vite dependency, so serverless function bundles that register API modules directly no longer drag Vite's node chunk (which imports esbuild, absent with Vite 8's rolldown) into the bundle
65
+ - Drop the redundant `config as ScanConfig` casts in `scanForServerFiles` (the merged config is already typed)
66
+
67
+ ### Fixes
68
+
69
+ - Netlify serverless deployment: externalize Vite from the function bundle via `[functions] external_node_modules = ["vite"]` in `netlify.toml`, so `@netlify/zip-it-and-ship-it` no longer fails with "Could not resolve 'esbuild'" when bundling the RPC function
70
+ - Fix broken documentation links in all 6 example READMEs (`wiki/setup.md` → `wiki/wire-protocol.md`, the former never existed)
71
+ - Remove stale `demo/src/render-bak.ts`; clean up the pnpm lockfile
72
+
73
+ ### Docs
74
+
75
+ - Update `wiki/security.md`, `wiki/wire-protocol.md`, `README.md`, and `llms.txt` to reflect that unexpected exception details never reach clients — only `RPCError` payloads, and only in development
76
+
77
+ ### Tests
78
+
79
+ - `formatError` dev-mode tests updated to assert the generic message for unexpected exceptions (plain `Error` and non-`Error` values), keeping the `RPCError` payload assertions
80
+
81
+ ### Chores
82
+
83
+ - Sync demo and all 6 examples to `@thednp/rpc ^0.1.0`
84
+ - Remove the leftover `0.0.14` changelog header (its entry was merged into `0.1.0`)
85
+
3
86
  ## [0.1.0] - 2026-08-06
4
87
 
5
88
  ### Features
package/README.md CHANGED
@@ -6,7 +6,7 @@
6
6
  [![JSR Version](https://img.shields.io/jsr/v/@thednp/rpc.svg)](https://jsr.io/@thednp/rpc)
7
7
  [![NPM Downloads](https://img.shields.io/npm/dm/@thednp/rpc.svg)](http://npm-stat.com/charts.html?package=@thednp/rpc)
8
8
 
9
- A Vite plugin for automatic RPC generation — simple, framework agnostic, and easy to use.
9
+ A Vite plugin for automatic RPC generation — write server functions, re-export them, call them. Simple and framework agnostic.
10
10
 
11
11
  ## Isomorphic Design
12
12
 
@@ -35,7 +35,7 @@ You define them in a file, import and call them where you need them. The plugin
35
35
  * `createFunction.ts` — server-side definition (wrapped handler with `AbortController`)
36
36
  * `getClientModules.ts` — build-time code generation (string template with validation)
37
37
  * `client-helpers.ts` — client-side runtime (thin `fetch` based modules)
38
- * `server-helpers.ts` — server-only utilities (`RPCError`, error formatting, glob file walking)
38
+ * `server-helpers.ts` — server-only utilities (`RPCError`, error formatting, `redirect`, glob file walking)
39
39
  * `scanForServerFiles.ts` — file discovery
40
40
  * **Adapters** — thin middleware wrappers
41
41
  </details>
@@ -90,7 +90,7 @@ Scan `src/api/` for classic `server.ts|js|mjs|mts` files, or switch to glob mode
90
90
  <details>
91
91
  <summary><b>Typed errors, safe by default</b></summary>
92
92
 
93
- Server errors return a generic `Internal Server Error` in production — no messages, codes, or stacks leak to clients. In development the message (and `code`/`data` for `RPCError`) is included so you can debug instantly. `multipart/form-data` content type is supported for file uploads via your framework's multipart parser.
93
+ Server errors return a generic `Internal Server Error` — no messages, codes, or stacks leak to clients, in any environment. Only `RPCError` payloads (developer-authored `message`/`code`/`data`) reach the client, and only in development, so you can debug instantly. `multipart/form-data` content type is supported for file uploads via your framework's multipart parser, and json/text/urlencoded requests are validated against the function's declared content type (`415 Unsupported Media Type` on mismatch; form encodings are interchangeable for nojs form fallbacks).
94
94
  </details>
95
95
 
96
96
  <details>
@@ -99,29 +99,29 @@ Server errors return a generic `Internal Server Error` in production — no mess
99
99
  Generic type inference flows from your server function's arguments and return type all the way to the client stub. You get autocomplete for function names, argument types, and return types without writing a single type annotation on the client side.
100
100
  </details>
101
101
 
102
- ## Demos
103
-
104
- | Example | Source Code | Try online |
105
- | -----------------| --------------------------------------------------------------------------------| ------------------------------------------------------------------------------------------|
106
- | SPA - node:http | [examples/spa](https://github.com/thednp/rpc/tree/master/examples/spa) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/spa) |
107
- | SSR - node:http | [examples/ssr](https://github.com/thednp/rpc/tree/master/examples/ssr) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/ssr) |
108
- | Express | [examples/express](https://github.com/thednp/rpc/tree/master/examples/express) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/express) |
109
- | Fastify | [examples/fastify](https://github.com/thednp/rpc/tree/master/examples/fastify) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/fastify) |
110
- | Hono | [examples/hono](https://github.com/thednp/rpc/tree/master/examples/hono) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/hono) |
111
- | Koa | [examples/koa](https://github.com/thednp/rpc/tree/master/examples/koa) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/koa) |
102
+ ## Examples
112
103
 
113
- > **NOTE**: Stackblitz is currently working on upgrading their platform. Demos may not work properly.
104
+ | Source | Demo | Clone |
105
+ | ----------------------------------------------------------------------------------------| ----------------------------------------------------------------------------------------------| ---------------------------------------------------------|
106
+ | [examples/spa](https://github.com/thednp/rpc/tree/master/examples/spa) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/spa) | `pmpm dlx degit thednp/rpc/examples/spa my-app` |
107
+ | [examples/ssr](https://github.com/thednp/rpc/tree/master/examples/ssr) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/ssr) | `pnpm dlx degit thednp/rpc/examples/ssr my-app` |
108
+ | [examples/express](https://github.com/thednp/rpc/tree/master/examples/express) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/express) | `pnpm dlx degit thednp/rpc/examples/express my-app` |
109
+ | [examples/fastify](https://github.com/thednp/rpc/tree/master/examples/fastify) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/fastify) | `pnpm dlx degit thednp/rpc/examples/fastify my-app` |
110
+ | [examples/h3](https://github.com/thednp/rpc/tree/master/examples/h3) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/h3) | `pnpm dlx degit thednp/rpc/examples/h3 my-app` |
111
+ | [examples/hono](https://github.com/thednp/rpc/tree/master/examples/hono) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/hono) | `pnpm dlx degit thednp/rpc/examples/hono my-app` |
112
+ | [examples/koa](https://github.com/thednp/rpc/tree/master/examples/koa) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/koa) | `pnpm dlx degit thednp/rpc/examples/koa my-app` |
113
+ | [examples/react-query](https://github.com/thednp/rpc/tree/master/examples/react-query) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/react-query) | `pnpm dlx degit thednp/rpc/examples/react-query my-app` |
114
+ | [examples/solid-query](https://github.com/thednp/rpc/tree/master/examples/solid-query) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/solid-query) | `pnpm dlx degit thednp/rpc/examples/solid-query my-app` |
114
115
 
115
- ## Examples
116
+ > **Clone an example**: `degit` scaffolds a fresh copy straight from the repo — no git history, ready to run:
116
117
 
117
- | Example | Adapter | Type | Run Command | RPC Approach |
118
- | ---------| -----------------------------------------------| ------| --------------------| ------------------------------------|
119
- | spa | Vite dev server (Connect, Express-compatible) | SPA | `pnpm dev` | Client stubs only |
120
- | express | Express | SSR | `pnpm dev:express` | Direct import (SSR) + client stubs |
121
- | fastify | Fastify | SSR | `pnpm dev:fastify` | Direct import (SSR) + client stubs |
122
- | hono | Hono | SSR | `pnpm dev:hono` | Direct import (SSR) + client stubs |
123
- | koa | Koa | SSR | `pnpm dev:koa` | Direct import (SSR) + client stubs |
124
- | ssr | Custom `node:http` (Express-compatible) | SSR | `pnpm dev:ssr` | Direct import (SSR) + client stubs |
118
+ ```bash
119
+ # Scaffold the Express example
120
+ pnpm dlx degit thednp/rpc/examples/express my-rpc-app
121
+ cd my-rpc-app
122
+ pnpm install
123
+ pnpm dev
124
+ ```
125
125
 
126
126
  SSR examples demonstrate isomorphic usage: server functions are imported directly during server-side rendering (`entry-server.ts`) and also called from the client via auto-generated fetch stubs. The SPA example uses only the client-side stubs.
127
127
 
@@ -319,6 +319,12 @@ The `readBody` utility of each adapter doesn't cap raw request bodies by default
319
319
  Server functions only support `GET` and `POST` (default `POST`). RPC dispatch is not REST — `PUT`/`PATCH`/`DELETE` carry resource semantics that don't apply to function calls, and `OPTIONS` must stay reserved for CORS preflight. Every accepted method is another dispatch path to validate; keeping the surface minimal (and defaulting to `POST`) reduces CSRF and parsing attack surface. See [Server Functions Guide](./wiki/server-functions.md) for details.
320
320
  </details>
321
321
 
322
+ <details>
323
+ <summary><b>Content-type enforcement</b></summary>
324
+
325
+ Request bodies are validated against the function's declared `contentType` before parsing — mismatches get a `415 Unsupported Media Type`. JSON and text functions require an exact match (after stripping `charset`/`boundary` parameters); the two form encodings are interchangeable so native urlencoded `<form>` submissions keep working on multipart-declared endpoints (nojs progressive enhancement). Requests without a `Content-Type` header are exempt, so curl and `GET` keep working unchanged. See [Wire Protocol](./wiki/wire-protocol.md) for details.
326
+ </details>
327
+
322
328
  ---
323
329
  The full threat model, including edge cases and configuration options for tightening things further, is documented in [Security](./wiki/security.md).
324
330
 
@@ -327,13 +333,13 @@ The full threat model, including edge cases and configuration options for tighte
327
333
 
328
334
  - [Quick Start](./wiki/quickstart.md) — Rebuild the Express SSR example from `create-vite` in under a minute
329
335
  - [Getting Started](./wiki/getting-started.md) — Installation, project structure, and your first function
330
- - [Configuration](./wiki/configuration.md)
331
- - [Server Functions](./wiki/server-functions.md)
332
- - [Client Usage](./wiki/client-usage.md)
336
+ - [Configuration](./wiki/configuration.md) — Full configuration reference
337
+ - [Server Functions](./wiki/server-functions.md) — Creating server functions
338
+ - [Client Usage](./wiki/client-usage.md) — Client-side usage
333
339
  - [Wire Protocol](./wiki/wire-protocol.md) — The HTTP contract behind the generated clients (curl debugging)
334
- - [Adapters](./wiki/adapters.md)
335
- - [Best Practices](./wiki/best-practices.md)
336
- - [Security](./wiki/security.md)
340
+ - [Adapters](./wiki/adapters.md) — Framework adapters
341
+ - [Best Practices](./wiki/best-practices.md) — Tips and best practices
342
+ - [Security](./wiki/security.md) — Security hardening
337
343
 
338
344
  ## License
339
345
 
@@ -107,6 +107,18 @@ declare const isExpressRequest: (req: IncomingMessage | Request) => req is Reque
107
107
  * @returns True if the response is an Express Response
108
108
  */
109
109
  declare const isExpressResponse: (res: ServerResponse | Response) => res is Response;
110
+ /**
111
+ * Issues an HTTP redirect on an Express or raw Node ServerResponse.
112
+ * Uses Express's native `res.redirect(status, location)` when an Express
113
+ * Response is provided, otherwise writes the status code and `Location`
114
+ * header directly on the raw `ServerResponse` (safe for Connect-compatible
115
+ * middlewares and serverless adapters whose mock responses lack `.redirect`).
116
+ * Defaults to `303 See Other` for convention (Post/Redirect/Get).
117
+ * @param res - Express Response or raw Node ServerResponse
118
+ * @param location - The URL to redirect to
119
+ * @param status - HTTP status code, defaults to 303
120
+ */
121
+ declare const redirect: (res: ServerResponse | Response, location: string, status?: number) => void;
110
122
  /**
111
123
  * Type guard that checks whether a request has a pre-parsed body (`body` property).
112
124
  * Used to detect if a body-parser middleware already consumed the stream.
@@ -129,5 +141,5 @@ declare const getRequestDetails: (request: Request | IncomingMessage) => Request
129
141
  */
130
142
  declare const getResponseDetails: (response: Response | ServerResponse) => ResponseDetails;
131
143
  //#endregion
132
- export { type ExpressMiddlewareFn, type ExpressMiddlewareHooks, type ExpressMiddlewareOptions, type RequestDetails, type ResponseDetails, attachRPC, attachVite, createMiddleware, createRPCMiddleware, getRequestDetails, getResponseDetails, hasPreParsedBody, isExpressRequest, isExpressResponse, readBody };
144
+ export { type ExpressMiddlewareFn, type ExpressMiddlewareHooks, type ExpressMiddlewareOptions, type RequestDetails, type ResponseDetails, attachRPC, attachVite, createMiddleware, createRPCMiddleware, getRequestDetails, getResponseDetails, hasPreParsedBody, isExpressRequest, isExpressResponse, readBody, redirect };
133
145
  //# sourceMappingURL=express.d.mts.map
@@ -1 +1 @@
1
- {"version":3,"file":"express.d.mts","names":[],"sources":["../../src/express/types.d.ts","../../src/express/createMiddleware.ts","../../src/express/helpers.ts"],"mappings":";;;;;;;;KAgBY,2BAA2B;;;;;KAM3B,uBACV,UAAU,yCAEV,iBAAiB,QAAQ,8BACtB;;;;UAKY;;;;;;;EAOf,UACE,KAAK,kBAAkB,SACvB,KAAK,iBAAiB,UACtB,MAAM,QAAQ,eAAe,iBAC1B;;;;;;KAOK;;EAEV;;EAEA,YAAY,cAAc;;EAE1B;;EAEA,gBAAgB;;EAEhB,eAAe,cAAc,QAAQ,eAAe;;;;;KAM1C;;EAEV;;EAEA;;EAEA,cAAc;;EAEd,SAAS;;EAET;;;;;;;;;;;cCvCW,kBAAkB;;;;;;;;cAyElB,qBAAqB;;;;;;;;iBC3FZ,UAAU,KAAK,UAAO;;;;;;iBAW5B,WAAW,KAAK,SAAS,MAAM;;;;;;;;cAWlC,WAAQ,KACd,UAAiB,oBACrB,QAAQ;;;;;;cAyEE,mBAAgB,KACtB,kBAAkB,YACtB,OAAO;;;;;;cASG,oBAAiB,KACvB,iBAAiB,aACrB,OAAO;;;;;;;cAUG,mBAAgB,KACtB,kBAAkB,YACtB,OAAO;;;;;;;cAUG,oBAAiB,SACnB,UAAiB,oBACzB;;;;;;;cAqBU,qBAAkB,UACnB,WAAkB,mBAC3B"}
1
+ {"version":3,"file":"express.d.mts","names":[],"sources":["../../src/express/types.d.ts","../../src/express/createMiddleware.ts","../../src/express/helpers.ts"],"mappings":";;;;;;;;KAgBY,2BAA2B;;;;;KAM3B,uBACV,UAAU,yCAEV,iBAAiB,QAAQ,8BACtB;;;;UAKY;;;;;;;EAOf,UACE,KAAK,kBAAkB,SACvB,KAAK,iBAAiB,UACtB,MAAM,QAAQ,eAAe,iBAC1B;;;;;;KAOK;;EAEV;;EAEA,YAAY,cAAc;;EAE1B;;EAEA,gBAAgB;;EAEhB,eAAe,cAAc,QAAQ,eAAe;;;;;KAM1C;;EAEV;;EAEA;;EAEA,cAAc;;EAEd,SAAS;;EAET;;;;;;;;;;;cC3BW,kBAAkB;;;;;;;;cAyElB,qBAAqB;;;;;;;;iBCvGZ,UAAU,KAAK,UAAO;;;;;;iBAc5B,WAAW,KAAK,SAAS,MAAM;;;;;;;;cAWlC,WAAQ,KACd,UAAiB,oBACrB,QAAQ;;;;;;cAqFE,mBAAgB,KACtB,kBAAkB,YACtB,OAAO;;;;;;cASG,oBAAiB,KACvB,iBAAiB,aACrB,OAAO;;;;;;;;;;;;cAeG,WAAQ,KACd,iBAAiB,UAAe,kBACrB;;;;;;;cAkBL,mBAAgB,KACtB,kBAAkB,YACtB,OAAO;;;;;;;cAUG,oBAAiB,SACnB,UAAiB,oBACzB;;;;;;;cAqBU,qBAAkB,UACnB,WAAkB,mBAC3B"}
@@ -1,4 +1,4 @@
1
- import { scanForServerFiles, serverFunctionsMap } from "@thednp/rpc/server";
1
+ import { escapeRegExp, formatError, hasContentTypeMismatch, provideRequestContext, scanForServerFiles, serverFunctionsMap } from "@thednp/rpc/server";
2
2
  //#region src/options.ts
3
3
  const defaultRPCOptions = {
4
4
  rpcPrefix: "__rpc",
@@ -44,9 +44,10 @@ const readBody = (req) => {
44
44
  const contentType = req.headers["content-type"]?.toLowerCase() || "";
45
45
  const isJSON = contentType.includes("json");
46
46
  const isMultipart = contentType.includes("multipart/form-data");
47
+ const isUrlEncoded = contentType.includes("urlencoded");
47
48
  resolve({
48
- contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : "text/plain",
49
- data: isMultipart ? req.body : isJSON ? req.body : String(req.body)
49
+ contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : isUrlEncoded ? "application/x-www-form-urlencoded" : "text/plain",
50
+ data: isMultipart ? req.body : isJSON ? req.body : isUrlEncoded ? req.body : String(req.body)
50
51
  });
51
52
  return;
52
53
  }
@@ -65,10 +66,11 @@ const readBody = (req) => {
65
66
  const incomingType = req.headers["content-type"]?.toLowerCase() || "";
66
67
  const isJSON = incomingType.includes("json");
67
68
  const isMultipart = incomingType.includes("multipart/form-data");
69
+ const isUrlEncoded = incomingType.includes("urlencoded");
68
70
  try {
69
- const data = isMultipart ? { raw: body } : JSON.parse(body);
71
+ const data = isMultipart ? { raw: body } : isUrlEncoded ? Object.fromEntries(new URLSearchParams(body)) : JSON.parse(body);
70
72
  resolve({
71
- contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : "text/plain",
73
+ contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : isUrlEncoded ? "application/x-www-form-urlencoded" : "text/plain",
72
74
  data: isMultipart ? data : data
73
75
  });
74
76
  } catch (_e) {
@@ -102,6 +104,26 @@ const isExpressResponse = (res) => {
102
104
  return "json" in res && "send" in res;
103
105
  };
104
106
  /**
107
+ * Issues an HTTP redirect on an Express or raw Node ServerResponse.
108
+ * Uses Express's native `res.redirect(status, location)` when an Express
109
+ * Response is provided, otherwise writes the status code and `Location`
110
+ * header directly on the raw `ServerResponse` (safe for Connect-compatible
111
+ * middlewares and serverless adapters whose mock responses lack `.redirect`).
112
+ * Defaults to `303 See Other` for convention (Post/Redirect/Get).
113
+ * @param res - Express Response or raw Node ServerResponse
114
+ * @param location - The URL to redirect to
115
+ * @param status - HTTP status code, defaults to 303
116
+ */
117
+ const redirect = (res, location, status = 303) => {
118
+ if (isExpressResponse(res)) {
119
+ res.redirect(status, location);
120
+ return;
121
+ }
122
+ res.statusCode = status;
123
+ res.setHeader("Location", location);
124
+ res.end();
125
+ };
126
+ /**
105
127
  * Type guard that checks whether a request has a pre-parsed body (`body` property).
106
128
  * Used to detect if a body-parser middleware already consumed the stream.
107
129
  * @param req - A Node IncomingMessage or Express Request
@@ -158,66 +180,15 @@ const getResponseDetails = (response) => {
158
180
  };
159
181
  };
160
182
  //#endregion
161
- //#region src/tools.ts
162
- /**
163
- * Escapes special regex metacharacters in a string.
164
- * Used to safely embed user-configurable values (like rpcPrefix) into regular expressions,
165
- * preventing ReDoS and regex injection attacks.
166
- * @param s - The raw string to escape
167
- * @returns The escaped string safe for use in new RegExp()
168
- */
169
- function escapeRegExp(s) {
170
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
171
- }
172
- //#endregion
173
183
  //#region src/constants.ts
174
184
  const FUNCTION_NOT_FOUND = "Function not found";
175
185
  const METHOD_NOT_ALLOWED = "Method Not Allowed";
176
186
  const REQUEST_FORBIDDEN = "Forbidden";
177
- const INTERNAL_SERVER_ERROR = "Internal Server Error";
187
+ const UNSUPPORTED_MEDIA_TYPE = "Unsupported Media Type";
178
188
  const CLIENT_DISCONNECTED = "client disconnected";
179
189
  /** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */
180
190
  const MIDDLEWARE_NAME_USED = (name) => `The middleware name "${name}" is already used.`;
181
191
  //#endregion
182
- //#region src/server-helpers.ts
183
- /**
184
- * A typed error thrown from server functions.
185
- * The middleware serializes the `message` and `code` in the response,
186
- * allowing clients to recognise and handle specific error conditions.
187
- */
188
- var RPCError = class extends Error {
189
- /** Machine-readable error code (e.g. "VALIDATION_FAILED", "UNAUTHORIZED") */
190
- code;
191
- /** Optional diagnostic payload */
192
- data;
193
- constructor(message, code = "INTERNAL", data) {
194
- super(message);
195
- this.name = "RPCError";
196
- this.code = code;
197
- this.data = data;
198
- }
199
- };
200
- /**
201
- * Formats an error for the RCP middleware response.
202
- * In development the full message and stack are included so developers
203
- * can quickly identify issues. In production only the generic
204
- * "Internal Server Error" is sent, preventing information disclosure.
205
- */
206
- const formatError = (err, isProduction) => {
207
- if (!isProduction) {
208
- if (err instanceof RPCError) {
209
- const payload = {
210
- error: err.message || "Internal Server Error",
211
- code: err.code
212
- };
213
- if (err.data !== void 0) payload.data = err.data;
214
- return payload;
215
- }
216
- return { error: (err instanceof Error ? err.message : String(err)) || "Internal Server Error" };
217
- }
218
- return { error: INTERNAL_SERVER_ERROR };
219
- };
220
- //#endregion
221
192
  //#region src/express/createMiddleware.ts
222
193
  let middlewareCount = 0;
223
194
  const middlewareStack = /* @__PURE__ */ new Set();
@@ -295,15 +266,35 @@ const createRPCMiddleware = (initialOptions = {}) => {
295
266
  const raw = searchParams.get("args");
296
267
  if (raw) args = JSON.parse(raw);
297
268
  } else {
269
+ if (hasContentTypeMismatch(serverFunction.options?.contentType ?? "application/json", req.headers["content-type"])) {
270
+ sendResponse(415, { error: UNSUPPORTED_MEDIA_TYPE });
271
+ return;
272
+ }
298
273
  const body = await readBody(req);
299
274
  args = Array.isArray(body.data) ? body.data : [body.data];
300
275
  }
301
- const { data, cancel } = serverFunction.handler(...args);
276
+ const requestEvent = {
277
+ request: req,
278
+ response: res,
279
+ nativeEvent: {
280
+ req,
281
+ res
282
+ },
283
+ locals: res.locals ?? {},
284
+ redirect: (location, status = 303) => {
285
+ requestEvent.redirected = {
286
+ location,
287
+ status
288
+ };
289
+ redirect(res, location, status);
290
+ }
291
+ };
292
+ const { data, cancel } = provideRequestContext(requestEvent, () => serverFunction.handler(...args));
302
293
  const onClose = () => cancel(CLIENT_DISCONNECTED);
303
294
  req.on("close", onClose);
304
295
  const result = await data;
305
296
  req.off("close", onClose);
306
- if (!res.headersSent) sendResponse(200, { data: result });
297
+ if (!requestEvent.redirected && !res.headersSent) sendResponse(200, { data: result });
307
298
  } catch (err) {
308
299
  console.error(String(err));
309
300
  const isProduction = process.env.NODE_ENV === "production";
@@ -313,6 +304,6 @@ const createRPCMiddleware = (initialOptions = {}) => {
313
304
  });
314
305
  };
315
306
  //#endregion
316
- export { attachRPC, attachVite, createMiddleware, createRPCMiddleware, getRequestDetails, getResponseDetails, hasPreParsedBody, isExpressRequest, isExpressResponse, readBody };
307
+ export { attachRPC, attachVite, createMiddleware, createRPCMiddleware, getRequestDetails, getResponseDetails, hasPreParsedBody, isExpressRequest, isExpressResponse, readBody, redirect };
317
308
 
318
309
  //# sourceMappingURL=express.mjs.map