@thednp/rpc 0.2.0 → 0.3.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.
- package/AGENTS.md +54 -38
- package/CHANGELOG.md +85 -0
- package/README.md +27 -12
- package/dist/express/express.d.mts +5 -3
- package/dist/express/express.d.mts.map +1 -1
- package/dist/express/express.mjs +95 -20
- package/dist/express/express.mjs.map +1 -1
- package/dist/fastify/fastify.d.mts.map +1 -1
- package/dist/fastify/fastify.mjs +59 -10
- package/dist/fastify/fastify.mjs.map +1 -1
- package/dist/fastify/plugin/fastify/plugin.d.mts +13 -0
- package/dist/fastify/plugin/fastify/plugin.d.mts.map +1 -1
- package/dist/fastify/plugin/fastify/plugin.mjs +59 -10
- package/dist/fastify/plugin/fastify/plugin.mjs.map +1 -1
- package/dist/h3/h3.d.mts.map +1 -1
- package/dist/h3/h3.mjs +69 -16
- package/dist/h3/h3.mjs.map +1 -1
- package/dist/helpers/helpers.d.mts +63 -5
- package/dist/helpers/helpers.d.mts.map +1 -1
- package/dist/helpers/helpers.mjs +58 -1
- package/dist/helpers/helpers.mjs.map +1 -1
- package/dist/hono/hono.d.mts.map +1 -1
- package/dist/hono/hono.mjs +59 -8
- package/dist/hono/hono.mjs.map +1 -1
- package/dist/index.d.mts +48 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +84 -50
- package/dist/index.mjs.map +1 -1
- package/dist/koa/koa.d.mts.map +1 -1
- package/dist/koa/koa.mjs +70 -19
- package/dist/koa/koa.mjs.map +1 -1
- package/dist/server/server.d.mts +302 -27
- package/dist/server/server.d.mts.map +1 -1
- package/dist/server/server.mjs +180 -69
- package/dist/server/server.mjs.map +1 -1
- package/package.json +20 -18
package/AGENTS.md
CHANGED
|
@@ -4,25 +4,33 @@
|
|
|
4
4
|
|
|
5
5
|
```bash
|
|
6
6
|
pnpm dev # Run examples/spa dev server
|
|
7
|
+
pnpm dev:advanced # Run examples/advanced dev server
|
|
7
8
|
pnpm dev:express # Run examples/express dev server
|
|
8
9
|
pnpm dev:fastify # Run examples/fastify dev server
|
|
10
|
+
pnpm dev:h3 # Run examples/h3 dev server
|
|
9
11
|
pnpm dev:hono # Run examples/hono dev server
|
|
10
12
|
pnpm dev:koa # Run examples/koa dev server
|
|
11
13
|
pnpm dev:react-query # Run examples/react-query dev server
|
|
14
|
+
pnpm dev:solid-query # Run examples/solid-query dev server
|
|
12
15
|
pnpm dev:ssr # Run examples/ssr dev server
|
|
13
16
|
pnpm lint # Lint + typecheck (deno lint + tsc)
|
|
14
|
-
pnpm test # Run tests with coverage
|
|
15
|
-
pnpm test
|
|
17
|
+
pnpm test # Run tests once with coverage (vitest run --coverage)
|
|
18
|
+
pnpm test:watch # Run tests in watch mode with coverage
|
|
19
|
+
pnpm test:ui # Run tests with UI
|
|
20
|
+
pnpm test:dev # Run examples in dev mode (scripts/dev-test)
|
|
21
|
+
pnpm test:prod # Run examples in prod preview (scripts/dev-test --mode=preview)
|
|
16
22
|
pnpm lint:ts # deno lint src
|
|
17
23
|
pnpm fix:ts # deno lint src --fix
|
|
18
24
|
pnpm check:ts # tsc -noEmit
|
|
19
|
-
pnpm format # deno fmt src
|
|
25
|
+
pnpm format # deno fmt src tests examples/**/src
|
|
26
|
+
pnpm clean # Remove build artifacts and caches
|
|
20
27
|
pnpm build # tsdown (outputs to dist/)
|
|
21
28
|
pnpm up:examples # Update all example deps (to latest published @thednp/rpc + latest example deps)
|
|
22
29
|
pnpm up:examples:lib # Sync examples to the latest published @thednp/rpc version
|
|
23
30
|
pnpm up:root # Update root deps
|
|
24
31
|
pnpm up:deno # deno update + sync deno.json deps
|
|
25
32
|
pnpm upd # Update all deps (up:examples + up:examples:lib + up:root)
|
|
33
|
+
pnpm audit:src # Audit src deps
|
|
26
34
|
pnpm prepareOnly # upd + up:deno + lint + format + audit:src + build
|
|
27
35
|
pnpm release # Publish npm + jsr (scripts/release.js)
|
|
28
36
|
```
|
|
@@ -33,25 +41,20 @@ pnpm release # Publish npm + jsr (scripts/release.js)
|
|
|
33
41
|
|
|
34
42
|
## Examples
|
|
35
43
|
|
|
36
|
-
The `examples/` directory contains
|
|
37
|
-
|
|
38
|
-
| Example | Adapter | Type | Run Command
|
|
39
|
-
|
|
|
40
|
-
| `spa`
|
|
41
|
-
| `express`
|
|
42
|
-
| `
|
|
43
|
-
| `
|
|
44
|
-
| `
|
|
45
|
-
| `
|
|
46
|
-
| `
|
|
47
|
-
| `
|
|
48
|
-
| `
|
|
49
|
-
| `
|
|
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` |
|
|
44
|
+
The `examples/` directory contains 10 example apps:
|
|
45
|
+
|
|
46
|
+
| Example | Adapter | Type | Run Command | Config |
|
|
47
|
+
| ---------------| -------------------------------------------------------------------| ------| ------------------------| --------------------------------------|
|
|
48
|
+
| `spa` | Vite dev server (no adapter) | SPA | `pnpm dev` | `examples/spa/rpc.config.ts` |
|
|
49
|
+
| `express` | Express | SSR | `pnpm dev:express` | `examples/express/rpc.config.ts` |
|
|
50
|
+
| `advanced` | Express | SSR | `pnpm dev:advanced` | `examples/advanced/rpc.config.ts` |
|
|
51
|
+
| `fastify` | Fastify | SSR | `pnpm dev:fastify` | `examples/fastify/rpc.config.ts` |
|
|
52
|
+
| `h3` | h3 | SSR | `pnpm dev:h3` | `examples/h3/rpc.config.ts` |
|
|
53
|
+
| `hono` | Hono | SSR | `pnpm dev:hono` | `examples/hono/rpc.config.ts` |
|
|
54
|
+
| `koa` | Koa | SSR | `pnpm dev:koa` | `examples/koa/rpc.config.ts` |
|
|
55
|
+
| `react-query` | Express (React + @tanstack/react-query SSR) | SSR | `pnpm dev:react-query` | `examples/react-query/rpc.config.ts` |
|
|
56
|
+
| `solid-query` | Express (Solid + @tanstack/solid-query SSR) | SSR | `pnpm dev:solid-query` | `examples/solid-query/rpc.config.ts` |
|
|
57
|
+
| `ssr` | Custom `http-express.ts` (Express-compatible `node:http` server ) | SSR | `pnpm dev:ssr` | `examples/ssr/rpc.config.ts` |
|
|
55
58
|
|
|
56
59
|
Each example follows the same structure:
|
|
57
60
|
|
|
@@ -67,11 +70,11 @@ Each example follows the same structure:
|
|
|
67
70
|
|
|
68
71
|
## Key Directories
|
|
69
72
|
|
|
70
|
-
- `src/` — source for all packages (vite plugin, server, express, fastify, hono, koa adapters)
|
|
73
|
+
- `src/` — source for all packages (vite plugin, server, express, fastify, h3, hono, koa adapters)
|
|
71
74
|
- `dist/` — build output (not committed, generated by tsdown)
|
|
72
|
-
- `tests/` — test files (one per adapter + plugin)
|
|
75
|
+
- `tests/` — test files (one per adapter + plugin + helpers)
|
|
73
76
|
- `tests/fixtures/` — test fixtures (config files, vite-mock.ts)
|
|
74
|
-
- `examples/` — example apps (spa, express, fastify, hono, koa, react-query, solid-query, ssr)
|
|
77
|
+
- `examples/` — example apps (spa, express, fastify, h3, hono, koa, react-query, solid-query, ssr)
|
|
75
78
|
|
|
76
79
|
## Build Output (tsdown)
|
|
77
80
|
|
|
@@ -81,19 +84,24 @@ The tsdown.config.ts produces multiple entries:
|
|
|
81
84
|
- `dist/server/server.mjs` — standalone server
|
|
82
85
|
- `dist/express/express.mjs` — Express middleware
|
|
83
86
|
- `dist/fastify/fastify.mjs` — Fastify middleware
|
|
87
|
+
- `dist/h3/h3.mjs` — h3 middleware
|
|
84
88
|
- `dist/hono/hono.mjs` — Hono middleware
|
|
85
89
|
- `dist/koa/koa.mjs` — Koa middleware
|
|
86
90
|
|
|
87
91
|
## Test Files
|
|
88
92
|
|
|
89
|
-
| File
|
|
90
|
-
|
|
|
91
|
-
| `tests/plugin.test.ts`
|
|
92
|
-
| `tests/
|
|
93
|
-
| `tests/
|
|
94
|
-
| `tests/
|
|
95
|
-
| `tests/
|
|
96
|
-
| `tests/
|
|
93
|
+
| File | Tests | |
|
|
94
|
+
| ---------------------------------| --------------------------------------------------------------------| -----|
|
|
95
|
+
| `tests/plugin.test.ts` | Plugin init, loadRPCConfig, createServerFunction, getClientModules | |
|
|
96
|
+
| `tests/scan.test.ts` | scanForServerFiles (real scan, skip, devServer, error handling) | |
|
|
97
|
+
| `tests/server-helpers.test.ts` | RPCError, formatError, redirect, glob walking | |
|
|
98
|
+
| `tests/client-helpers.test.ts` | Client fetch stubs and retrieval helpers | |
|
|
99
|
+
| `tests/context.test.ts` | provideRequestContext / getRequestContext (AsyncLocalStorage) | |
|
|
100
|
+
| `tests/express.test.ts` | Express helpers, createMiddleware, createRPCMiddleware | |
|
|
101
|
+
| `tests/fastify.test.ts` | Fastify helpers, plugin, createMiddleware, createRPCMiddleware | |
|
|
102
|
+
| `tests/h3.test.ts` | h3 helpers, viteMiddleware, createMiddleware, createRPCMiddleware | |
|
|
103
|
+
| `tests/hono.test.ts` | Hono helpers, createMiddleware, createRPCMiddleware | |
|
|
104
|
+
| `tests/koa.test.ts` | Koa helpers, createMiddleware, createRPCMiddleware | |
|
|
97
105
|
|
|
98
106
|
## Important Notes
|
|
99
107
|
|
|
@@ -107,9 +115,10 @@ The tsdown.config.ts produces multiple entries:
|
|
|
107
115
|
|
|
108
116
|
- Vite plugin for creating server functions with automatic RPC generation
|
|
109
117
|
- Server functions return `{ data: Promise<T>, cancel: (reason?: string) => void }` shape
|
|
110
|
-
- Framework-agnostic core with adapters for Express, Fastify, Hono, and
|
|
118
|
+
- Framework-agnostic core with adapters for Express, Fastify, Hono, Koa, and h3
|
|
111
119
|
- Client modules are auto-generated with `AbortController` support for cancellation
|
|
112
120
|
- Server-side caching must be handled by third party tools (e.g. `@tanstack/react-query`)
|
|
121
|
+
- **Multi-prefix support**: `createServerFunction(..., { rpcPrefix })` registers functions in a prefix-scoped map (`getFunctionsForPrefix`), so multiple RPC instances can coexist (versioned/namespaced APIs). All five adapters dispatch via `getFunctionsForPrefix(rpcPrefix || defaultPrefix)`; `serverFunctionsMap` is a backward-compatible proxy for the default `"__rpc"` prefix (`defaultPrefix`)
|
|
113
122
|
|
|
114
123
|
## Security & Hardening
|
|
115
124
|
|
|
@@ -117,9 +126,9 @@ The tsdown.config.ts produces multiple entries:
|
|
|
117
126
|
- **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
|
|
118
127
|
- **Regex compilation hoisted**: All prefix/path regexes are compiled once at middleware creation time (not per-request), eliminating per-request regex overhead
|
|
119
128
|
- **Koa URL normalization**: Koa adapter parses `ctx.url` through `new URL()` to strip query strings and normalize encoding before prefix checking
|
|
120
|
-
- **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
|
|
121
|
-
- **Body size limits**: Host frameworks cap parsed JSON bodies — Express (`express.json({ limit })`), Fastify (`bodyLimit`), Koa (`koa-body`), Hono (`hono/body-limit`). 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.
|
|
122
|
-
- **Generic 404 responses**: Error messages
|
|
129
|
+
- **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.
|
|
130
|
+
- **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.
|
|
131
|
+
- **Generic 404 responses**: Error messages never echo the requested function name (no message-based function enumeration). Note the status code still distinguishes unknown (`404`) from known functions (`405`/`415`/`403`); function names ship in the client bundle so they are not secret — see `wiki/security.md`
|
|
123
132
|
- **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.
|
|
124
133
|
- **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
|
|
125
134
|
|
|
@@ -143,7 +152,13 @@ The framework's security boundary is the **RPC prefix-gated HTTP endpoint**. Inp
|
|
|
143
152
|
**Attackers are expected to**:
|
|
144
153
|
- Be free to send as many requests as the host allows (no rate limiting — host's responsibility)
|
|
145
154
|
- Be free to hit any URL (no auth — host's responsibility via prior middleware)
|
|
146
|
-
- Be rejected with generic
|
|
155
|
+
- Be rejected with generic error bodies (no function-name disclosure in messages, no stack traces); status-code differential still reveals existence — see `wiki/security.md`
|
|
156
|
+
|
|
157
|
+
## Workflow notes (important!)
|
|
158
|
+
|
|
159
|
+
- **Harness folders**: when scaffolding a minimal repro/harness to debug the Vite plugin or an adapter, create it inside the repo (e.g. `TEMP/`) — **never** in the root or in OS temp dirs. Root-level harness files break `tsdown`/`vitest` path resolution, and temp dirs outside the project get swept by OS cleaners and leave stale `node_modules`/`.vite` state that corrupts the next run.
|
|
160
|
+
- **Never delete files**: do not `rm` source/test files. If a file must be removed from the tree, **rename it to `<name>-bak.<ext>`** (e.g. `foo.ts` → `foo-bak.ts`) and leave it in place. The `-bak` suffix is the only sanctioned way to retire a file; the repo may be scanned for history or references later.
|
|
161
|
+
|
|
147
162
|
|
|
148
163
|
## Documentation
|
|
149
164
|
|
|
@@ -151,6 +166,7 @@ The framework's security boundary is the **RPC prefix-gated HTTP endpoint**. Inp
|
|
|
151
166
|
- `wiki/getting-started.md` — Installation, project structure, auto-scanning, and your first function
|
|
152
167
|
- `wiki/configuration.md` — Configuration reference (`rpc.config.ts`, `vite.config.ts`, options)
|
|
153
168
|
- `wiki/server-functions.md` — `createServerFunction` API, methods, validation, **request context (`getRequestContext`/`provideRequestContext`)** for per-request data access across async call stacks
|
|
169
|
+
- `wiki/middleware.md` — universal adapter-agnostic middleware via the request context (`locals` bridge, `getRequestMeta`, `sendResponse`, `functionName`)
|
|
154
170
|
- `wiki/nojs-fallback.md` — native (no-JS) `<form>` fallback / progressive enhancement pattern
|
|
155
171
|
- `wiki/client-usage.md` — Client-side usage, type safety, react-query integration
|
|
156
172
|
- `wiki/wire-protocol.md` — HTTP contract, request/response bodies, curl debugging
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,90 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [0.3.0] - 2026-08-21
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
|
|
7
|
+
- **Multi-prefix support**: `createServerFunction` accepts a per-function `rpcPrefix` option (`{ rpcPrefix: "v1:rpc" }`) so multiple RPC instances can coexist in parallel — versioned APIs, namespaced endpoints, and API segregation without function-name collisions. The server functions map is now scoped by prefix (`getFunctionsForPrefix(prefix)`), the plugin generates client stubs per prefix (`getClientModules` reads only the requested prefix's map), and all five adapters (Express, Fastify, Hono, Koa, h3) look functions up in the prefix-scoped map instead of a single global map. Functions default to `"__rpc"` for full backward compatibility; the same registered name under different prefixes is no longer a duplicate, while same-prefix duplicates still throw in dev / warn in production
|
|
8
|
+
- **`getFunctionsForPrefix(prefix)`**: new exported server helper returning (and lazily creating) the `Map<name, ServerFnEntry>` for a given RPC prefix; `serverFunctionsMap` remains as the backward-compatible proxy for the default `"__rpc"` prefix
|
|
9
|
+
- **`defaultPrefix` constant**: the default `"__rpc"` prefix is now a named export from `@thednp/rpc/server`, used consistently across the scan, adapters, and function registration instead of a hardcoded string
|
|
10
|
+
- **Prefix charset widened**: `validatePathSegment` now permits `:` (and `@` in the first position) so versioned prefixes like `v1:rpc` / `v2:rpc` pass validation; `.` remains disallowed to keep path-traversal rejection (`foo..bar`, `foo/../bar`) intact
|
|
11
|
+
- **`getClientStub` helper** (`@thednp/rpc/helpers`): manual typed client stub factory for privileged prefixes not emitted in the public bundle — `getClientStub("admin:rpc","get-user")` (also curried `getClientStub("admin:rpc")("get-user")`) returns the same `{data,cancel}` shape as auto-generated stubs, with `method`/`credentials`/`contentType` options; code-splittable so `admin:rpc` literals never appear in the public chunk when `await import`-ed only inside `/admin` routes
|
|
12
|
+
- **Advanced example auth**: `examples/advanced` now has cookie-session auth (`HttpOnly; SameSite=Lax` `sid` via `Symbol.for("thednp.rpc.advanced.session")`), `public:rpc/login`/`logout`/`me`, `admin:rpc` guarded by `requireAdminSession` (403 without admin role), and SSR guard for `/admin` → `403` in `server.js` — demonstrates that `admin:rpc` isolation is not obscurity and that `getClientStub` must be used with real auth
|
|
13
|
+
|
|
14
|
+
### Fixes
|
|
15
|
+
|
|
16
|
+
- **Cross-bundle map sharing**: `serverFunctionsByPrefix` now on `globalThis[Symbol.for("thednp.rpc.functionsMap")]` (`src/functionsMap.ts:12`) like `requestContext` — plugin scan (`dist/index.mjs`) and adapter dispatch (`dist/express/*.mjs`) share one map instead of per-bundle copies (dev 404 fix)
|
|
17
|
+
- **Config fallback**: scan fallback `exportValue.options?.rpcPrefix || config.rpcPrefix || defaultPrefix` (`src/scanForServerFiles.ts:138`) and `ScanConfig.rpcPrefix` (`src/types.d.ts:217`) propagated from `vite.config.ts`/`rpc.config.ts` via `src/index.ts:193` and lazy `src/*/*createMiddleware.ts:107` — existing examples without per-function `rpcPrefix` now register under the config prefix instead of `__rpc`
|
|
18
|
+
- **Glob scan in prod**: `MiddlewareOptions.serverFiles/scanRoot` (`src/types.d.ts:333`) now forwarded to lazy `scanForServerFiles` in all five adapters, and `examples/advanced/server.js:25` `admin:rpc` mounts with `serverFiles:"glob"` — prod `preview` finds `*.server.ts` files instead of defaulting to `exact`
|
|
19
|
+
- **Client generation DRY**: `src/getClientModules.ts:47` now emits `getClientStub("prefix","name",{...})` via `src/client-helpers.ts:32` `makeStub` instead of duplicating `body`/`headers` per function
|
|
20
|
+
|
|
21
|
+
### Docs
|
|
22
|
+
|
|
23
|
+
- New `wiki/multi-prefix-guide.md` — parallel RPC instances: versioned/public/admin API layouts, per-prefix middleware wiring, canary deployments, origin validation per instance, and backward compatibility; added **Security: Do Not Trust the Prefix** section
|
|
24
|
+
- `wiki/security.md:92` **Multi-Prefix Client Isolation** — `getClientModules` virtual modules (`src/index.ts:221`), no disk files, only config prefix emitted, prefix is not a secret, must use `requireAdminSession`/`sendResponse(403)`
|
|
25
|
+
- `wiki/index.md` TOC + cross-links from `wiki/configuration.md` and `wiki/adapters.md` to the multi-prefix guide
|
|
26
|
+
- `AGENTS.md`, `llms.txt`, and `README.md` updated for the multi-prefix feature, `defaultPrefix` constant, `getClientStub`, and `dev:advanced`/`test:dev`/`test:prod` scripts
|
|
27
|
+
- `examples/advanced/README.md` rewritten for auth + multi-prefix demo
|
|
28
|
+
|
|
29
|
+
### Tests
|
|
30
|
+
|
|
31
|
+
- Multi-prefix coverage: `createServerFunction` registers under a custom prefix (isolated from the default map), `getClientModules` generates `getClientStub` stubs only for the requested prefix, and the scan registers functions under their declared prefix without name collision
|
|
32
|
+
- Adapter middleware tests updated to register functions in the prefix-scoped map for non-default prefixes
|
|
33
|
+
- `getClientStub` coverage: curried `getClientStub("admin:rpc")("get-user")` and direct `getClientStub("admin:rpc","get-user")` plus `GET`/`text/plain`/`urlencoded`/`multipart` branches (`tests/client-helpers.test.ts:198`)
|
|
34
|
+
- **100% coverage**: all metrics (statements, branches, functions, lines) at 100% — 427 tests
|
|
35
|
+
|
|
36
|
+
### Chores
|
|
37
|
+
|
|
38
|
+
- `pnpm test` now `vitest run --coverage`; new `pnpm test:watch` `vitest --watch --coverage`; `pnpm test:dev`/`test:prod` now `test:dev`/`test:prod` with colon; `pnpm clean` and `pnpm audit:src` documented; `scripts/update-examples.js:33` skips `advanced` (`link:../..`)
|
|
39
|
+
|
|
40
|
+
## [0.2.1] - 2026-08-10
|
|
41
|
+
|
|
42
|
+
### Features
|
|
43
|
+
|
|
44
|
+
- **`send` on the request context**: `RequestEvent.send(status, body, headers?)` (plus the `sent` flag) lets middleware and server functions short-circuit the RPC dispatch with a full HTTP response instead of the `{ data }` envelope, mirroring the existing `redirect`/`redirected` pair. All five adapters bind it and skip their JSON send when `sent` is set: Express (via `getResponseDetails().sendResponse`), Fastify (`reply.header()` loop + `reply.status().send()`), Koa (`ctx.set()` loop + `ctx.status`/`ctx.body`), Hono (records only — the post-dispatch handler returns `c.body(JSON.stringify(body), status, { "content-type": "application/json", ...headers })`), and h3 (records only — the post-dispatch handler sets `event.res.status` and `event.res.headers` then returns the body)
|
|
45
|
+
- **`functionName` on the request context**: `RequestEvent.functionName` exposes the dispatched function name so universal middleware can branch per function (e.g. rate limits, per-function authorization)
|
|
46
|
+
- **`sendResponse` helper**: a `sendResponse(status, body, headers?)` context helper, the response counterpart to the `redirect` helper, delegating to the same adapter-bound write path
|
|
47
|
+
- **`getRequestMeta(event)`**: a normalized request reader returning `{ method, pathname, search, searchParams, headers, host, ip, protocol }`, duck-typed across all five adapter request shapes — feature-detects fetch-like `Headers` (h3/Hono) vs plain maps (Express/Fastify/Koa), resolves the URL from `originalUrl ?? url ?? path`, and derives `host`/`protocol`/`ip` with safe fallbacks
|
|
48
|
+
|
|
49
|
+
### Examples
|
|
50
|
+
|
|
51
|
+
- **h3 example**: extract `middleware/bodyLimit.js` — delegates to h3's native `assertBodySize`, which swaps `event.req` for a bounded stream so the cap is enforced while streaming (never fully buffered) and the RPC `readBody` can still consume it afterwards; oversized bodies reject with `413` JSON. Extract `middleware/serveStatic.js` — aliases h3's `serveStatic` from `h3/node`, adds `Content-Length`, `Last-Modified`, and `Cache-Control: public, max-age=31536000, immutable`, and is registered **after** `createRPCMiddleware()` so asset requests never reach server functions; missing files fall through to the SSR handler
|
|
52
|
+
- **demo**: the prerender plugin now writes the app content between `<!-- app-content -->` markers and gains a `configurePreviewServer` middleware that re-renders just that region from the URL query — nojs form state (values + errors) is recovered in `vite preview`, where the baked `dist/index.html` shell otherwise skips `transformIndexHtml`
|
|
53
|
+
- **demo**: `body-limit.ts` stashes multipart bodies as `{ raw: body }` to mirror `@thednp/rpc/express`'s `readBody` streaming semantics; the render page and `getLibraryInfo` now count 9 examples and list the h3 adapter; the features grid grows to 9 cards (3×3) with request-context, no-JS form-fallback, and boundary-enforcement entries
|
|
54
|
+
- **fastify example**: switch the production server to `@fastify/compress` (gzip) for the RPC endpoint and static HTML. `@fastify/compress` attaches its per-route `onSend` hook via `onRoute`, which never fires for the RPC plugin's global `preHandler` handling, so the example registers a scoped `app.post("/_server/*")` catch-all route — the RPC `preHandler` short-circuits before the handler runs, letting compress's hook attach to RPC POSTs while non-RPC POSTs still get a 404. Verified with curl: HTML and RPC POST responses (200 and 404) compress (gzip) with byte-identical decompression (md5 match), and non-RPC POSTs keep their 404
|
|
55
|
+
- Sync all 9 examples to `@thednp/rpc ^0.2.0`
|
|
56
|
+
- **advanced example** (`examples/advanced`): Express SSR showcase of the multi-prefix model and universal middleware — the same `get-user` function name is registered under both `public:rpc` (rate-limited, 5 req/10s, returns public user data) and `admin:rpc` (guarded by a `x-admin-token` header check, returns full record), served by two `createRPCMiddleware` instances mounted in `server.js` while the client stubs are generated only for the config `public:rpc` prefix; a `middleware.ts` module (`rateLimit`, `auditLog`, `requireAdmin`) built on `getRequestContext`/`getRequestMeta`/`sendResponse` is shared across both prefixes. Dev mode mounts the admin middleware explicitly since the Vite plugin only auto-mounts the configured prefix; `scripts/dev-test.js` PREFIX_MAP includes `advanced: "public:rpc"` and the root `dev:advanced` script runs it
|
|
57
|
+
|
|
58
|
+
### Docs
|
|
59
|
+
|
|
60
|
+
- **New `wiki/middleware.md`** — universal adapter-agnostic middleware via the request context: the `locals` bridge table (Express `res.locals`, Koa `ctx.state`, h3 `event.context`, Fastify/Hono `{}` with `decorateRequest`/`c.set` workarounds), `getRequestMeta`, `functionName`, `sendResponse` per-adapter mapping, and wrap recipes for official framework middleware (Express session, Koa `ctx.state`, h3 `event.context`, Fastify `decorateRequest`, Hono `c.set`/`c.get`)
|
|
61
|
+
- TOC sweep: `- [Middleware](./middleware.md)` entry added to all 11 wiki pages after Server Functions; cross-linked from `server-functions.md` (RequestEvent shape + "Writing Universal Middleware" section), `best-practices.md` (Authentication, Rate Limiting), and `adapters.md`; `wiki/index.md` updated
|
|
62
|
+
- Update `wiki/server-functions.md` RequestEvent reference for `send`/`sent`/`functionName` and re-point its "Next" pointer to `middleware.md`
|
|
63
|
+
- `wiki/nojs-fallback.md`: explain the form markup and detection rule without embedding the whole `createFormFallback` implementation (now links to `demo/src/lib/form-fallback.ts`)
|
|
64
|
+
- `wiki/adapters.md`: replace the h3 body-limit snippet with h3's native `assertBodySize` pattern (bounded stream, never buffered) and add a **Static Assets** section for the extracted `serveStatic` middleware; `wiki/best-practices.md` h3 body limit now recommends `assertBodySize` and warns against iterating `event.req` (which breaks the RPC `readBody` with "Body is unusable")
|
|
65
|
+
- Update `AGENTS.md` (9 examples, h3 adapter, test files table), `llms.txt`, and `README.md` (five adapters, 10 test files, `nojs-fallback` doc link, `pmpm` → `pnpm` typo) for the new adapter, examples, and context API
|
|
66
|
+
- Example READMEs: add the `wiki/middleware.md` resource link to all 9 example READMEs; rewrite `examples/solid-query/README.md` (was a copy-paste of the react-query README — now describes Solid's `createQuery`/`createMutation`, `renderToStringAsync` SSR, and the disabled-query gotcha); expand the h3 example README's production flow for the extracted `bodyLimit`/`serveStatic` middleware
|
|
67
|
+
|
|
68
|
+
### Tests
|
|
69
|
+
|
|
70
|
+
- **100% coverage across all adapters**: 1001/1001 statements, 608/608 branches, 155/155 functions, 981/981 lines (412 tests)
|
|
71
|
+
- Add `send` short-circuit tests to the Express, Fastify, and Koa suites — both with headers (asserting the `headers` loop) and without (covering the `if (headers)` false branch that had dropped branch coverage below 100%)
|
|
72
|
+
- Add `tests/context.test.ts` suite: `sendResponse` delegation (with/without headers, outside-request throw), `getRequestMeta` normalization (Express-style, fetch-like `Headers`, `ip`/`protocol` derivation, bare request, plain-map headers, array-valued header, URL protocol fallback), and `functionName` passthrough
|
|
73
|
+
- Adapter suites assert `functionName` exposure via `getRequestContext()` and `send` short-circuits the JSON dispatch on all five adapters
|
|
74
|
+
- Add `?args=` non-array rejection tests (400 Bad Request) and bare-GET (no `?args=`) dispatch tests to all five adapter suites — **100/100% coverage, 412 tests**
|
|
75
|
+
|
|
76
|
+
### Security
|
|
77
|
+
|
|
78
|
+
- **`safeURL` defensive URL parsing**: `getRequestDetails()` and every adapter's inline `new URL(rawUrl, ...)` call now parse through `safeURL` (`src/server-helpers.ts`), which never throws — a malformed request-target like `/\` or `//` used to trigger an unhandled `TypeError: Invalid URL` rejection outside the dispatch `try` block and **crash raw `node:http` hosts** (and Express 4). Malformed URLs now fall back to a safe root pathname that never matches the RPC prefix, so the request degrades to `next()`/404 instead of crashing the process (High)
|
|
79
|
+
- **GET `?args=` is now validated as a JSON array**: a non-array value (e.g. `?args={"a":1}`) previously spread into `handler(...args)` and threw a confusing `TypeError: object is not iterable` 500; all five adapters now reject it with `400 { error: "Bad Request" }` before dispatch
|
|
80
|
+
- **`wiki/security.md` enumeration claim corrected**: the "prevents function enumeration" wording was overstated — the status code still distinguishes unknown (`404`) from known (`405`/`415`/`403`) functions. Documented that enumeration is mitigated against *message* disclosure only; function names ship in the client bundle so they are not secret. `AGENTS.md` and `llms.txt` updated to match
|
|
81
|
+
- Full source security audit recorded in `SECURITY-AUDIT.md` (High crash finding fixed; remaining findings are LOW/INFO/design notes)
|
|
82
|
+
|
|
83
|
+
### Chores
|
|
84
|
+
|
|
85
|
+
- Bump version to `0.2.1` (package.json + deno.json)
|
|
86
|
+
- Add `@thednp/rpc@0.2.0` to `minimumReleaseAgeExclude` in `pnpm-workspace.yaml`
|
|
87
|
+
|
|
3
88
|
## [0.2.0] - 2026-08-09
|
|
4
89
|
|
|
5
90
|
### Features
|
package/README.md
CHANGED
|
@@ -78,7 +78,7 @@ Every server function call returns a handle with a `cancel()` helper. Under the
|
|
|
78
78
|
<details>
|
|
79
79
|
<summary><b>Your server framework is your business</b></summary>
|
|
80
80
|
|
|
81
|
-
The core plugin doesn't care whether you're running Express, Fastify, Hono, or
|
|
81
|
+
The core plugin doesn't care whether you're running Express, Fastify, Hono, Koa, or h3. Adapters for all five are bundled with the package — you import the one you need, register it as middleware, and you're done. If you're building a plain SPA with no server framework at all, the Vite dev server handles RPC requests directly in development. No adapter needed.
|
|
82
82
|
</details>
|
|
83
83
|
|
|
84
84
|
<details>
|
|
@@ -99,11 +99,23 @@ Server errors return a generic `Internal Server Error` — no messages, codes, o
|
|
|
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
|
+
<details>
|
|
103
|
+
<summary><b>Multi-prefix support</b></summary>
|
|
104
|
+
|
|
105
|
+
Run multiple RPC instances in parallel. Pass `{ rpcPrefix: "v1:rpc" }` to `createServerFunction` to register a function under a custom prefix — versioned APIs, namespaced endpoints, and API segregation without function-name collisions. The same name can coexist under different prefixes (`v1:rpc/login` + `v2:rpc/login`), middleware dispatches to the prefix-scoped map, and the plugin generates client stubs per prefix. Functions default to `"__rpc"` for full backward compatibility. See the [Multi-Prefix Guide](./wiki/multi-prefix-guide.md).
|
|
106
|
+
</details>
|
|
107
|
+
|
|
108
|
+
<details>
|
|
109
|
+
<summary><b>Universal middleware</b></summary>
|
|
110
|
+
|
|
111
|
+
Write **one** middleware function that runs unchanged on every adapter (Express, Fastify, Hono, Koa, h3). Because every dispatch runs inside a per-request context, middleware written against `getRequestContext()` — reading normalized request data via `getRequestMeta()`, short-circuiting with `sendResponse(status, body, headers)` — behaves identically regardless of the host framework. No per-framework rewrites for cross-cutting RPC rules like per-function rate limiting, audit logging, or feature flags. See the [Middleware Guide](./wiki/middleware.md).
|
|
112
|
+
</details>
|
|
113
|
+
|
|
102
114
|
## Examples
|
|
103
115
|
|
|
104
116
|
| Source | Demo | Clone |
|
|
105
117
|
| ----------------------------------------------------------------------------------------| ----------------------------------------------------------------------------------------------| ---------------------------------------------------------|
|
|
106
|
-
| [examples/spa](https://github.com/thednp/rpc/tree/master/examples/spa) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/spa) | `
|
|
118
|
+
| [examples/spa](https://github.com/thednp/rpc/tree/master/examples/spa) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/spa) | `pnpm dlx degit thednp/rpc/examples/spa my-app` |
|
|
107
119
|
| [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
120
|
| [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
121
|
| [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` |
|
|
@@ -112,6 +124,7 @@ Generic type inference flows from your server function's arguments and return ty
|
|
|
112
124
|
| [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
125
|
| [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
126
|
| [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` |
|
|
127
|
+
| [examples/advanced](https://github.com/thednp/rpc/tree/master/examples/advanced) | [StackBlitz](https://stackblitz.com/fork/github/thednp/rpc/tree/master/examples/advanced) | `pnpm dlx degit thednp/rpc/examples/advanced my-app` |
|
|
115
128
|
|
|
116
129
|
> **Clone an example**: `degit` scaffolds a fresh copy straight from the repo — no git history, ready to run:
|
|
117
130
|
|
|
@@ -244,18 +257,18 @@ See the [Adapters guide](./wiki/adapters.md) for full snippets for each framewor
|
|
|
244
257
|
### Unit Testing
|
|
245
258
|
|
|
246
259
|
```bash
|
|
247
|
-
pnpm test # Run tests with coverage
|
|
248
|
-
pnpm test
|
|
249
|
-
pnpm test
|
|
260
|
+
pnpm test # Run tests once with coverage (vitest run --coverage)
|
|
261
|
+
pnpm test:watch # Run tests in watch mode with coverage
|
|
262
|
+
pnpm test:ui # Run tests with UI
|
|
250
263
|
```
|
|
251
264
|
|
|
252
|
-
Tests use **Vitest** with **Istanbul** coverage —
|
|
265
|
+
Tests use **Vitest** with **Istanbul** coverage — 10 test files covering the plugin, scanning, server/client helpers, request context, and all five adapters, at 100% coverage.
|
|
253
266
|
|
|
254
267
|
### Live Testing
|
|
255
268
|
|
|
256
269
|
```bash
|
|
257
|
-
pnpm test
|
|
258
|
-
pnpm test
|
|
270
|
+
pnpm test:dev # Runs all examples/<example> in DEV mode and reports their status in a table
|
|
271
|
+
pnpm test:prod # Runs all examples/<example> in PRODUCTION mode and reports their status in a table
|
|
259
272
|
```
|
|
260
273
|
|
|
261
274
|
These tests check the following:
|
|
@@ -277,10 +290,10 @@ Contributions are welcome. This project uses:
|
|
|
277
290
|
|
|
278
291
|
```bash
|
|
279
292
|
pnpm lint # deno lint + tsc -noEmit
|
|
280
|
-
pnpm format # deno fmt src
|
|
281
|
-
pnpm test # Run tests with coverage
|
|
282
|
-
pnpm test
|
|
283
|
-
pnpm build # Bundle with tsdown
|
|
293
|
+
pnpm format # deno fmt src tests examples/**/src
|
|
294
|
+
pnpm test # Run tests once with coverage (vitest run --coverage)
|
|
295
|
+
pnpm test:ui # Run tests with UI
|
|
296
|
+
pnpm build # Bundle with tsdown (tsdown)
|
|
284
297
|
```
|
|
285
298
|
|
|
286
299
|
All changes should pass `pnpm lint && pnpm format && pnpm test` before submitting. See [AGENTS.md](./AGENTS.md) for the full command reference and project conventions.
|
|
@@ -335,6 +348,8 @@ The full threat model, including edge cases and configuration options for tighte
|
|
|
335
348
|
- [Getting Started](./wiki/getting-started.md) — Installation, project structure, and your first function
|
|
336
349
|
- [Configuration](./wiki/configuration.md) — Full configuration reference
|
|
337
350
|
- [Server Functions](./wiki/server-functions.md) — Creating server functions
|
|
351
|
+
- [Middleware](./wiki/middleware.md) — Universal middleware via the request context
|
|
352
|
+
- [Native Form Fallback](./wiki/nojs-fallback.md) — Making RPC endpoints work as a no-JS `<form>` action
|
|
338
353
|
- [Client Usage](./wiki/client-usage.md) — Client-side usage
|
|
339
354
|
- [Wire Protocol](./wiki/wire-protocol.md) — The HTTP contract behind the generated clients (curl debugging)
|
|
340
355
|
- [Adapters](./wiki/adapters.md) — Framework adapters
|
|
@@ -38,7 +38,7 @@ type ResponseDetails = {
|
|
|
38
38
|
/** Sets the response status code */
|
|
39
39
|
setStatusCode: (code: number) => void;
|
|
40
40
|
/** Sends a JSON response with the given status code and output */
|
|
41
|
-
sendResponse: (code: number, output:
|
|
41
|
+
sendResponse: (code: number, output: JsonValue) => void;
|
|
42
42
|
};
|
|
43
43
|
/**
|
|
44
44
|
* Normalized view of an incoming request: URL parts, headers, and method.
|
|
@@ -67,9 +67,11 @@ type RequestDetails = {
|
|
|
67
67
|
declare const createMiddleware: ExpressMiddlewareFn;
|
|
68
68
|
/**
|
|
69
69
|
* Creates the Express RPC middleware that routes incoming requests to registered server functions.
|
|
70
|
-
* Reads the request body, dispatches to the matching function via
|
|
70
|
+
* Reads the request body, dispatches to the matching function via getFunctionsForPrefix,
|
|
71
71
|
* and sends the JSON-serialized result. Handles client disconnection via abort signals.
|
|
72
|
-
*
|
|
72
|
+
* Supports multi-prefix setups where different middleware instances can route to functions
|
|
73
|
+
* registered under different prefixes.
|
|
74
|
+
* @param initialOptions - Options including rpcPrefix for URL routing and prefix-scoped function lookup
|
|
73
75
|
* @returns An Express middleware function
|
|
74
76
|
*/
|
|
75
77
|
declare const createRPCMiddleware: ExpressMiddlewareFn;
|
|
@@ -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
|
|
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;;;;;KAM3B;;EAEV;;EAEA;;EAEA,cAAc;;EAEd,SAAS;;EAET;;;;;;;;;;;cCrBW,kBAAkB;;;;;;;;;;cAiFlB,qBAAqB;;;;;;;;iBCpHZ,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"}
|
package/dist/express/express.mjs
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
import { escapeRegExp, formatError, hasContentTypeMismatch, provideRequestContext, scanForServerFiles
|
|
2
|
-
//#region src/options.ts
|
|
1
|
+
import { escapeRegExp, formatError, getGlobalPrefix, hasContentTypeMismatch, provideRequestContext, scanForServerFiles } from "@thednp/rpc/server";
|
|
3
2
|
const defaultRPCOptions = {
|
|
4
3
|
rpcPrefix: "__rpc",
|
|
5
4
|
adapter: "express",
|
|
@@ -12,6 +11,65 @@ const defaultMiddlewareOptions = {
|
|
|
12
11
|
origin: void 0
|
|
13
12
|
};
|
|
14
13
|
//#endregion
|
|
14
|
+
//#region src/functionsMap.ts
|
|
15
|
+
/**
|
|
16
|
+
* Global symbol under which the shared `serverFunctionsByPrefix` map is stored
|
|
17
|
+
* on `globalThis`. Keeping it on a `Symbol.for` key makes it instance-stable
|
|
18
|
+
* across the bundled entry copies (`index.mjs`, `server.mjs`, `express.mjs`,
|
|
19
|
+
* ...) and dev-server hot reloads, exactly like the request-context storage in
|
|
20
|
+
* `context.ts`. Without this, `scanForServerFiles` (bundled into the plugin)
|
|
21
|
+
* would populate a map copy the adapter middleware could not read.
|
|
22
|
+
*/
|
|
23
|
+
const functionsMapSymbol = Symbol.for("thednp.rpc.functionsMap");
|
|
24
|
+
/**
|
|
25
|
+
* Map of rpcPrefix -> Map of function names -> ServerFnEntry
|
|
26
|
+
* Enables multiple RPC instances with different prefixes to coexist
|
|
27
|
+
* without name collisions.
|
|
28
|
+
*/
|
|
29
|
+
const serverFunctionsByPrefix = globalThis[functionsMapSymbol] ??= /* @__PURE__ */ new Map();
|
|
30
|
+
/**
|
|
31
|
+
* Gets or creates the function map for a specific prefix.
|
|
32
|
+
* @param prefix - The RPC prefix (e.g., "__rpc", "v1:rpc", "admin:rpc")
|
|
33
|
+
* @returns Map of function names to ServerFnEntry for that prefix
|
|
34
|
+
*/
|
|
35
|
+
const getFunctionsForPrefix = (prefix) => {
|
|
36
|
+
if (!serverFunctionsByPrefix.has(prefix)) serverFunctionsByPrefix.set(prefix, /* @__PURE__ */ new Map());
|
|
37
|
+
return serverFunctionsByPrefix.get(prefix);
|
|
38
|
+
};
|
|
39
|
+
//#endregion
|
|
40
|
+
//#region src/constants.ts
|
|
41
|
+
const FUNCTION_NOT_FOUND = "Function not found";
|
|
42
|
+
const METHOD_NOT_ALLOWED = "Method Not Allowed";
|
|
43
|
+
const REQUEST_FORBIDDEN = "Forbidden";
|
|
44
|
+
const UNSUPPORTED_MEDIA_TYPE = "Unsupported Media Type";
|
|
45
|
+
const BAD_REQUEST = "Bad Request";
|
|
46
|
+
const CLIENT_DISCONNECTED = "client disconnected";
|
|
47
|
+
/** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */
|
|
48
|
+
const MIDDLEWARE_NAME_USED = (name) => `The middleware name "${name}" is already used.`;
|
|
49
|
+
//#endregion
|
|
50
|
+
//#region src/server-helpers.ts
|
|
51
|
+
const SAFE_URL_BASE = "http://localhost";
|
|
52
|
+
/**
|
|
53
|
+
* Parses a raw request URL against a fixed base without ever throwing.
|
|
54
|
+
* Malformed request-targets (e.g. `/\`, `//`, `/\/`) make the WHATWG URL
|
|
55
|
+
* parser throw `TypeError: Invalid URL`; the adapters call this while
|
|
56
|
+
* building the per-request URL **before** their dispatch `try` block, so an
|
|
57
|
+
* unhandled rejection there crashes raw `node:http` hosts (and Express 4).
|
|
58
|
+
* On failure we fall back to the base root: the resulting pathname never
|
|
59
|
+
* matches the RPC prefix, so the request is treated as non-RPC and falls
|
|
60
|
+
* through to `next()` / 404 instead of crashing the process.
|
|
61
|
+
* @param rawUrl - Raw request URL (path + optional query string)
|
|
62
|
+
* @param base - Optional base URL, defaults to a fixed localhost origin
|
|
63
|
+
* @returns A URL object; never throws
|
|
64
|
+
*/
|
|
65
|
+
const safeURL = (rawUrl, base = SAFE_URL_BASE) => {
|
|
66
|
+
try {
|
|
67
|
+
return new URL(rawUrl, base);
|
|
68
|
+
} catch {
|
|
69
|
+
return new URL("/", base);
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
//#endregion
|
|
15
73
|
//#region src/express/helpers.ts
|
|
16
74
|
/**
|
|
17
75
|
* Convenience function to load RPC config and attach the RPC middleware to an Express app.
|
|
@@ -140,7 +198,7 @@ const hasPreParsedBody = (req) => {
|
|
|
140
198
|
*/
|
|
141
199
|
const getRequestDetails = (request) => {
|
|
142
200
|
const rawUrl = isExpressRequest(request) ? request.originalUrl : request.url;
|
|
143
|
-
const url =
|
|
201
|
+
const url = safeURL(rawUrl);
|
|
144
202
|
return {
|
|
145
203
|
url: url.pathname,
|
|
146
204
|
search: url.search,
|
|
@@ -180,15 +238,6 @@ const getResponseDetails = (response) => {
|
|
|
180
238
|
};
|
|
181
239
|
};
|
|
182
240
|
//#endregion
|
|
183
|
-
//#region src/constants.ts
|
|
184
|
-
const FUNCTION_NOT_FOUND = "Function not found";
|
|
185
|
-
const METHOD_NOT_ALLOWED = "Method Not Allowed";
|
|
186
|
-
const REQUEST_FORBIDDEN = "Forbidden";
|
|
187
|
-
const UNSUPPORTED_MEDIA_TYPE = "Unsupported Media Type";
|
|
188
|
-
const CLIENT_DISCONNECTED = "client disconnected";
|
|
189
|
-
/** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */
|
|
190
|
-
const MIDDLEWARE_NAME_USED = (name) => `The middleware name "${name}" is already used.`;
|
|
191
|
-
//#endregion
|
|
192
241
|
//#region src/express/createMiddleware.ts
|
|
193
242
|
let middlewareCount = 0;
|
|
194
243
|
const middlewareStack = /* @__PURE__ */ new Set();
|
|
@@ -202,7 +251,7 @@ const middlewareStack = /* @__PURE__ */ new Set();
|
|
|
202
251
|
const createMiddleware = (initialOptions = {}) => {
|
|
203
252
|
const options = Object.assign({}, defaultMiddlewareOptions, initialOptions);
|
|
204
253
|
const middlewareName = options.name;
|
|
205
|
-
|
|
254
|
+
let rpcPrefix = options.rpcPrefix;
|
|
206
255
|
const path = options.path;
|
|
207
256
|
const handler = options.handler;
|
|
208
257
|
let name = middlewareName;
|
|
@@ -216,10 +265,15 @@ const createMiddleware = (initialOptions = {}) => {
|
|
|
216
265
|
const pathMatcher = path ? typeof path === "string" ? new RegExp(path) : path : null;
|
|
217
266
|
const middlewareHandler = async (req, res, next) => {
|
|
218
267
|
const { url } = getRequestDetails(req);
|
|
219
|
-
if (serverFunctionsMap.size === 0) await scanForServerFiles();
|
|
220
268
|
if (!handler) return next?.();
|
|
221
269
|
if (pathMatcher && !pathMatcher.test(url)) return next?.();
|
|
222
270
|
if (prefixRegex && !prefixRegex.test(url)) return next?.();
|
|
271
|
+
rpcPrefix = rpcPrefix ?? "__rpc";
|
|
272
|
+
if (getFunctionsForPrefix(rpcPrefix).size === 0) await scanForServerFiles({
|
|
273
|
+
rpcPrefix,
|
|
274
|
+
serverFiles: options.serverFiles,
|
|
275
|
+
scanRoot: options.scanRoot
|
|
276
|
+
});
|
|
223
277
|
await handler(req, res, next);
|
|
224
278
|
};
|
|
225
279
|
Object.defineProperty(middlewareHandler, "name", { value: name });
|
|
@@ -227,16 +281,19 @@ const createMiddleware = (initialOptions = {}) => {
|
|
|
227
281
|
};
|
|
228
282
|
/**
|
|
229
283
|
* Creates the Express RPC middleware that routes incoming requests to registered server functions.
|
|
230
|
-
* Reads the request body, dispatches to the matching function via
|
|
284
|
+
* Reads the request body, dispatches to the matching function via getFunctionsForPrefix,
|
|
231
285
|
* and sends the JSON-serialized result. Handles client disconnection via abort signals.
|
|
232
|
-
*
|
|
286
|
+
* Supports multi-prefix setups where different middleware instances can route to functions
|
|
287
|
+
* registered under different prefixes.
|
|
288
|
+
* @param initialOptions - Options including rpcPrefix for URL routing and prefix-scoped function lookup
|
|
233
289
|
* @returns An Express middleware function
|
|
234
290
|
*/
|
|
235
291
|
const createRPCMiddleware = (initialOptions = {}) => {
|
|
236
292
|
const options = Object.assign({}, defaultMiddlewareOptions, { rpcPrefix: defaultRPCOptions.rpcPrefix }, initialOptions);
|
|
237
293
|
const rpcPrefix = options.rpcPrefix;
|
|
294
|
+
const prefix = rpcPrefix || getGlobalPrefix() || "__rpc";
|
|
238
295
|
const prefixRegex = rpcPrefix ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`) : null;
|
|
239
|
-
const prefixReplace = `/${
|
|
296
|
+
const prefixReplace = `/${prefix}/`;
|
|
240
297
|
return createMiddleware({
|
|
241
298
|
...options,
|
|
242
299
|
handler: async (req, res, _next) => {
|
|
@@ -250,7 +307,7 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
250
307
|
return;
|
|
251
308
|
}
|
|
252
309
|
const functionName = path.replace(prefixReplace, "");
|
|
253
|
-
const serverFunction =
|
|
310
|
+
const serverFunction = getFunctionsForPrefix(prefix).get(functionName);
|
|
254
311
|
if (!serverFunction) {
|
|
255
312
|
sendResponse(404, { error: FUNCTION_NOT_FOUND });
|
|
256
313
|
return;
|
|
@@ -264,7 +321,14 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
264
321
|
let args = [];
|
|
265
322
|
if (method === "GET") {
|
|
266
323
|
const raw = searchParams.get("args");
|
|
267
|
-
if (raw)
|
|
324
|
+
if (raw) {
|
|
325
|
+
const parsed = JSON.parse(raw);
|
|
326
|
+
if (!Array.isArray(parsed)) {
|
|
327
|
+
sendResponse(400, { error: BAD_REQUEST });
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
args = parsed;
|
|
331
|
+
}
|
|
268
332
|
} else {
|
|
269
333
|
if (hasContentTypeMismatch(serverFunction.options?.contentType ?? "application/json", req.headers["content-type"])) {
|
|
270
334
|
sendResponse(415, { error: UNSUPPORTED_MEDIA_TYPE });
|
|
@@ -281,12 +345,23 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
281
345
|
res
|
|
282
346
|
},
|
|
283
347
|
locals: res.locals ?? {},
|
|
348
|
+
functionName,
|
|
284
349
|
redirect: (location, status = 303) => {
|
|
285
350
|
requestEvent.redirected = {
|
|
286
351
|
location,
|
|
287
352
|
status
|
|
288
353
|
};
|
|
289
354
|
redirect(res, location, status);
|
|
355
|
+
},
|
|
356
|
+
send: (status, body, headers) => {
|
|
357
|
+
requestEvent.sent = {
|
|
358
|
+
status,
|
|
359
|
+
body,
|
|
360
|
+
headers
|
|
361
|
+
};
|
|
362
|
+
const details = getResponseDetails(res);
|
|
363
|
+
if (headers) for (const [name, value] of Object.entries(headers)) details.setHeader(name, value);
|
|
364
|
+
details.sendResponse(status, body);
|
|
290
365
|
}
|
|
291
366
|
};
|
|
292
367
|
const { data, cancel } = provideRequestContext(requestEvent, () => serverFunction.handler(...args));
|
|
@@ -294,7 +369,7 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
294
369
|
req.on("close", onClose);
|
|
295
370
|
const result = await data;
|
|
296
371
|
req.off("close", onClose);
|
|
297
|
-
if (!requestEvent.redirected && !res.headersSent) sendResponse(200, { data: result });
|
|
372
|
+
if (!requestEvent.redirected && !requestEvent.sent && !res.headersSent) sendResponse(200, { data: result });
|
|
298
373
|
} catch (err) {
|
|
299
374
|
console.error(String(err));
|
|
300
375
|
const isProduction = process.env.NODE_ENV === "production";
|