@thednp/rpc 0.3.6 → 0.3.7
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/AGENTS.md +25 -7
- package/CHANGELOG.md +116 -0
- package/README.md +2 -2
- package/dist/config/config.d.mts +0 -7
- package/dist/config/config.d.mts.map +1 -1
- package/dist/config/config.mjs +5 -1
- package/dist/config/config.mjs.map +1 -1
- package/dist/express/express.d.mts +2 -9
- package/dist/express/express.d.mts.map +1 -1
- package/dist/express/express.mjs +70 -22
- package/dist/express/express.mjs.map +1 -1
- package/dist/fastify/fastify.d.mts +2 -8
- package/dist/fastify/fastify.d.mts.map +1 -1
- package/dist/fastify/fastify.mjs +72 -22
- package/dist/fastify/fastify.mjs.map +1 -1
- package/dist/fastify/plugin/fastify/plugin.d.mts +13 -49
- package/dist/fastify/plugin/fastify/plugin.d.mts.map +1 -1
- package/dist/fastify/plugin/fastify/plugin.mjs +71 -21
- package/dist/fastify/plugin/fastify/plugin.mjs.map +1 -1
- package/dist/h3/h3.d.mts +2 -2
- package/dist/h3/h3.d.mts.map +1 -1
- package/dist/h3/h3.mjs +68 -21
- package/dist/h3/h3.mjs.map +1 -1
- package/dist/helpers/helpers.d.mts.map +1 -1
- package/dist/helpers/helpers.mjs +2 -0
- package/dist/helpers/helpers.mjs.map +1 -1
- package/dist/hono/hono.d.mts +2 -2
- package/dist/hono/hono.d.mts.map +1 -1
- package/dist/hono/hono.mjs +93 -23
- package/dist/hono/hono.mjs.map +1 -1
- package/dist/index.d.mts +26 -19
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +62 -28
- package/dist/index.mjs.map +1 -1
- package/dist/koa/koa.d.mts +2 -9
- package/dist/koa/koa.d.mts.map +1 -1
- package/dist/koa/koa.mjs +74 -22
- package/dist/koa/koa.mjs.map +1 -1
- package/dist/server/server.d.mts +107 -13
- package/dist/server/server.d.mts.map +1 -1
- package/dist/server/server.mjs +146 -16
- package/dist/server/server.mjs.map +1 -1
- package/llms.txt +4 -2
- package/package.json +5 -3
package/AGENTS.md
CHANGED
|
@@ -13,7 +13,7 @@ pnpm dev:koa # Run examples/koa dev server
|
|
|
13
13
|
pnpm dev:react-query # Run examples/react-query dev server
|
|
14
14
|
pnpm dev:solid-query # Run examples/solid-query dev server
|
|
15
15
|
pnpm dev:ssr # Run examples/ssr dev server
|
|
16
|
-
pnpm lint # Lint + typecheck (deno lint + tsc)
|
|
16
|
+
pnpm lint # Lint + typecheck (deno lint + tsc src + tsc tests)
|
|
17
17
|
pnpm test # Run tests once with coverage (vitest run --coverage)
|
|
18
18
|
pnpm test:watch # Run tests in watch mode with coverage
|
|
19
19
|
pnpm test:ui # Run tests with UI
|
|
@@ -23,6 +23,7 @@ pnpm lint:ts # deno lint src
|
|
|
23
23
|
pnpm fix:ts # deno lint src --fix
|
|
24
24
|
pnpm check:ts # tsc -noEmit
|
|
25
25
|
pnpm format # deno fmt src tests examples/**/src
|
|
26
|
+
pnpm format:check # deno fmt --check (what CI runs — never rewrites)
|
|
26
27
|
pnpm clean # Remove build artifacts and caches
|
|
27
28
|
pnpm build # tsdown (outputs to dist/)
|
|
28
29
|
pnpm up:examples # Update all example deps (to latest published @thednp/rpc + latest example deps)
|
|
@@ -105,7 +106,7 @@ The tsdown.config.ts produces multiple entries:
|
|
|
105
106
|
| `tests/hono.test.ts` | Hono helpers, createMiddleware, createRPCMiddleware | |
|
|
106
107
|
| `tests/koa.test.ts` | Koa helpers, createMiddleware, createRPCMiddleware | |
|
|
107
108
|
|
|
108
|
-
Run `pnpm build` before `pnpm test` — `tests/adapter-exports.test.ts` reads the emitted declarations, and the adapters themselves import `@thednp/rpc/server` (aliased to `src/server.ts` under vitest, so only that one suite needs the build).
|
|
109
|
+
Run `pnpm build` before `pnpm test` — `tests/adapter-exports.test.ts` reads the emitted declarations and hard-fails without them, and the adapters themselves import `@thednp/rpc/server` (aliased to `src/server.ts` under vitest, so only that one suite needs the build). CI now builds first: `dist/` is **committed**, so without that step the suite silently validated the last-committed bundle and a type export removed or renamed in `src/` went unnoticed (demonstrated: deleting `ExpressApp` from `src/express/types.d.ts` passed 56/56 with no build, fails with one). `tsc` has no `paths` mapping for `@thednp/rpc/server`, so it type-checks against `dist/` — a source change that adds an export needs a rebuild before `pnpm lint` will accept it.
|
|
109
110
|
|
|
110
111
|
## Important Notes
|
|
111
112
|
|
|
@@ -113,7 +114,20 @@ Run `pnpm build` before `pnpm test` — `tests/adapter-exports.test.ts` reads th
|
|
|
113
114
|
- Uses `deno` for linting and formatting (not eslint/prettier)
|
|
114
115
|
- Uses `tsdown` for bundling (not rollup/vite directly)
|
|
115
116
|
- Uses `vitest` for testing with `istanbul` coverage
|
|
116
|
-
- The `
|
|
117
|
+
- The `scannedTargets` memo in `scanForServerFiles.ts` persists across tests — reset modules to bypass, or vary the `(scanRoot, serverFiles, rpcPrefix)` triple. It is keyed per target rather than being one process-wide boolean, which is what lets a second prefix be scanned after the first; do not collapse it back to a single flag
|
|
118
|
+
- **Coverage measures line execution, not feature correctness.** Two bugs shipped at 100% coverage because every fixture in `tests/fixtures/*.ts` calls `setGlobalPrefix(undefined)`, so the only state the global-prefix path cares about was never exercised. When adding a test, assert *which* code path ran — not just that output is non-empty (the vite 7/8 transform tests did the latter, which is why the `Number(viteVersion[0])` bug hid)
|
|
119
|
+
- `setGlobalPrefix` is stored on `globalThis[Symbol.for("thednp.rpc.globalPrefix")]`, so probes can read it without importing the module
|
|
120
|
+
|
|
121
|
+
## Fixed in 0.3.7 — prefix resolution
|
|
122
|
+
|
|
123
|
+
A 2026-09-27 audit found the global prefix and the dispatch prefix were resolved by two independent pieces of logic that could disagree. Both are now fixed; the notes below are the guardrails.
|
|
124
|
+
|
|
125
|
+
- **`resolveRPCPrefix(rpcPrefix?)` (`src/server-helpers.ts`) is the single resolution point** — explicit argument → `getGlobalPrefix()` → `defaultPrefix`. All five adapters call it in *both* places they need it (the outer `createMiddleware` gate and the `createRPCMiddleware` dispatch), so parity is structural rather than a convention. Do not reintroduce a local `a || b || c` in an adapter
|
|
126
|
+
- **`createRPCMiddleware` no longer injects `{ rpcPrefix: defaultRPCOptions.rpcPrefix }`.** That default is what made the `|| getGlobalPrefix()` fallback unreachable: `rpcPrefix` was always the truthy `"__rpc"`, so with `setGlobalPrefix("@demo")` a function registered under `@demo` (via `createServerFunction`, which does honour the global prefix) was unreachable — `/@demo/greet` got `next()` and `/__rpc/greet` got `"Function not found"`. The resolved prefix is now passed down from `createRPCMiddleware` to the gate as an explicit value
|
|
127
|
+
- **The boundary regex is built from the resolved prefix, and only when a prefix is supplied.** Two traps here: building it unconditionally would start prefix-gating a bare `createMiddleware({ path, handler })`, which has never gated; building it from the *raw* argument would make it `null` and silently disable gating. The gate gates on `rpcPrefix ? ... : null`; the value is the resolved prefix
|
|
128
|
+
- **`loadRPCConfig` publishes the global prefix on every return path**, including the default config-file discovery loop — that is the common case, and it used to be the one path that skipped the call
|
|
129
|
+
- **Known, deliberately unchanged:** a config file that throws resets the module-level `RPCConfig` cache to the defaults. The "fall back to defaults" contract is asserted by tests, so a failed load downgrades a previously loaded config for the rest of the process. `MiddlewareOptions.rpcPrefix` also declared a `false` that was documented nowhere, tested nowhere, and handled nowhere — measured as byte-identical to omitting the option. Removed from the type in the same release; do not reintroduce it without implementing and documenting what it does, since a `false` that reads like "disable prefix gating" while gating on the default is the more dangerous shape
|
|
130
|
+
- **The coverage lesson stands:** both bugs shipped at 100% coverage because every fixture in `tests/fixtures/*.ts` called `setGlobalPrefix(undefined)`, so the state the feature is *about* was never exercised. `tests/express.test.ts` now has a `global-prefix dispatch` block that sets a real prefix, and `tests/plugin.test.ts` has a file-level `afterEach` resetting it — do not remove either
|
|
117
131
|
|
|
118
132
|
## Framework
|
|
119
133
|
|
|
@@ -129,10 +143,12 @@ Run `pnpm build` before `pnpm test` — `tests/adapter-exports.test.ts` reads th
|
|
|
129
143
|
- **Prefix boundary check**: All adapters use `new RegExp(\`^/${escapeRegExp(rpcPrefix)}/\`)` instead of `startsWith` to prevent path segment bypassing (e.g., `/__rpc-evil/foo` no longer matches prefix `"__rpc"`)
|
|
130
144
|
- **Prefix regex injection prevention**: `rpcPrefix` config string is escaped via `escapeRegExp()` before being embedded in the boundary regex, preventing ReDoS or unintended matching from metacharacters in the prefix
|
|
131
145
|
- **Regex compilation hoisted**: All prefix/path regexes are compiled once at middleware creation time (not per-request), eliminating per-request regex overhead
|
|
132
|
-
- **Koa URL normalization** → **URL normalization (all adapters)**: every adapter
|
|
146
|
+
- **Koa URL normalization** → **URL normalization (all adapters)**: every adapter normalizes the request URL before prefix checking. `safeURL()` (`src/server-helpers.ts`) **never throws** — malformed request-targets (`/\`, `//`, `/\/`) make the WHATWG parser raise `TypeError: Invalid URL`, and the adapters parse the URL *before* their dispatch `try` block, so an unguarded throw became an unhandled rejection that crashed raw `node:http` hosts and Express 4. On failure it falls back to the base root, so the pathname never matches the prefix and the request degrades to `next()`/404. The call site differs per adapter, so "all adapters use `safeURL`" is only *nearly* true: **fastify/hono/koa** call it directly, **express** reaches it through `getRequestDetails(req)` in `src/express/helpers.ts`, and **h3 does not call it at all** — it reads `event.url.pathname`, which h3 has already parsed, so the throw-on-malformed-target case is handled upstream by h3 rather than by `safeURL`
|
|
133
147
|
- **GET `?args=` array validation**: all five adapters `JSON.parse` the query value and reject anything that is not an array with `400 Bad Request` before dispatch. Without the guard, `?args={"a":1}` spread an object into `handler(...args)` (`TypeError`) and `?args="abc"` spread a string into characters — confusing 500s on attacker-controlled input
|
|
134
148
|
- **Code injection prevention in client module generation**: `getClientModules.ts` validates all interpolated identifiers (`fnName`, `fnEntry`, `rpcPrefix`) against `/^[A-Za-z_$][A-Za-z0-9_$]*$/` (and a path-safe variant allowing `/`, `@`, `:`, `-`) before interpolating into the generated client bundle. This prevents code injection via malicious export names or prefixes containing template literal interpolations (`${...}`), backticks, or `</script>` sequences.
|
|
135
149
|
- **Body size limits**: Host frameworks cap parsed JSON bodies — Express (`express.json({ limit })`), Fastify (`bodyLimit`), Koa (`koa-body`), Hono (`hono/body-limit`), h3 (`bodyLimit`/`assertBodySize`). Rely on your framework's body parser middleware for size limits (see wiki/best-practices.md). The raw stream path in `readBody` does not impose a built-in limit — use framework middleware or a custom body limit handler for defense-in-depth. **Enforce any custom cap while streaming, never after buffering**: `examples/spa/body-limit.ts` measures each chunk as it arrives, retains nothing past the cap, then drains-and-discards so the `413` is deliverable (closing a socket with unread request data makes Node emit `RST`) with a drain ceiling so the discard can't become an unbounded slowloris. Buffering first and measuring after — the obvious `readBody`-then-check shape — provides no memory-exhaustion protection at all
|
|
150
|
+
- **A malformed request is a `4xx`, never a `500`, and never a silent `200`**: a declared-JSON body that does not parse answers `400`, as does a GET `?args=` that is malformed or not an array. Three helpers in `src/server-helpers.ts` implement this once for all five adapters — `httpError(status, message)` tags an error for the dispatch, `isClientHttpError(err)` decides the class, and `clientErrorMessage(status)` picks the body from a fixed table. Read **both** `status` and `statusCode`: h3's `HTTPError` and `httpError` use the former, the `http-errors` objects Express's `body-parser` throws and Koa's `ctx.throw` use the latter. `readBody` must only `JSON.parse` when the content type actually declared JSON — the lenient sniff for undeclared bodies is deliberate (curl and the nojs fallback send JSON with no `Content-Type`) and an earlier version had all three branches fall through to one `JSON.parse`, so a text body threw and was "recovered" as `text/plain`, which made malformed JSON indistinguishable from a text body and answered `200`
|
|
151
|
+
- **Hono's `c.env` is optional**: only `@hono/node-server` populates it. Workers, Bun, Deno, serverless adapters and `app.fetch()` all leave it `undefined`, so every `c.env` read needs `?.` — `c.env.incoming?.` is not enough, since that guards a null `incoming`, not an absent `c.env`. `tests/hono.test.ts` drives a real `Hono` app through `app.fetch()` for this reason
|
|
136
152
|
- **Content-type strictness is one-directional**: JSON- and text-declared functions are strict (a form body is rejected with `415`); form-declared functions accept either form encoding, which is what makes the nojs `<form>` fallback work. The leniency does not run the other way
|
|
137
153
|
- **Generic 404 responses**: Error messages never echo the requested function name (no message-based function enumeration). Note the status code still distinguishes unknown (`404`/`400`) from known functions (`405`/`415`/`403`); function names ship in the client bundle so they are not secret — see `wiki/security.md`
|
|
138
154
|
- **Client error contract is shared, not fail-open**: `handleResponse` (used by generated stubs) and `unwrapEnvelope` (exported for native clients) agree — a top-level `error` key throws, `{ data: { error } }` resolves normally so validation-as-data keeps working. `unwrapEnvelope` is status-code agnostic, so callers must keep the `res.ok` check. `RPCError` is server-side only (`@thednp/rpc/server`); it is not a client export and its `code`/`data` are stripped in production regardless
|
|
@@ -140,7 +156,8 @@ Run `pnpm build` before `pnpm test` — `tests/adapter-exports.test.ts` reads th
|
|
|
140
156
|
- **Adapter type exports are uniform**: every adapter re-exports `<Fw>App` / `<Fw>Request` / `<Fw>Response` / `<Fw>Next` / `<Fw>MiddlewareFn` / `<Fw>MiddlewareOptions` / `<Fw>MiddlewareHooks`, plus the shared `RequestDetails` / `ResponseDetails` (defined once in `src/adapter-types.ts`, re-exported by all five), so a wrapper can annotate without depending on the framework. Additive only — legacy names (`Express`, `Fastify`, `Hono`, `Koa`, `H3Event`, `HonoContext`, `KoaContext`) still resolve. `tests/adapter-exports.test.ts` parses the emitted `dist/<adapter>/<adapter>.d.mts` to guard this, because type-only exports are erased from the `.mjs` and invisible to a runtime check
|
|
141
157
|
- **Auth is middleware's responsibility**: Authentication should be handled by middleware registered before `createRPCMiddleware()`. The middleware chain naturally composes — no built-in auth hook is needed.
|
|
142
158
|
- **No client-side secrets or stack traces**: Error responses always return `"Internal Server Error"` regardless of the underlying error; `console.error(String(err))` is server-side only for debugging and does not surface internals to the client
|
|
143
|
-
- **
|
|
159
|
+
- **There is no `adapter` config option, by design.** The adapter is the subpath you import. A runtime value could only disagree with the subpath actually mounted, and nothing read it — it was inert for its whole life. The union survives only as the exported `AdapterName` type, which keys `FrameworkHooks[A]["handler"]`; each adapter hardcodes its own literal into `MiddlewareOptions<"…">`. Do not reintroduce a config field that selects it. The generated client stubs are plain `fetch` calls, so `getClientModules` needs only the prefix
|
|
160
|
+
- **Prefix parity across adapters**: enforced by `resolveRPCPrefix()`, not by convention — see *Fixed in 0.3.7*. A mismatch would be fail-closed (404, never a cross-prefix dispatch) but still a bug
|
|
144
161
|
|
|
145
162
|
## Threat Model
|
|
146
163
|
|
|
@@ -150,7 +167,7 @@ The framework's security boundary is the **RPC prefix-gated HTTP endpoint**. Inp
|
|
|
150
167
|
| -----------------------| -------------------------------| ---------------------| --------------------------------------------------------------------------|
|
|
151
168
|
| `rpcPrefix` (config) | `rpc.config.ts` / dev options | Developer-trusted | Escaped before regex; validated before code gen |
|
|
152
169
|
| Function export name | `src/api/server.ts` exports | Developer-trusted | Validated against identifier regex before client codegen |
|
|
153
|
-
| HTTP request URL | Untrusted client | Boundary-filtered | Prefix regex (escaped, anchored, hoisted); non-throwing `safeURL()`
|
|
170
|
+
| HTTP request URL | Untrusted client | Boundary-filtered | Prefix regex (escaped, anchored, hoisted); non-throwing URL normalization (`safeURL()` on fastify/hono/koa, via `getRequestDetails` on express, pre-parsed `event.url` on h3) |
|
|
154
171
|
| HTTP request headers (`Origin`, `Sec-Fetch-Site`) | Untrusted client | Boundary-filtered | Exact-match allowlist; `Origin` short-circuits ahead of `Sec-Fetch-Site`; fails closed when only the coarse signal survives; empty values treated as absent |
|
|
155
172
|
| HTTP request body | Untrusted client | Capped by framework | Framework body parsers cap JSON and raw bodies |
|
|
156
173
|
|
|
@@ -180,11 +197,12 @@ The framework's security boundary is the **RPC prefix-gated HTTP endpoint**. Inp
|
|
|
180
197
|
- `wiki/configuration.md` — Configuration reference (`rpc.config.ts`, `vite.config.ts`, options)
|
|
181
198
|
- `wiki/server-functions.md` — `createServerFunction` API, methods, validation, **request context (`getRequestContext`/`provideRequestContext`)** for per-request data access across async call stacks
|
|
182
199
|
- `wiki/multi-prefix-guide.md` — Parallel RPC instances: versioned/public/admin API layouts, per-prefix middleware, canary deployments, origin validation per instance
|
|
183
|
-
- `wiki/middleware.md` — universal adapter-agnostic middleware via the request context (`locals` bridge, `getRequestMeta`, `sendResponse`, `functionName`)
|
|
200
|
+
- `wiki/middleware.md` — universal adapter-agnostic middleware via the request context (`locals` bridge, `getRequestMeta`, `sendResponse`, `functionName`), plus **handler wrappers** — the portable way to populate `event.locals` that works on all five adapters (including Fastify, which has no per-request store to bridge). Prefer the wrapper pattern over per-adapter reads when documenting middleware
|
|
184
201
|
- `wiki/nojs-fallback.md` — native (no-JS) `<form>` fallback / progressive enhancement pattern
|
|
185
202
|
- `wiki/client-usage.md` — Client-side usage, type safety, react-query integration, native clients via `unwrapEnvelope`
|
|
186
203
|
- `wiki/wire-protocol.md` — HTTP contract, request/response bodies, curl debugging
|
|
187
204
|
- `wiki/adapters.md` — Framework adapters (Express, Fastify, Hono, Koa, h3) and the re-exported framework type contract
|
|
188
205
|
- `wiki/security.md` — Security hardening
|
|
206
|
+
- `wiki/comparison.md` — How the cross-origin/CSRF boundary compares to Next.js Server Actions, TanStack Start, SvelteKit, tRPC, and (in a section) Vike/Telefunc. **Read this before writing any security copy.** The framing is *fail-open by default, strictest-once-configured, multi-origin without a proxy* — measured, not asserted: with `origin` set, rpc rejects an untrusted `Origin` even when `Sec-Fetch-Site: same-origin` claims otherwise, which TanStack's tier order waves through. Do **not** soften this into "as secure as" or "more secure than" — the page's own numbers (3 stricter, 2 more lenient, both deliberate) are the honest shape, and a reader who knows the tools will check. The `Where the trade costs you` section keeps rpc's real sharp edges (case-sensitive origin matching, literal origins only, no automatic input validation, deliberate `Referer` omission)
|
|
189
207
|
- `wiki/best-practices.md` — Production patterns (auth, rate limiting, body limits, CSRF)
|
|
190
208
|
- `wiki/index.md` — Documentation index / table of contents
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,121 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.7] - 2026-09-27
|
|
4
|
+
|
|
5
|
+
A correctness pass over the transport, found by auditing the codebase against its
|
|
6
|
+
own documentation. Two of the three headline bugs were found *by* that audit
|
|
7
|
+
being unable to corroborate the docs — they were documented, tested-adjacent, and
|
|
8
|
+
wrong.
|
|
9
|
+
|
|
10
|
+
**What was broken**
|
|
11
|
+
|
|
12
|
+
- **Hono's RPC returned `500` for every request on any runtime that is not
|
|
13
|
+
`@hono/node-server`** — Workers, Bun, Deno, standalone serverless, and Hono's
|
|
14
|
+
own `app.fetch()`. Three unguarded `c.env` reads, and a disconnect hook whose
|
|
15
|
+
comment said "may be absent ... so guard the close hook" followed by
|
|
16
|
+
`c.env.incoming?.`, which guards a null `incoming` rather than an absent
|
|
17
|
+
`c.env`. The fixtures always set `env`, so nothing caught it.
|
|
18
|
+
- **A malformed declared-JSON body was answered `200` with the raw string handed
|
|
19
|
+
to the handler** on Express, Koa and Fastify, and `500` on Hono and h3. The
|
|
20
|
+
cause was a `readBody` ternary with no `isJSON` branch: text bodies also went
|
|
21
|
+
through `JSON.parse`, so a malformed JSON body and a legitimate text body threw
|
|
22
|
+
the same exception and were indistinguishable. The `200` failed *open*.
|
|
23
|
+
- **The documented global-prefix and serverless flows returned `404` on every
|
|
24
|
+
adapter**, and `loadRPCConfig` skipped publishing the prefix on its most
|
|
25
|
+
common code path.
|
|
26
|
+
- **Only the first scan in a process ran**, so a second RPC instance on its own
|
|
27
|
+
prefix could never populate its map.
|
|
28
|
+
- **A CI gap let the export-surface test pass while the type it guards was
|
|
29
|
+
deleted** — it is a build-output test, and the build step was commented out.
|
|
30
|
+
|
|
31
|
+
**What changed underneath**: a malformed request is now a `4xx` and never a
|
|
32
|
+
silent `200` or a `500`, via one shared rule used by all five adapters; the
|
|
33
|
+
prefix resolves through a single `resolveRPCPrefix()`; and two config options
|
|
34
|
+
that never did anything (`adapter`, and `rpcPrefix: false`) are gone, with the
|
|
35
|
+
one type that carries real meaning extracted as `AdapterName`.
|
|
36
|
+
|
|
37
|
+
This release **does** change the emitted bundles — `src/index.ts`,
|
|
38
|
+
`src/scanForServerFiles.ts`, `src/server-helpers.ts`, `src/getClientModules.ts`,
|
|
39
|
+
`src/constants.ts`, `src/options.ts`, `src/types.d.ts` and all five
|
|
40
|
+
`src/*/createMiddleware.ts` + `src/*/helpers.ts` files — so `dist/` is rebuilt
|
|
41
|
+
and must be committed with the source. **Breaking**: express/koa/fastify now
|
|
42
|
+
answer `400` where they answered `200` for a malformed body, and a config
|
|
43
|
+
carrying `adapter:` now fails typecheck rather than being ignored.
|
|
44
|
+
|
|
45
|
+
### ⚠️ Behaviour change — prefix resolution
|
|
46
|
+
|
|
47
|
+
- **The documented global-prefix and serverless flows returned 404 on all five adapters.** `createRPCMiddleware` merged `{ rpcPrefix: defaultRPCOptions.rpcPrefix }` into its options *before* resolving the prefix, so `rpcPrefix` was always the truthy `"__rpc"` and the trailing `rpcPrefix || getGlobalPrefix() || defaultPrefix` was unreachable. `createServerFunction` *does* honour the global prefix, so the two halves disagreed — with `setGlobalPrefix("@demo")`, a middleware built without an explicit prefix answered `next()` for `/@demo/greet` (falling through to the app) and `"Function not found"` for `/__rpc/greet`. `attachRPC` escaped this only because it threads the loaded config through explicitly, which is why every example passed.
|
|
48
|
+
The default injection is gone, and all five adapters now resolve through a new `resolveRPCPrefix()` (`src/server-helpers.ts`) used in *both* the outer `createMiddleware` gate and the `createRPCMiddleware` dispatch, so parity is structural rather than a convention. **The change only affects states that were already returning 404:** with no global prefix set — every example and every other test — the resolved prefix is still `"__rpc"`, and an explicitly-passed prefix still wins. The boundary regex is built from the *resolved* prefix so gating is never dropped.
|
|
49
|
+
- **`loadRPCConfig` now publishes the global prefix on every return path.** It returned from inside its config-file search loop without calling `setGlobalPrefix`, while the explicit-`configFile` and no-config paths both did — so the common case (an `rpc.config.ts` exists) was the one that skipped it. Masked in the plugin path because `scanForServerFiles` re-resolves the prefix itself; exposed in the `attachRPC` / direct-import path.
|
|
50
|
+
|
|
51
|
+
Both bugs shipped at 100% coverage because every fixture in `tests/fixtures/*.ts` called `setGlobalPrefix(undefined)`, so the state the feature is *about* was never exercised. `tests/express.test.ts` now has a `global-prefix dispatch` block (6 tests) that sets a real prefix, and `tests/plugin.test.ts` has a file-level `afterEach` resetting it.
|
|
52
|
+
|
|
53
|
+
One related thing was investigated and deliberately left alone: a config file that throws still resets the `RPCConfig` cache to the defaults, because the "fall back to defaults" contract is asserted by tests. (The `rpcPrefix: false` phantom found by the same audit is handled below.)
|
|
54
|
+
|
|
55
|
+
### ⚠️ Breaking — the inert `adapter` config option is gone
|
|
56
|
+
|
|
57
|
+
- `RpcPluginOptions.adapter` (`'express' | 'hono' | 'h3' | 'fastify' | 'koa'`) was **never read by anything**. Exactly two places in the whole source touched its value: `src/index.ts` destructured it out and discarded it (`const { adapter: _adapter, ...rest } = options`), and passed it into `getClientModules`, which spread it into a helper's options and never looked at it. The adapter is determined entirely by which subpath you import — `@thednp/rpc/express`, `/hono`, `/koa`, `/h3`, `/fastify` — which is exactly how the type system already modelled it, each adapter hardcoding its own literal into `MiddlewareOptions<"express">` etc.
|
|
58
|
+
A runtime value could therefore only ever *disagree* with the subpath actually mounted, and nothing read it to notice. Setting `adapter: "hono"` while mounting Express silently did nothing. The field is removed; the **union survives as the exported `AdapterName` type**, which is what keys `FrameworkHooks[A]["handler"]` at `src/types.d.ts:383` and is genuinely load-bearing.
|
|
59
|
+
Removed alongside it: `adapter` from `RpcPluginOptionsInternal` (which is why `getClientModules` only ever needed `rpcPrefix`), from `defaultRPCOptions`, and the five `const { adapter: _adapter, ...options } = await loadRPCConfig()` workarounds in `src/*/helpers.ts` and the example servers — a phantom field that every consumer had to strip out by hand before the middleware would accept the config. `configuration.md` no longer lists it.
|
|
60
|
+
Compile-time only: a config file still carrying `adapter:` now fails typecheck rather than being ignored.
|
|
61
|
+
|
|
62
|
+
- **Also removed (types only): `rpcPrefix: false`.**
|
|
63
|
+
|
|
64
|
+
`MiddlewareOptions.rpcPrefix` was declared `string | false`, but `false` was **documented nowhere, tested nowhere, and handled nowhere**. Measured: it was byte-identical to omitting the option — `createRPCMiddleware({ rpcPrefix: false })` dispatched exactly like `createRPCMiddleware({})` for both `/__rpc/greet` (200) and `/anything/greet` (fall-through). A silent no-op that reads like "disable prefix gating" is worse than an absent option, so the type now says `string`. `resolveRPCPrefix()`'s signature was narrowed to match.
|
|
65
|
+
This is a **compile-time-only** change: JavaScript callers passing `false` are unaffected at runtime, since the `||` chain still falls through to the global prefix and then the default. It only surfaces for TypeScript users who wrote `rpcPrefix: false` — and for them the error is the correction, because they had been running with full prefix gating while believing the gate was disabled.
|
|
66
|
+
|
|
67
|
+
### ⚠️ Behaviour change — malformed request bodies are now `400`, not `200` or `500`
|
|
68
|
+
|
|
69
|
+
- **A declared-JSON body that does not parse used to be answered `200` with the raw string handed to the handler** on Express, Koa and Fastify, and `500` on Hono and h3. Both outcomes were wrong, and the first was worse: it failed *open*. The cause was in `readBody`, where all three content-type branches fell through to a single `JSON.parse(body)` — so a `text/plain` body also went through `JSON.parse`, threw, and was "recovered" by a catch that resolved it as `text/plain`. A malformed JSON body and a legitimate text body were therefore indistinguishable, and the recovery path silently answered `200`.
|
|
70
|
+
Only a *declared* JSON body is now parsed strictly; everything else keeps the lenient sniff, which is deliberate and load-bearing — a request with no `Content-Type` at all (curl, and the nojs form fallback) that happens to carry JSON must still arrive parsed. On failure the error is tagged with a `400` (`httpError`, new) and every adapter answers `{ error: "Bad Request" }`.
|
|
71
|
+
This matches every supported host: Express `body-parser` (`entity.parse.failed` → 400), Fastify (`FST_ERR_CTP_INVALID_JSON_BODY` → 400), koa-bodyparser (400), and h3's own `readBody` (`HTTPError` 400, which rpc was discarding by parsing the body itself). Hono has no opinion here — its maintainers declined to own the case in honojs/hono#578 — so as the thing doing the parsing, rpc answers 400 for it.
|
|
72
|
+
- **Only the first scan in a process ran.** `scanForServerFiles` memoized with a single module-level boolean, so the *first* scan suppressed every subsequent one. The scan target is now keyed on the resolved `(scanRoot, serverFiles, rpcPrefix)` triple — everything that determines which files are read and where prefix-less functions register — so a repeat of the same scan is still skipped, but a second RPC instance on its own prefix now scans. Previously that instance asked for a lazy scan, got an early return, and answered `404` for every function it owned. Only reachable with a function declaring no `rpcPrefix` under a second middleware, i.e. a configuration that was already broken; it is now correct. Functions that declare their own prefix were never affected — one scan registers them all.
|
|
73
|
+
- **A malformed GET `?args=` is now `400` on all five adapters.** It previously threw out of the dispatch's `try` and was reported as a `500` — a cheap way for a client to generate server errors. The existing non-array case already answered `400`; only the malformed-syntax half was wrong.
|
|
74
|
+
- Client-error statuses are surfaced through one shared rule (`isClientHttpError` / `clientErrorStatus` / `clientErrorMessage` in `src/server-helpers.ts`) rather than per-adapter logic, so a host framework's signal and an rpc-raised one are handled identically. It reads both `status` (h3's `HTTPError`, rpc's `httpError`) and `statusCode` (the `http-errors` objects Express throws, Koa's `ctx.throw`), and the response body comes from a fixed table so no host-framework message is echoed back. `5xx` and unrecognised errors still go through `formatError`.
|
|
75
|
+
|
|
76
|
+
### Fixed
|
|
77
|
+
|
|
78
|
+
- **⚠️ Hono's RPC was broken on every runtime that is not `@hono/node-server`.** `readBody`, the client-disconnect hook and `viteMiddleware` each read `c.env` unguarded, so on Cloudflare Workers, Bun, Deno, standalone serverless adapters — and Hono's own `app.fetch()` — `c.env` is `undefined` and *every* request threw a `TypeError` and came back as a `500`, including a well-formed one. The disconnect hook even carried a comment saying the runtime adapter "may be absent ... so guard the close hook" and then wrote `c.env.incoming?.`, which guards a null `incoming` but not an undefined `c.env`. The existing fixtures always set `env`, so nothing caught it; four tests now drive a real `Hono` app through `app.fetch()` where `c.env` is genuinely absent.
|
|
79
|
+
|
|
80
|
+
- **h3 reported an oversized body as a `500` instead of a `413`.** h3's `bodyLimit`/`assertBodySize` enforces its cap in two places: an honest `Content-Length` over the limit is rejected up front, before rpc is reached, but a **chunked** body (no length to check) trips *while the stream is read* — which is inside the RPC dispatch's `try` block. The adapter's generic catch flattened h3's `413` into a `500`, so h3 was the only adapter that reported an oversize body as a server fault; the other four get their `413` from the host body parser before rpc runs. The h3 adapter now forwards h3's own `4xx` out of the dispatch `try` (`413` becomes `{ error: "Payload Too Large" }`), while `5xx` and unrecognised errors still go through `formatError`. Covered by a real-`H3` test that streams a body with no `Content-Length` and asserts `413`, verified to fail with the fix removed.
|
|
81
|
+
|
|
82
|
+
- **`loadRPCConfig({ silent: true })` silently discarded the config** (`src/index.ts`). The documented single-argument form passes the options bag where the signature expects a config path, so `resolve()` threw `ERR_INVALID_ARG_TYPE`, the `catch` swallowed it, and the call returned `defaultRPCOptions` instead of the project's real config — with only a `Failed to load RPC config` warning as evidence. This was the form shown in `wiki/configuration.md`. An object first argument is now normalised to the options bag, and both the two-argument and one-argument forms work.
|
|
83
|
+
- **Vite 10+ silently used the wrong transform pipeline** (`src/index.ts`). `isOxc` was derived from `Number(viteVersion[0]) >= 8`, which reads the first *character*: `"10.4.2"` yields `1`, fails the `>= 8` test, and routes every future double-digit Vite through `transformWithEsbuild` instead of `transformWithOxc`. Now parsed as an integer major.
|
|
84
|
+
- **An export-less module silently abandoned the rest of the scan** (`src/scanForServerFiles.ts`). A module with no exports warned and then `return`ed out of the whole function rather than `continue`ing to the next file, so in `serverFiles: "glob"` mode every file after it was dropped without a word. Any glob project with a helper or barrel file that exports nothing was affected, and the missing functions surfaced only as `404`s at request time.
|
|
85
|
+
- **h3's outer gate resolved its prefix differently from the other four adapters** (`src/h3/createMiddleware.ts`). It used the three-way `rpcPrefix || getGlobalPrefix() || defaultPrefix` where express/fastify/hono/koa use `rpcPrefix ?? defaultPrefix`, so the same setup could pass h3's gate and be rejected by the others. Normalised to match.
|
|
86
|
+
- **Scan fixtures could not compile** (`tests/fixtures/scan-api/src/api/users.server.ts`, `upload.server.mts`): both called `createServerFunction(handler)` with the required `name` argument missing, so neither file was valid against the real signature. This never surfaced because `scanForServerFiles` discovers server modules by *filename* and never imports them — a fixture that would fail to compile sat in the tree indefinitely. Both now match the real API.
|
|
87
|
+
|
|
88
|
+
### Tests
|
|
89
|
+
|
|
90
|
+
- **Regression tests for each runtime fix**, each verified to fail with the bug reintroduced. The Vite-version test asserts *which* transformer was called — the existing vite 7/8 tests only asserted the output was non-empty, and both mocked transformers return the input, which is why `Number(viteVersion[0])` survived. The scan test uses a fixture with two export-less modules and one populated, so it fails under any directory read order.
|
|
91
|
+
- New `tests/fixtures/scan-mixed/` (`a-empty.server.ts`, `b-loaded.server.ts`, `c-empty.server.ts`); `tests/fixtures/vite-mock.ts` gains `mockPlugin10Context` for the double-digit major version.
|
|
92
|
+
- **The Fastify reply mock now carries `redirect`** (`tests/fixtures/fastify.ts`): the v5-signature helper in `src/fastify/helpers.ts` calls `reply.redirect(location, status)`, so all four redirect tests had to attach the method to the double with a cast. The mock ships it instead. All four still assert on the call, so none became vacuous.
|
|
93
|
+
- Removed genuinely dead code from the suites: an unused `sendResponse` import (express, hono), an unused `app` binding (express), three unused `result` bindings (hono), an unused trailing `cb` parameter (h3), and a commented-out `origWarn` (scan).
|
|
94
|
+
- **17 pre-existing type errors in the test suite fixed.** None were reachable before — see the `check:tests` entry below.
|
|
95
|
+
- The `F1` finding was originally tracked as an `it.todo` in `tests/express.test.ts`; once fixed it became a six-test `global-prefix dispatch` block that sets a real global prefix. Three of the six fail with the bug reintroduced; the other three guard behaviour that must *not* change (default prefix with no global prefix set, explicit prefix winning over the global one, and boundary safety on a resolved prefix). `tests/plugin.test.ts` gained a file-level `afterEach` resetting the global prefix, since `loadRPCConfig` now publishes it and would otherwise leak into every later `createServerFunction` call.
|
|
96
|
+
|
|
97
|
+
### Chores
|
|
98
|
+
|
|
99
|
+
- **CI now builds before testing** (`.github/workflows/ci.yml`). `tests/adapter-exports.test.ts` is a build-output test that parses the emitted `dist/<adapter>/*.d.mts`, but the build step was commented out. Because `dist/` is committed the file exists on a fresh checkout, so the suite silently validated the *last committed bundle* rather than current source — demonstrated by deleting `ExpressApp` from `src/express/types.d.ts`, which passed 56/56 with no build and fails with one. That test exists to stop a type name "quietly disappearing in a refactor", and it could not do so.
|
|
100
|
+
- **CI checks formatting instead of rewriting it** (`format:check`, new script). The workflow ran `pnpm format`, which rewrites files in the runner with no `git diff --exit-code` afterward, so format drift never failed a build — it just got silently "fixed" somewhere nobody would notice. `deno fmt --check` exits non-zero with `Found N not formatted files`.
|
|
101
|
+
- **The test suite is now typechecked** (`tsconfig.tests.json`, `check:tests`): `tsc` only ever covered `src`, so none of the test files were typechecked despite being a large part of the tree. `lint` now runs a third step after `check:ts`.
|
|
102
|
+
It is a **separate tsconfig on purpose**: `tsdown` emits declarations from `tsconfig.json`, and pulling `tests/fixtures` into that program raises TS2883 (*"inferred type cannot be named without a reference to 'Procedure' from `…/vitest/dist/…`"*) and fails the build. Under `noEmit` those errors do not occur, so tests are checked in their own program rather than the emit one.
|
|
103
|
+
- Removed dead and duplicated logic in the plugin entry: the unreachable third clause of the `transform` guard (`code.includes(...)` was already tested, and `typeof process === "undefined"` is always false inside a Node-hosted plugin), a per-call `await import("vite")` replaced by a static namespace import, a `(!initialCfg && !devServer) || !initialCfg` no-op, and the two duplicated config-merge blocks folded into one `mergeLoaded` helper.
|
|
104
|
+
|
|
105
|
+
### Docs
|
|
106
|
+
|
|
107
|
+
- **`wiki/comparison.md` re-verified against vendor source and documentation**, and it was wrong in a security-relevant direction. Next.js does **not** abort a request with no `Origin` — an absent header is let through with a dev warning, on the stated reasoning that a handcrafted request cannot carry unwilling victim credentials. That is the *same* fail-open posture `@thednp/rpc` takes for its curl/native hole, and the page had claimed rpc was uniquely permissive about it. TanStack was also overstated: it rejects a request carrying *no* signal at all, not specifically one lacking `Origin` (a no-`Origin` request with a same-origin `Referer` is allowed). Corrected, with a note recording what the earlier draft got wrong.
|
|
108
|
+
Also updated: the `GHSA-mq59-m269-xvcx` entry with its CVE alias, severity, affected range (`16.0.1`–`16.1.6`) and fix version (`16.1.7`); SvelteKit's `trustedOrigins: ['*']`, which does **not** rescue a missing-`Origin` form POST, and the fact that PR #14795 was **closed unmerged** — remote functions are exempt from `trustedOrigins` by design, not awaiting a fix; a **removed** claim that `Referrer-Policy: no-referrer` causes SvelteKit CSRF false positives (SvelteKit reads only `Origin` and has no `Referer` fallback, so that mechanism does not exist); tRPC's POST `Content-Type` enforcement as a form-CSRF mitigation it does ship; `shield()` being dev-off by default; and verified versions for all five projects in the header and Sources.
|
|
109
|
+
|
|
110
|
+
### Docs
|
|
111
|
+
|
|
112
|
+
- **`AGENTS.md` — the URL-normalization note overclaimed.** It stated that *every* adapter parses the request URL through the shared `safeURL()` helper. In fact fastify/hono/koa call it directly, express reaches it through `getRequestDetails`, and **h3 does not call it at all** — it reads `event.url.pathname`, which h3 has already parsed, so the throw-on-malformed-target case is handled upstream by h3. There was never a security gap; the note claimed more than the code did. Corrected in both the security section and the threat-model table.
|
|
113
|
+
- **`AGENTS.md` — the deferred-findings section became a *Fixed in 0.3.7* section** once the two prefix bugs landed, so it now documents the guardrails instead of the open questions: `resolveRPCPrefix()` is the single resolution point and a local `a || b || c` in an adapter is a regression; the removed default injection is *why* the `|| getGlobalPrefix()` fallback was unreachable; the boundary regex must be built from the resolved prefix but still gated on an explicitly-supplied one, because building it unconditionally would start gating a bare `createMiddleware({ path, handler })`; and a throwing config file still resets the `RPCConfig` cache, deliberately. It also keeps the coverage lesson that let both bugs through at 100%.
|
|
114
|
+
- **`wiki/middleware.md` — the framework-agnostic alternative to the `locals` bridge.** The page documented per-adapter recipes for reaching pre-dispatch framework state, but on Fastify and Hono those are five separate code paths with no coverage, and Fastify has no per-request store to bridge at all. New *The Framework-Agnostic Alternative: Handler Wrappers* section documents the part of the contract that actually carries: `event.locals` is a mutable object rpc passes into the request context, so a handler wrapper can resolve per-request data and store it there — identically on all five adapters, with no framework imports and no new rpc API. Includes a signed-cookie session worked example, a "when to use which" split against the framework-middleware path, and the honest tradeoff (a wrapper runs per function call, so request-pipeline concerns still belong in real framework middleware). The Fastify/Hono note now points at it as the better default
|
|
115
|
+
- **New `wiki/comparison.md`** — the cross-origin / CSRF boundary compared against Next.js Server Actions, TanStack Start, SvelteKit, and tRPC, with a section covering Vike and Telefunc. Verified against vendor documentation on 2026-09-27 rather than written from memory, which changed several conclusions: TanStack's `createCsrfMiddleware` is auto-installed and **fails closed**; Next.js shipped `GHSA-mq59-m269-xvcx` for treating `origin: null` as missing (rpc rejects it by default); tRPC shipped `CVE-2025-68130` for prototype pollution via FormData keys (rpc's urlencoded path is verified immune). Measured against TanStack's documented algorithm, rpc is **stricter in three cases** — an untrusted `Origin` claiming `Sec-Fetch-Site: same-origin`, `Origin: null`, and lookalike hosts — because checking `Origin` first is what closes them, and more permissive in two, both deliberate (the allowlisted sibling, and the headerless curl case)
|
|
116
|
+
- Framed as *fail-open by default, strictest-once-configured, multi-origin without a proxy* — with `Where the trade costs you` keeping the real sharp edges (case-sensitive origin matching, literal origins only, no automatic input validation, deliberate `Referer` omission). Linked from all 13 other wiki pages, `wiki/index.md`, `AGENTS.md`, and `llms.txt`; the `AGENTS.md` entry warns future agents to read it before writing security copy
|
|
117
|
+
- Two broken wiki anchors fixed: `client-usage.md#native-http-clients--unwrapenvelopet` and the tier-order slug in `comparison.md`. A link/anchor pass validates all 47.
|
|
118
|
+
|
|
3
119
|
## [0.3.6] - 2026-09-27
|
|
4
120
|
|
|
5
121
|
Two independent changes: a **behaviour change** to origin validation, and an
|
package/README.md
CHANGED
|
@@ -172,7 +172,6 @@ Create `rpc.config.ts` at your project root:
|
|
|
172
172
|
import { defineConfig } from "@thednp/rpc/config";
|
|
173
173
|
|
|
174
174
|
export default defineConfig({
|
|
175
|
-
adapter: "express",
|
|
176
175
|
rpcPrefix: "__rpc",
|
|
177
176
|
});
|
|
178
177
|
```
|
|
@@ -262,7 +261,7 @@ pnpm test:watch # Run tests in watch mode with coverage
|
|
|
262
261
|
pnpm test:ui # Run tests with UI
|
|
263
262
|
```
|
|
264
263
|
|
|
265
|
-
Tests use **Vitest** with **Istanbul** coverage —
|
|
264
|
+
Tests use **Vitest** with **Istanbul** coverage — 11 test files covering the plugin, scanning, server/client helpers, request context, the adapter type-export surface, and all five adapters, at 100% coverage.
|
|
266
265
|
|
|
267
266
|
### Live Testing
|
|
268
267
|
|
|
@@ -353,6 +352,7 @@ The full threat model, including edge cases and configuration options for tighte
|
|
|
353
352
|
- [Client Usage](./wiki/client-usage.md) — Client-side usage
|
|
354
353
|
- [Wire Protocol](./wiki/wire-protocol.md) — The HTTP contract behind the generated clients (curl debugging)
|
|
355
354
|
- [Adapters](./wiki/adapters.md) — Framework adapters
|
|
355
|
+
- [Comparison](./wiki/comparison.md) — How the cross-origin/CSRF boundary compares to Next.js Server Actions, TanStack Start, SvelteKit, and tRPC
|
|
356
356
|
- [Best Practices](./wiki/best-practices.md) — Tips and best practices
|
|
357
357
|
- [Security](./wiki/security.md) — Security hardening
|
|
358
358
|
|
package/dist/config/config.d.mts
CHANGED
|
@@ -28,13 +28,6 @@ interface RpcPluginOptions {
|
|
|
28
28
|
* rpcPrefix: "api/rpc"
|
|
29
29
|
*/
|
|
30
30
|
rpcPrefix: "__rpc" | string;
|
|
31
|
-
/**
|
|
32
|
-
* Option to set an adapter for the middleware connection. The default is _express_,
|
|
33
|
-
* which is the most popular and battle tested server app. The _express_ adapter is
|
|
34
|
-
* also compatible with the vite's Connect development server.
|
|
35
|
-
* @default express
|
|
36
|
-
*/
|
|
37
|
-
adapter: "express" | "hono" | "h3" | "fastify" | "koa";
|
|
38
31
|
/**
|
|
39
32
|
* Root directory from which the plugin scans for server files.
|
|
40
33
|
* Defaults to `<root>/src/api`. Use this in monorepos where server files
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.d.mts","names":[],"sources":["../../src/types.d.ts","../../src/config.ts"],"mappings":";;;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"config.d.mts","names":[],"sources":["../../src/types.d.ts","../../src/config.ts"],"mappings":";;;;;;;;;;;;;;;;;;UAmQiB;;;;;;;;;;;EAWf;;;;;;;EAQA;;;;;;;EAQA;;;;;;;EAQA;;;;;;;;;;;qBCpRW,eACX,GAAG,QAAQ,sBACR"}
|
package/dist/config/config.mjs
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Baseline plugin options. `defineConfig` merges a user's partial config over
|
|
3
|
+
* these, and `loadRPCConfig` merges a loaded config file over them, so every
|
|
4
|
+
* option has a defined value even when a config file omits it.
|
|
5
|
+
*/
|
|
1
6
|
const defaultRPCOptions = {
|
|
2
7
|
rpcPrefix: "__rpc",
|
|
3
|
-
adapter: "express",
|
|
4
8
|
serverFiles: "exact",
|
|
5
9
|
scanRoot: void 0
|
|
6
10
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"config.mjs","names":[],"sources":["../../src/options.ts","../../src/config.ts"],"sourcesContent":["import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\nexport const defaultServerFnOptions: ServerFunctionOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n};\n\nexport const defaultPrefix = \"__rpc\";\n\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: defaultPrefix,\n
|
|
1
|
+
{"version":3,"file":"config.mjs","names":[],"sources":["../../src/options.ts","../../src/config.ts"],"sourcesContent":["import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\n/**\n * Defaults applied to a server function that declares no `method`,\n * `credentials`, or `contentType` of its own.\n */\nexport const defaultServerFnOptions: ServerFunctionOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n};\n\n/**\n * The built-in RPC endpoint prefix, used when neither an explicit prefix nor a\n * global one (`getGlobalPrefix`) is supplied. Kept for backward compatibility\n * with pre-multi-prefix setups, where every function lived under this one map.\n */\nexport const defaultPrefix = \"__rpc\";\n\n/**\n * Baseline plugin options. `defineConfig` merges a user's partial config over\n * these, and `loadRPCConfig` merges a loaded config file over them, so every\n * option has a defined value even when a config file omits it.\n */\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: defaultPrefix,\n serverFiles: \"exact\",\n scanRoot: undefined,\n};\n\n/**\n * Baseline middleware options. Note `rpcPrefix` is `undefined` rather than\n * `defaultPrefix` on purpose: leaving it unset lets `resolveRPCPrefix` fall\n * through to the global prefix, which is what makes a published global prefix\n * reach the middleware.\n */\nexport const defaultMiddlewareOptions: MiddlewareOptions = {\n rpcPrefix: undefined,\n path: undefined,\n origin: undefined,\n};\n","/**\n * Vite-free configuration helpers.\n *\n * This module intentionally has zero runtime dependencies (not even on `vite`),\n * so `rpc.config.ts` files that import it stay safe to load in serverless\n * bundles where Vite is not installed. Importing the main plugin entry\n * (`@thednp/rpc`) instead would drag Vite into every server-side consumer.\n */\nimport type { RpcPluginOptions } from \"./types.d.ts\";\nimport { defaultRPCOptions } from \"./options.ts\";\n\n/**\n * Type-safe helper to create an RPC configuration object.\n * Merges the provided partial config over the built-in defaults,\n * skipping explicitly `undefined` values.\n * @param uniConfig - System-wide RPC configuration overrides\n * @returns Complete RPC plugin options with defaults applied\n */\nexport const defineConfig: (\n c: Partial<RpcPluginOptions>,\n) => RpcPluginOptions = (uniConfig: Partial<RpcPluginOptions>) => {\n const merged: RpcPluginOptions & Record<string, unknown> = {\n ...defaultRPCOptions,\n };\n for (const [key, value] of Object.entries(uniConfig)) {\n // istanbul ignore else\n if (value !== undefined) {\n merged[key] = value;\n }\n }\n return merged;\n};\n"],"mappings":";;;;;AA4BA,MAAa,oBAAsC;CACjD,WAAW;CACX,aAAa;CACb,UAAU,KAAA;AACZ;;;;;;;;;;ACdA,MAAa,gBAEY,cAAyC;CAChE,MAAM,SAAqD,EACzD,GAAG,kBACL;CACA,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,SAAS,GAEjD,IAAI,UAAU,KAAA,GACZ,OAAO,OAAO;CAGlB,OAAO;AACT"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Connect, ViteDevServer } from "vite";
|
|
2
|
-
import { BodyResult, MiddlewareOptions
|
|
2
|
+
import { AdapterName, BodyResult, MiddlewareOptions } from "@thednp/rpc";
|
|
3
3
|
import { IncomingHttpHeaders, IncomingMessage, ServerResponse } from "node:http";
|
|
4
4
|
import { Express, Express as Express$1, Express as ExpressApp, NextFunction, NextFunction as ExpressNext, Request, Request as ExpressRequest, Response as ExpressResponse, Response as Response$1 } from "express";
|
|
5
5
|
import "hono";
|
|
@@ -79,7 +79,7 @@ type ExpressMiddlewareOptions = MiddlewareOptions<"express">;
|
|
|
79
79
|
* Express middleware factory: takes optional initial options and returns
|
|
80
80
|
* the Express/Connect-compatible handler.
|
|
81
81
|
*/
|
|
82
|
-
type ExpressMiddlewareFn = <A extends
|
|
82
|
+
type ExpressMiddlewareFn = <A extends AdapterName = "express">(initialOptions?: Partial<ExpressMiddlewareOptions>) => ExpressMiddlewareHooks["handler"];
|
|
83
83
|
/**
|
|
84
84
|
* Express/Connect middleware handler signature used by the RPC middleware.
|
|
85
85
|
*/
|
|
@@ -126,13 +126,6 @@ export declare function attachRPC(app: Express$1): Promise<void>;
|
|
|
126
126
|
* @param vite - Running Vite dev server
|
|
127
127
|
*/
|
|
128
128
|
export declare function attachVite(app: Express$1, vite: ViteDevServer): void;
|
|
129
|
-
/**
|
|
130
|
-
* Reads and parses the HTTP request body from an Express or Node IncomingMessage.
|
|
131
|
-
* If a body parser middleware (e.g. express.json()) already consumed the stream,
|
|
132
|
-
* uses the pre-parsed body from `req.body`.
|
|
133
|
-
* @param req - Express or Node.js IncomingMessage
|
|
134
|
-
* @returns A promise resolving to the parsed body with its content type
|
|
135
|
-
*/
|
|
136
129
|
export declare const readBody: (req: Request | IncomingMessage) => Promise<BodyResult>;
|
|
137
130
|
/**
|
|
138
131
|
* Type guard that checks whether a request is an Express Request (has `originalUrl`).
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"express.d.mts","names":["Response","Express","ExpressRequest","ExpressResponse"],"sources":["../../src/types.d.ts","../../src/adapter-types.ts","../../src/express/types.d.ts","../../src/express/createMiddleware.ts","../../src/express/helpers.ts"],"mappings":";;;;;;;;;;;;;;;;;
|
|
1
|
+
{"version":3,"file":"express.d.mts","names":["Response","Express","ExpressRequest","ExpressResponse"],"sources":["../../src/types.d.ts","../../src/adapter-types.ts","../../src/express/types.d.ts","../../src/express/createMiddleware.ts","../../src/express/helpers.ts"],"mappings":";;;;;;;;;;;;;;;;;KAsIY;;;;KAIA;GAAgB,cAAc,YAAY;;;;;KAI1C,aAAa,WAAW;;;;KAIxB,YAAY,gBAAgB,YAAY;;;;;;;;;;;KCtIxC;;EAEV;;EAEA,YAAY,cAAc;;EAE1B;;EAEA,gBAAgB;;EAEhB,eAAe,cAAc,QAAQ;;;;;;;KAQ3B;;EAEV;;EAEA;;EAEA,cAAc;;EAEd,SAAS;;EAET;;;;;;;KCxBU,2BAA2B;;;;;KAM3B,uBACV,UAAU,yBAEV,iBAAiB,QAAQ,8BACtB;;;;UAKY;;;;;;;EAOf,UACE,KAAK,kBAAkB,SACvB,KAAK,iBAAiBA,YACtB,MAAM,QAAQ,eAAe,iBAC1B;;;;;;;;;;;qBCYM,kBAAkB;;;;;;;;;;qBAsFlB,qBAAqB;;;;;;;;wBCzHZ,UAAU,KAAKC,YAAO;;;;;;wBAc5B,WAAW,KAAKA,WAAS,MAAM;qBA0BlC,WAAQ,KACdC,UAAiB,oBACrB,QAAQ;;;;;;qBAuGE,mBAAgB,KACtB,kBAAkBA,YACtB,OAAOA;;;;;;qBASG,oBAAiB,KACvB,iBAAiBC,eACrB,OAAOA;;;;;;;;;;;;qBAeG,WAAQ,KACd,iBAAiBA,YAAe,kBACrB;;;;;;;qBAkBL,mBAAgB,KACtB,kBAAkBD,YACtB,OAAOA;;;;;;;qBAUG,oBAAiB,SACnBA,UAAiB,oBACzB;;;;;;;qBAqBU,qBAAkB,UACnBC,aAAkB,mBAC3B"}
|
package/dist/express/express.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { escapeRegExp, formatError,
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import { clientErrorMessage, clientErrorStatus, escapeRegExp, formatError, hasContentTypeMismatch, isClientHttpError, isOriginRequestAllowed, provideRequestContext, resolveRPCPrefix, scanForServerFiles } from "@thednp/rpc/server";
|
|
2
|
+
//#region src/options.ts
|
|
3
|
+
/**
|
|
4
|
+
* Baseline middleware options. Note `rpcPrefix` is `undefined` rather than
|
|
5
|
+
* `defaultPrefix` on purpose: leaving it unset lets `resolveRPCPrefix` fall
|
|
6
|
+
* through to the global prefix, which is what makes a published global prefix
|
|
7
|
+
* reach the middleware.
|
|
8
|
+
*/
|
|
8
9
|
const defaultMiddlewareOptions = {
|
|
9
10
|
rpcPrefix: void 0,
|
|
10
11
|
path: void 0,
|
|
@@ -38,16 +39,40 @@ const getFunctionsForPrefix = (prefix) => {
|
|
|
38
39
|
};
|
|
39
40
|
//#endregion
|
|
40
41
|
//#region src/constants.ts
|
|
42
|
+
/** Body of a 404. Deliberately does not name the requested function. */
|
|
41
43
|
const FUNCTION_NOT_FOUND = "Function not found";
|
|
44
|
+
/** Body of a 405, returned when the HTTP method does not match the function's declared method. */
|
|
42
45
|
const METHOD_NOT_ALLOWED = "Method Not Allowed";
|
|
46
|
+
/** Body of a 403, returned when the optional origin allowlist rejects the request. */
|
|
43
47
|
const REQUEST_FORBIDDEN = "Forbidden";
|
|
48
|
+
/** Body of a 415, returned when the request's `Content-Type` does not satisfy the function's declared `contentType`. */
|
|
44
49
|
const UNSUPPORTED_MEDIA_TYPE = "Unsupported Media Type";
|
|
50
|
+
/** Body of a 400, returned when a GET `?args=` value parses but is not an array. */
|
|
45
51
|
const BAD_REQUEST = "Bad Request";
|
|
52
|
+
/** Abort reason used when the client disconnects mid-dispatch. */
|
|
46
53
|
const CLIENT_DISCONNECTED = "client disconnected";
|
|
47
54
|
/** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */
|
|
48
55
|
const MIDDLEWARE_NAME_USED = (name) => `The middleware name "${name}" is already used.`;
|
|
49
56
|
//#endregion
|
|
50
57
|
//#region src/server-helpers.ts
|
|
58
|
+
/**
|
|
59
|
+
* Tags an error with an HTTP status for the dispatch to surface.
|
|
60
|
+
*
|
|
61
|
+
* Used where a malformed *request* is the fault — a body that does not parse
|
|
62
|
+
* under a declared JSON `Content-Type`, a GET `?args=` value that is not valid
|
|
63
|
+
* JSON. Every host framework rpc supports answers `400` for these (Express
|
|
64
|
+
* `entity.parse.failed`, Fastify `FST_ERR_CTP_INVALID_JSON_BODY`, koa-bodyparser,
|
|
65
|
+
* and h3's own `readBody`), and treating one as a server fault both misreports
|
|
66
|
+
* the fault and turns a trivial client mistake into a log entry.
|
|
67
|
+
* @param status - The HTTP status to answer with
|
|
68
|
+
* @param message - Internal diagnostic message; never sent to the client
|
|
69
|
+
* @returns An `Error` carrying `status`
|
|
70
|
+
*/
|
|
71
|
+
const httpError = (status, message) => {
|
|
72
|
+
const err = new Error(message);
|
|
73
|
+
err.status = status;
|
|
74
|
+
return err;
|
|
75
|
+
};
|
|
51
76
|
const SAFE_URL_BASE = "http://localhost";
|
|
52
77
|
/**
|
|
53
78
|
* Parses a raw request URL against a fixed base without ever throwing.
|
|
@@ -78,7 +103,7 @@ const safeURL = (rawUrl, base = SAFE_URL_BASE) => {
|
|
|
78
103
|
*/
|
|
79
104
|
async function attachRPC(app) {
|
|
80
105
|
const { loadRPCConfig } = await import("@thednp/rpc");
|
|
81
|
-
const
|
|
106
|
+
const options = await loadRPCConfig();
|
|
82
107
|
app.use(createRPCMiddleware(options));
|
|
83
108
|
}
|
|
84
109
|
/**
|
|
@@ -96,6 +121,20 @@ function attachVite(app, vite) {
|
|
|
96
121
|
* @param req - Express or Node.js IncomingMessage
|
|
97
122
|
* @returns A promise resolving to the parsed body with its content type
|
|
98
123
|
*/
|
|
124
|
+
/**
|
|
125
|
+
* Parses a body leniently: JSON when it parses, otherwise the raw string.
|
|
126
|
+
* Used for bodies that did not declare JSON — notably a request with no
|
|
127
|
+
* `Content-Type` header, which must still arrive parsed if it carries JSON.
|
|
128
|
+
* @param body - The raw body text
|
|
129
|
+
* @returns The parsed JSON value, or the original string
|
|
130
|
+
*/
|
|
131
|
+
const parseJsonOrRawText = (body) => {
|
|
132
|
+
try {
|
|
133
|
+
return JSON.parse(body);
|
|
134
|
+
} catch {
|
|
135
|
+
return body;
|
|
136
|
+
}
|
|
137
|
+
};
|
|
99
138
|
const readBody = (req) => {
|
|
100
139
|
return new Promise((resolve, reject) => {
|
|
101
140
|
if (hasPreParsedBody(req) && req.body !== void 0) {
|
|
@@ -126,16 +165,13 @@ const readBody = (req) => {
|
|
|
126
165
|
const isMultipart = incomingType.includes("multipart/form-data");
|
|
127
166
|
const isUrlEncoded = incomingType.includes("urlencoded");
|
|
128
167
|
try {
|
|
129
|
-
const data = isMultipart ? { raw: body } : isUrlEncoded ? Object.fromEntries(new URLSearchParams(body)) : JSON.parse(body);
|
|
168
|
+
const data = isMultipart ? { raw: body } : isUrlEncoded ? Object.fromEntries(new URLSearchParams(body)) : isJSON ? JSON.parse(body) : parseJsonOrRawText(body);
|
|
130
169
|
resolve({
|
|
131
170
|
contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : isUrlEncoded ? "application/x-www-form-urlencoded" : "text/plain",
|
|
132
171
|
data: isMultipart ? data : data
|
|
133
172
|
});
|
|
134
173
|
} catch (_e) {
|
|
135
|
-
|
|
136
|
-
contentType: "text/plain",
|
|
137
|
-
data: String(body)
|
|
138
|
-
});
|
|
174
|
+
reject(httpError(400, "Invalid JSON body"));
|
|
139
175
|
}
|
|
140
176
|
};
|
|
141
177
|
const onError = (err) => {
|
|
@@ -251,7 +287,7 @@ const middlewareStack = /* @__PURE__ */ new Set();
|
|
|
251
287
|
const createMiddleware = (initialOptions = {}) => {
|
|
252
288
|
const options = Object.assign({}, defaultMiddlewareOptions, initialOptions);
|
|
253
289
|
const middlewareName = options.name;
|
|
254
|
-
|
|
290
|
+
const rpcPrefix = options.rpcPrefix;
|
|
255
291
|
const path = options.path;
|
|
256
292
|
const handler = options.handler;
|
|
257
293
|
let name = middlewareName;
|
|
@@ -261,16 +297,16 @@ const createMiddleware = (initialOptions = {}) => {
|
|
|
261
297
|
}
|
|
262
298
|
if (middlewareStack.has(name)) throw new Error(MIDDLEWARE_NAME_USED(name));
|
|
263
299
|
middlewareStack.add(name);
|
|
264
|
-
const
|
|
300
|
+
const resolvedPrefix = resolveRPCPrefix(rpcPrefix);
|
|
301
|
+
const prefixRegex = rpcPrefix ? new RegExp(`^/${escapeRegExp(resolvedPrefix)}/`) : null;
|
|
265
302
|
const pathMatcher = path ? typeof path === "string" ? new RegExp(path) : path : null;
|
|
266
303
|
const middlewareHandler = async (req, res, next) => {
|
|
267
304
|
const { url } = getRequestDetails(req);
|
|
268
305
|
if (!handler) return next?.();
|
|
269
306
|
if (pathMatcher && !pathMatcher.test(url)) return next?.();
|
|
270
307
|
if (prefixRegex && !prefixRegex.test(url)) return next?.();
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
rpcPrefix,
|
|
308
|
+
if (getFunctionsForPrefix(resolvedPrefix).size === 0) await scanForServerFiles({
|
|
309
|
+
rpcPrefix: resolvedPrefix,
|
|
274
310
|
serverFiles: options.serverFiles,
|
|
275
311
|
scanRoot: options.scanRoot
|
|
276
312
|
});
|
|
@@ -289,13 +325,14 @@ const createMiddleware = (initialOptions = {}) => {
|
|
|
289
325
|
* @returns An Express middleware function
|
|
290
326
|
*/
|
|
291
327
|
const createRPCMiddleware = (initialOptions = {}) => {
|
|
292
|
-
const options = Object.assign({}, defaultMiddlewareOptions,
|
|
328
|
+
const options = Object.assign({}, defaultMiddlewareOptions, initialOptions);
|
|
293
329
|
const rpcPrefix = options.rpcPrefix;
|
|
294
|
-
const prefix = rpcPrefix
|
|
295
|
-
const prefixRegex =
|
|
330
|
+
const prefix = resolveRPCPrefix(rpcPrefix);
|
|
331
|
+
const prefixRegex = new RegExp(`^/${escapeRegExp(prefix)}/`);
|
|
296
332
|
const prefixReplace = `/${prefix}/`;
|
|
297
333
|
return createMiddleware({
|
|
298
334
|
...options,
|
|
335
|
+
rpcPrefix: prefix,
|
|
299
336
|
handler: async (req, res, _next) => {
|
|
300
337
|
const { url: path, searchParams } = getRequestDetails(req);
|
|
301
338
|
const { sendResponse } = getResponseDetails(res);
|
|
@@ -320,7 +357,13 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
320
357
|
if (method === "GET") {
|
|
321
358
|
const raw = searchParams.get("args");
|
|
322
359
|
if (raw) {
|
|
323
|
-
|
|
360
|
+
let parsed;
|
|
361
|
+
try {
|
|
362
|
+
parsed = JSON.parse(raw);
|
|
363
|
+
} catch {
|
|
364
|
+
sendResponse(400, { error: BAD_REQUEST });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
324
367
|
if (!Array.isArray(parsed)) {
|
|
325
368
|
sendResponse(400, { error: BAD_REQUEST });
|
|
326
369
|
return;
|
|
@@ -369,6 +412,11 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
369
412
|
req.off("close", onClose);
|
|
370
413
|
if (!requestEvent.redirected && !requestEvent.sent && !res.headersSent) sendResponse(200, { data: result });
|
|
371
414
|
} catch (err) {
|
|
415
|
+
if (isClientHttpError(err)) {
|
|
416
|
+
const status = clientErrorStatus(err);
|
|
417
|
+
sendResponse(status, { error: clientErrorMessage(status) });
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
372
420
|
console.error(String(err));
|
|
373
421
|
const isProduction = process.env.NODE_ENV === "production";
|
|
374
422
|
sendResponse(500, formatError(err, isProduction));
|