@thednp/rpc 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +30 -15
- package/CHANGELOG.md +53 -0
- package/README.md +35 -29
- package/dist/express/express.d.mts +13 -1
- package/dist/express/express.d.mts.map +1 -1
- package/dist/express/express.mjs +51 -60
- package/dist/express/express.mjs.map +1 -1
- package/dist/fastify/fastify.d.mts +16 -1
- package/dist/fastify/fastify.d.mts.map +1 -1
- package/dist/fastify/fastify.mjs +40 -60
- package/dist/fastify/fastify.mjs.map +1 -1
- package/dist/fastify/plugin/fastify/plugin.d.mts +20 -3
- package/dist/fastify/plugin/fastify/plugin.d.mts.map +1 -1
- package/dist/fastify/plugin/fastify/plugin.mjs +39 -59
- package/dist/fastify/plugin/fastify/plugin.mjs.map +1 -1
- package/dist/h3/h3.d.mts +94 -0
- package/dist/h3/h3.d.mts.map +1 -0
- package/dist/h3/h3.mjs +245 -0
- package/dist/h3/h3.mjs.map +1 -0
- package/dist/helpers/helpers.d.mts +2 -0
- package/dist/helpers/helpers.d.mts.map +1 -1
- package/dist/helpers/helpers.mjs.map +1 -1
- package/dist/hono/hono.d.mts +14 -40
- package/dist/hono/hono.d.mts.map +1 -1
- package/dist/hono/hono.mjs +39 -71
- package/dist/hono/hono.mjs.map +1 -1
- package/dist/index.d.mts +36 -5
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +4 -0
- package/dist/index.mjs.map +1 -1
- package/dist/koa/koa.d.mts +12 -1
- package/dist/koa/koa.d.mts.map +1 -1
- package/dist/koa/koa.mjs +46 -61
- package/dist/koa/koa.mjs.map +1 -1
- package/dist/server/server.d.mts +98 -3
- package/dist/server/server.d.mts.map +1 -1
- package/dist/server/server.mjs +79 -1
- package/dist/server/server.mjs.map +1 -1
- package/package.json +10 -4
package/dist/koa/koa.mjs
CHANGED
|
@@ -1,64 +1,13 @@
|
|
|
1
|
-
import { scanForServerFiles, serverFunctionsMap } from "@thednp/rpc/server";
|
|
2
|
-
//#region src/tools.ts
|
|
3
|
-
/**
|
|
4
|
-
* Escapes special regex metacharacters in a string.
|
|
5
|
-
* Used to safely embed user-configurable values (like rpcPrefix) into regular expressions,
|
|
6
|
-
* preventing ReDoS and regex injection attacks.
|
|
7
|
-
* @param s - The raw string to escape
|
|
8
|
-
* @returns The escaped string safe for use in new RegExp()
|
|
9
|
-
*/
|
|
10
|
-
function escapeRegExp(s) {
|
|
11
|
-
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
12
|
-
}
|
|
13
|
-
//#endregion
|
|
1
|
+
import { escapeRegExp, formatError, hasContentTypeMismatch, provideRequestContext, scanForServerFiles, serverFunctionsMap } from "@thednp/rpc/server";
|
|
14
2
|
//#region src/constants.ts
|
|
15
3
|
const FUNCTION_NOT_FOUND = "Function not found";
|
|
16
4
|
const METHOD_NOT_ALLOWED = "Method Not Allowed";
|
|
17
5
|
const REQUEST_FORBIDDEN = "Forbidden";
|
|
18
|
-
const
|
|
6
|
+
const UNSUPPORTED_MEDIA_TYPE = "Unsupported Media Type";
|
|
19
7
|
const CLIENT_DISCONNECTED = "client disconnected";
|
|
20
8
|
/** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */
|
|
21
9
|
const MIDDLEWARE_NAME_USED = (name) => `The middleware name "${name}" is already used.`;
|
|
22
10
|
//#endregion
|
|
23
|
-
//#region src/server-helpers.ts
|
|
24
|
-
/**
|
|
25
|
-
* A typed error thrown from server functions.
|
|
26
|
-
* The middleware serializes the `message` and `code` in the response,
|
|
27
|
-
* allowing clients to recognise and handle specific error conditions.
|
|
28
|
-
*/
|
|
29
|
-
var RPCError = class extends Error {
|
|
30
|
-
/** Machine-readable error code (e.g. "VALIDATION_FAILED", "UNAUTHORIZED") */
|
|
31
|
-
code;
|
|
32
|
-
/** Optional diagnostic payload */
|
|
33
|
-
data;
|
|
34
|
-
constructor(message, code = "INTERNAL", data) {
|
|
35
|
-
super(message);
|
|
36
|
-
this.name = "RPCError";
|
|
37
|
-
this.code = code;
|
|
38
|
-
this.data = data;
|
|
39
|
-
}
|
|
40
|
-
};
|
|
41
|
-
/**
|
|
42
|
-
* Formats an error for the RPC middleware response.
|
|
43
|
-
* In development the full `RPCError` payload is included so developers
|
|
44
|
-
* can quickly identify issues. Unexpected exceptions never expose their
|
|
45
|
-
* message — only the generic "Internal Server Error" is sent, preventing
|
|
46
|
-
* information disclosure; server-side diagnostics are preserved via the
|
|
47
|
-
* middleware's `console.error` logging.
|
|
48
|
-
*/
|
|
49
|
-
const formatError = (err, isProduction) => {
|
|
50
|
-
if (isProduction) return { error: INTERNAL_SERVER_ERROR };
|
|
51
|
-
if (err instanceof RPCError) {
|
|
52
|
-
const payload = {
|
|
53
|
-
error: err.message || "Internal Server Error",
|
|
54
|
-
code: err.code
|
|
55
|
-
};
|
|
56
|
-
if (err.data !== void 0) payload.data = err.data;
|
|
57
|
-
return payload;
|
|
58
|
-
}
|
|
59
|
-
return { error: INTERNAL_SERVER_ERROR };
|
|
60
|
-
};
|
|
61
|
-
//#endregion
|
|
62
11
|
//#region src/options.ts
|
|
63
12
|
const defaultRPCOptions = {
|
|
64
13
|
rpcPrefix: "__rpc",
|
|
@@ -121,11 +70,12 @@ const readBody = (ctx) => {
|
|
|
121
70
|
return new Promise((resolve, reject) => {
|
|
122
71
|
const isJSON = contentType.includes("json");
|
|
123
72
|
const isMultipart = contentType.includes("multipart/form-data");
|
|
73
|
+
const isUrlEncoded = contentType.includes("urlencoded");
|
|
124
74
|
const reqBody = ctx.request.body;
|
|
125
75
|
if (reqBody !== void 0) {
|
|
126
76
|
resolve({
|
|
127
|
-
contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : "text/plain",
|
|
128
|
-
data: isMultipart ? reqBody : isJSON ? reqBody : String(reqBody)
|
|
77
|
+
contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : isUrlEncoded ? "application/x-www-form-urlencoded" : "text/plain",
|
|
78
|
+
data: isMultipart ? reqBody : isJSON ? reqBody : isUrlEncoded ? reqBody : String(reqBody)
|
|
129
79
|
});
|
|
130
80
|
return;
|
|
131
81
|
}
|
|
@@ -143,10 +93,11 @@ const readBody = (ctx) => {
|
|
|
143
93
|
toggleListeners();
|
|
144
94
|
const isJSON = contentType.includes("json");
|
|
145
95
|
const isMultipart = contentType.includes("multipart/form-data");
|
|
96
|
+
const isUrlEncoded = contentType.includes("urlencoded");
|
|
146
97
|
try {
|
|
147
|
-
const data = isMultipart ? { raw: body } : JSON.parse(body);
|
|
98
|
+
const data = isMultipart ? { raw: body } : isUrlEncoded ? Object.fromEntries(new URLSearchParams(body)) : JSON.parse(body);
|
|
148
99
|
resolve({
|
|
149
|
-
contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : "text/plain",
|
|
100
|
+
contentType: isMultipart ? "multipart/form-data" : isJSON ? "application/json" : isUrlEncoded ? "application/x-www-form-urlencoded" : "text/plain",
|
|
150
101
|
data: isMultipart ? data : data
|
|
151
102
|
});
|
|
152
103
|
} catch (_er) {
|
|
@@ -163,6 +114,20 @@ const readBody = (ctx) => {
|
|
|
163
114
|
toggleListeners(true);
|
|
164
115
|
});
|
|
165
116
|
};
|
|
117
|
+
/**
|
|
118
|
+
* Issues an HTTP redirect on a Koa context. Koa's `ctx.redirect(location)`
|
|
119
|
+
* defaults to `302` and sets the `Location` header; the status code must be
|
|
120
|
+
* overridden *after* the call (setting it before is ignored, see
|
|
121
|
+
* koajs/koa#857). Defaults to `303 See Other` for convention
|
|
122
|
+
* (Post/Redirect/Get).
|
|
123
|
+
* @param ctx - Koa context
|
|
124
|
+
* @param location - The URL to redirect to
|
|
125
|
+
* @param status - HTTP status code, defaults to 303
|
|
126
|
+
*/
|
|
127
|
+
const redirect = (ctx, location, status = 303) => {
|
|
128
|
+
ctx.redirect(location);
|
|
129
|
+
ctx.status = status;
|
|
130
|
+
};
|
|
166
131
|
//#endregion
|
|
167
132
|
//#region src/koa/createMiddleware.ts
|
|
168
133
|
let middlewareCount = 0;
|
|
@@ -244,16 +209,36 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
244
209
|
const raw = reqUrl.searchParams.get("args");
|
|
245
210
|
if (raw) args = JSON.parse(raw);
|
|
246
211
|
} else {
|
|
212
|
+
if (hasContentTypeMismatch(serverFunction.options?.contentType ?? "application/json", ctx.headers["content-type"])) {
|
|
213
|
+
ctx.status = 415;
|
|
214
|
+
ctx.body = { error: UNSUPPORTED_MEDIA_TYPE };
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
247
217
|
const body = await readBody(ctx);
|
|
248
218
|
args = Array.isArray(body.data) ? body.data : [body.data];
|
|
249
219
|
}
|
|
250
|
-
const
|
|
220
|
+
const requestEvent = {
|
|
221
|
+
request: ctx.req,
|
|
222
|
+
response: ctx,
|
|
223
|
+
nativeEvent: ctx,
|
|
224
|
+
locals: ctx.state,
|
|
225
|
+
redirect: (location, status = 303) => {
|
|
226
|
+
requestEvent.redirected = {
|
|
227
|
+
location,
|
|
228
|
+
status
|
|
229
|
+
};
|
|
230
|
+
redirect(ctx, location, status);
|
|
231
|
+
}
|
|
232
|
+
};
|
|
233
|
+
const { data: resultData, cancel } = provideRequestContext(requestEvent, () => serverFunction.handler(...args));
|
|
251
234
|
const onClose = () => cancel(CLIENT_DISCONNECTED);
|
|
252
235
|
ctx.req.on("close", onClose);
|
|
253
236
|
const result = await resultData;
|
|
254
237
|
ctx.req.off("close", onClose);
|
|
255
|
-
|
|
256
|
-
|
|
238
|
+
if (!requestEvent.redirected) {
|
|
239
|
+
ctx.status = 200;
|
|
240
|
+
ctx.body = { data: result };
|
|
241
|
+
}
|
|
257
242
|
} catch (err) {
|
|
258
243
|
console.error(String(err));
|
|
259
244
|
const isProduction = process.env.NODE_ENV === "production";
|
|
@@ -264,6 +249,6 @@ const createRPCMiddleware = (initialOptions = {}) => {
|
|
|
264
249
|
});
|
|
265
250
|
};
|
|
266
251
|
//#endregion
|
|
267
|
-
export { attachRPC, attachVite, createMiddleware, createRPCMiddleware, readBody };
|
|
252
|
+
export { attachRPC, attachVite, createMiddleware, createRPCMiddleware, readBody, redirect };
|
|
268
253
|
|
|
269
254
|
//# sourceMappingURL=koa.mjs.map
|
package/dist/koa/koa.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"koa.mjs","names":[],"sources":["../../src/tools.ts","../../src/constants.ts","../../src/server-helpers.ts","../../src/options.ts","../../src/koa/helpers.ts","../../src/koa/createMiddleware.ts"],"sourcesContent":["/**\n * Escapes special regex metacharacters in a string.\n * Used to safely embed user-configurable values (like rpcPrefix) into regular expressions,\n * preventing ReDoS and regex injection attacks.\n * @param s - The raw string to escape\n * @returns The escaped string safe for use in new RegExp()\n */\nexport function escapeRegExp(s: string): string {\n return s.replace(/[.*+?^${}()|[\\]\\\\]/g, \"\\\\$&\");\n}\n","export const OPERATION_ABORTED = \"Operation aborted\";\n\nexport const REQUEST_CANCELLED = \"Request was cancelled\";\n\nexport const FETCH_ERROR_PREFIX = \"Fetch error: \";\n\nexport const NO_SERVER_FUNCTION_FOUND = \"No server function found.\";\n\nexport const ERROR_LOADING_FILE = \"Error loading file:\";\n\nexport const FUNCTION_NOT_FOUND = \"Function not found\";\n\nexport const METHOD_NOT_ALLOWED = \"Method Not Allowed\";\n\nexport const REQUEST_FORBIDDEN = \"Forbidden\";\n\nexport const INTERNAL_SERVER_ERROR = \"Internal Server Error\";\n\nexport const CLIENT_DISCONNECTED = \"client disconnected\";\n\n/** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */\nexport const MIDDLEWARE_NAME_USED = (name: string) =>\n `The middleware name \"${name}\" is already used.`;\n\n/** Error message when a value fails the safe-identifier validation. @param label - What kind of value was being validated. @param name - The rejected value */\nexport const INVALID_IDENTIFIER = (label: string, name: string) =>\n `Invalid ${label}: \"${name}\" must match /^[A-Za-z_$][A-Za-z0-9_$]*$/`;\n\n/** Error message when a value fails the safe-path-segment validation. @param label - What kind of value was being validated. @param segment - The rejected value */\nexport const INVALID_PATH_SEGMENT = (label: string, segment: string) =>\n `Invalid ${label}: \"${segment}\" must match /^[A-Za-z0-9_$][A-Za-z0-9_$/-]*$/`;\n\n/** Warning message when a specified RPC config file cannot be resolved on disk. @param configFile - The requested config filename. @param configFilePath - The resolved absolute path */\nexport const CONFIG_FILE_NOT_FOUND = (\n configFile: string,\n configFilePath: string,\n) =>\n ` ⚠︎ The specified RPC config file ${configFile} cannot be found at ${configFilePath}, loading the defaults..`;\n\nexport const NO_CONFIG_FOUND = ` ⚡︎ No RPC config found, loading the defaults..`;\n\nexport const FAILED_LOAD_CONFIG = ` ⚠︎ Failed to load RPC config:`;\n\n/** Error template for duplicate server function names across files. @param name - The duplicate registered name */\nexport const DUPLICATE_FUNCTION_NAME = (name: string) =>\n `Duplicate server function \"${name}\" detected. Each server function must have a unique name. Remove or rename the duplicate.`;\n","/** @module Server-side utilities. Exports the `RPCError` class for typed server-side errors, `formatError` for middleware error responses, and `walkGlobFiles` for recursively discovering `*.server.*` files. Never import this module in client code — it is server-only. */\nimport type { JsonObject, JsonValue } from \"./types.d.ts\";\nimport { readdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport { INTERNAL_SERVER_ERROR } from \"./constants.ts\";\n\nconst GLOB_REGEX = /^.+\\.server\\.(ts|js|mjs|mts)$/;\n\n/**\n * Recursively walks `dir` and collects absolute paths to files whose\n * basename matches the `*.server.{ts,js,mjs,mts}` glob pattern.\n */\nexport const walkGlobFiles = async (dir: string): Promise<string[]> => {\n const results: string[] = [];\n const stack = [dir];\n while (stack.length) {\n const current = stack.pop()!;\n let entries;\n try {\n entries = await readdir(current, { withFileTypes: true });\n } catch (_e) {\n continue;\n }\n for (const entry of entries) {\n const fullPath = join(current, entry.name);\n if (entry.isFile() && GLOB_REGEX.test(entry.name)) {\n results.push(fullPath);\n } else if (entry.isDirectory()) {\n stack.push(fullPath);\n }\n }\n }\n return results;\n};\n\n/**\n * A typed error thrown from server functions.\n * The middleware serializes the `message` and `code` in the response,\n * allowing clients to recognise and handle specific error conditions.\n */\nexport class RPCError extends Error {\n /** Machine-readable error code (e.g. \"VALIDATION_FAILED\", \"UNAUTHORIZED\") */\n code: string;\n /** Optional diagnostic payload */\n data?: JsonValue;\n constructor(message: string, code = \"INTERNAL\", data?: JsonValue) {\n super(message);\n this.name = \"RPCError\";\n this.code = code;\n this.data = data;\n }\n}\n\n/**\n * Formats an error for the RPC middleware response.\n * In development the full `RPCError` payload is included so developers\n * can quickly identify issues. Unexpected exceptions never expose their\n * message — only the generic \"Internal Server Error\" is sent, preventing\n * information disclosure; server-side diagnostics are preserved via the\n * middleware's `console.error` logging.\n */\nexport const formatError = (\n err: unknown,\n isProduction: boolean,\n): JsonObject => {\n if (isProduction) {\n return { error: INTERNAL_SERVER_ERROR };\n }\n if (err instanceof RPCError) {\n const payload: JsonObject = {\n error: err.message || INTERNAL_SERVER_ERROR,\n code: err.code,\n };\n if (err.data !== undefined) payload.data = err.data;\n return payload;\n }\n return { error: INTERNAL_SERVER_ERROR };\n};\n","import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\nexport const defaultServerFnOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n} satisfies ServerFunctionOptions;\n\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: \"__rpc\",\n adapter: \"express\",\n serverFiles: \"exact\",\n scanRoot: undefined,\n};\n\nexport const defaultMiddlewareOptions = {\n rpcPrefix: undefined,\n path: undefined,\n origin: undefined,\n} satisfies MiddlewareOptions;\n","// src/koa/helpers.ts\nimport type { Buffer } from \"node:buffer\";\nimport type { ViteDevServer } from \"vite\";\nimport { createRPCMiddleware } from \"./createMiddleware.ts\";\nimport type { BodyResult } from \"@thednp/rpc\";\nimport type { Koa } from \"./index.ts\";\nimport type { KoaContext } from \"./types.d.ts\";\n\n/**\n * Convenience function to load RPC config and attach the RPC middleware to a Koa app.\n * Dynamically imports loadRPCConfig and registers the middleware.\n * @param app - Koa application instance\n */\nexport async function attachRPC(app: Koa) {\n // The main plugin entry statically imports Vite, so loadRPCConfig is\n // imported lazily: function bundles that never call attachRPC (e.g.\n // serverless functions) keep Vite out of the bundle (or externalized).\n const { loadRPCConfig } = await import(\"@thednp/rpc\");\n\n const config = await loadRPCConfig();\n const { adapter: _adapter, ...options } = config;\n app.use(createRPCMiddleware(options));\n}\n\n/**\n * Attaches Vite's dev server middlewares to a Koa app for development mode.\n * Bridges Koa's context-based middleware to Vite's Connect-compatible middleware stack\n * by forwarding Koa body, wrapping res.end, and delegating back to Koa on 404 or unhandled routes.\n * @param app - Koa application instance\n * @param vite - Running Vite dev server\n */\nexport function attachVite(app: Koa, vite: ViteDevServer): void {\n app.use(async (ctx: KoaContext, next) => {\n const req = ctx.req;\n const res = ctx.res;\n\n // Forward Koa body to req.body for Express/RPC middleware compatibility\n const requestBody = ctx.request?.body;\n if (requestBody !== undefined) {\n Object.assign(req, { body: requestBody });\n }\n\n const originalEnd = res.end.bind(res);\n let viteHandled = false;\n // @ts-ignore - Koa res.end type mismatch with Node's\n res.end = function (...args: unknown[]) {\n viteHandled = true;\n return originalEnd(args[0]);\n };\n\n await new Promise<void>((resolve) => {\n vite.middlewares(req, res, () => resolve(undefined));\n });\n\n // @ts-ignore - Koa res.end type mismatch with Node's\n res.end = originalEnd;\n\n if (!viteHandled || res.statusCode === 404) {\n await next();\n }\n });\n}\n\n/**\n * Reads and parses the HTTP request body from a Koa context.\n * If koa-body or another body parser already consumed the stream,\n * uses the pre-parsed body from `ctx.request.body`.\n * @param ctx - Koa context\n * @returns A promise resolving to the parsed body with its content type\n */\nexport const readBody = (\n ctx: KoaContext,\n): Promise<BodyResult> => {\n const contentType = ctx.request.headers[\"content-type\"]?.toLowerCase() || \"\";\n\n return new Promise((resolve, reject) => {\n // If an koa-body already consumed the stream\n // via app.use(koaBody()), use ctx.request.body directly\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n const reqBody = ctx.request.body;\n if (reqBody !== undefined) {\n resolve({\n contentType: isMultipart\n ? \"multipart/form-data\"\n : isJSON\n ? \"application/json\"\n : \"text/plain\",\n data: isMultipart\n ? (reqBody as Record<string, unknown>)\n : isJSON\n ? reqBody\n : String(reqBody),\n } as BodyResult);\n return;\n }\n\n // OR read the body normally\n let body = \"\";\n\n const toggleListeners = (add?: boolean) => {\n const method = add ? \"on\" : \"off\";\n ctx.req[method](\"data\", onData);\n ctx.req[method](\"end\", onEnd);\n ctx.req[method](\"error\", onError);\n };\n\n const onData = (chunk: Buffer) => {\n // chunks.push(chunk);\n body += chunk.toString();\n };\n\n const onEnd = () => {\n toggleListeners();\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n try {\n const data = isMultipart ? { raw: body } : JSON.parse(body);\n resolve({\n contentType: isMultipart\n ? \"multipart/form-data\"\n : isJSON\n ? \"application/json\"\n : \"text/plain\",\n data: isMultipart ? (data as Record<string, unknown>) : data,\n } as BodyResult);\n } catch (_er) {\n resolve({ contentType: \"text/plain\", data: String(body) });\n }\n };\n\n const onError = (err: Error) => {\n toggleListeners();\n reject(err);\n };\n\n toggleListeners(true);\n });\n};\n","// src/koa/createMiddleware.ts\nimport type { Context, Next } from \"koa\";\nimport type { KoaMiddlewareFn, KoaMiddlewareOptions } from \"./types.d.ts\";\nimport type { JsonValue } from \"../types.d.ts\";\nimport { escapeRegExp } from \"../tools.ts\";\nimport { formatError } from \"../server-helpers.ts\";\nimport {\n CLIENT_DISCONNECTED,\n FUNCTION_NOT_FOUND,\n METHOD_NOT_ALLOWED,\n MIDDLEWARE_NAME_USED,\n REQUEST_FORBIDDEN,\n} from \"../constants.ts\";\n\nimport { scanForServerFiles, serverFunctionsMap } from \"@thednp/rpc/server\";\nimport { defaultMiddlewareOptions, defaultRPCOptions } from \"../options.ts\";\nimport { readBody } from \"./helpers.ts\";\n\nlet middlewareCount = 0;\nconst middlewareStack = new Set<string>();\n\n/**\n * Creates a Koa middleware with optional path and rpcPrefix filtering.\n * Middleware names are deduplicated. Prefix and path regexes are compiled once at creation time.\n * Koa URL is normalized via `new URL()` to strip query strings before matching.\n * @param initialOptions - Options for rpcPrefix, path matching, and the handler function\n * @returns A Koa middleware function\n */\nexport const createMiddleware: KoaMiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as KoaMiddlewareOptions;\n\n const middlewareName = options.name;\n const rpcPrefix = options.rpcPrefix;\n const path = options.path;\n const handler = options.handler;\n\n let name = middlewareName;\n if (!name) {\n name = \"viteRPCMiddleware-\" + middlewareCount;\n middlewareCount += 1;\n }\n if (middlewareStack.has(name)) {\n throw new Error(MIDDLEWARE_NAME_USED(name));\n }\n middlewareStack.add(name);\n\n // Hoist regex compilation out of per-request path. Escape the prefix to\n // prevent regex injection via metacharacters in the config string.\n const prefixRegex: RegExp | null = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`)\n : null;\n const pathMatcher: RegExp | null = path\n ? (typeof path === \"string\" ? new RegExp(path) : path)\n : null;\n\n const middlewareHandler = async (ctx: Context, next: Next) => {\n const url = new URL(ctx.url, \"http://localhost\").pathname;\n\n if (serverFunctionsMap.size === 0) {\n await scanForServerFiles();\n }\n\n // No need to continue when no handler provided\n if (!handler) {\n return next();\n }\n\n if (pathMatcher && !pathMatcher.test(url)) {\n return next();\n }\n\n if (prefixRegex && !prefixRegex.test(url)) {\n return next();\n }\n\n await handler(ctx, next);\n };\n\n Object.defineProperty(middlewareHandler, \"name\", {\n value: name,\n });\n\n return middlewareHandler;\n};\n\n/**\n * Creates the Koa RPC middleware that routes incoming requests to registered server functions.\n * Wraps the generic createMiddleware with the RPC handler that reads the body, dispatches\n * to the matching function, and sets the JSON-serialized result on ctx.body.\n * @param initialOptions - Options including rpcPrefix for URL routing\n * @returns A Koa middleware function\n */\nexport const createRPCMiddleware: KoaMiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n { rpcPrefix: defaultRPCOptions.rpcPrefix },\n initialOptions,\n ) as KoaMiddlewareOptions;\n\n // Hoist prefix regex (escaped) and the literal prefix-for-replace out of the\n // per-request handler to avoid regex injection and per-request compilation.\n const rpcPrefix = options.rpcPrefix;\n const prefixRegex = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`)\n : /* istanbul ignore next */ null;\n const prefixReplace = `/${rpcPrefix}/`;\n\n return createMiddleware({\n ...options,\n handler: async (ctx: Context, _next: Next) => {\n const reqUrl = new URL(ctx.url, \"http://localhost\");\n const url = reqUrl.pathname;\n // const { rpcPrefix } = options;\n\n // Defense-in-depth: validate prefix match via escaped regex even though\n // the outer createMiddleware gates on the same prefix already.\n // istanbul ignore next\n if (prefixRegex && !prefixRegex.test(url)) {\n return;\n }\n\n // Optional origin check: reject requests whose Origin header does not\n // match the configured origin. Requests without an Origin header\n // (curl, native clients) pass through unchecked.\n const origin = options.origin;\n const requestOrigin = ctx.headers.origin;\n if (origin && requestOrigin && requestOrigin !== origin) {\n ctx.status = 403;\n ctx.body = { error: REQUEST_FORBIDDEN };\n return;\n }\n\n const functionName = url.replace(prefixReplace, \"\");\n const serverFunction = serverFunctionsMap.get(functionName);\n\n if (!serverFunction) {\n ctx.status = 404;\n ctx.body = { error: FUNCTION_NOT_FOUND };\n return;\n }\n\n try {\n const method = serverFunction.options?.method || \"POST\";\n if (ctx.method.toUpperCase() !== method) {\n ctx.status = 405;\n ctx.body = { error: METHOD_NOT_ALLOWED };\n return;\n }\n\n let args: JsonValue[] = [];\n if (method === \"GET\") {\n const raw = reqUrl.searchParams.get(\"args\");\n if (raw) args = JSON.parse(raw);\n } else {\n const body = await readBody(ctx);\n args = Array.isArray(body.data)\n ? body.data as JsonValue[]\n : [body.data as JsonValue];\n }\n const { data: resultData, cancel } = serverFunction.handler(...args);\n const onClose = () => cancel(CLIENT_DISCONNECTED);\n ctx.req.on(\"close\", onClose);\n const result = await resultData;\n ctx.req.off(\"close\", onClose);\n ctx.status = 200;\n ctx.body = { data: result };\n } catch (err) {\n console.error(String(err));\n const isProduction = process.env.NODE_ENV === \"production\";\n ctx.status = 500;\n ctx.body = formatError(err, isProduction);\n }\n },\n });\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"koa.mjs","names":[],"sources":["../../src/constants.ts","../../src/options.ts","../../src/koa/helpers.ts","../../src/koa/createMiddleware.ts"],"sourcesContent":["export const OPERATION_ABORTED = \"Operation aborted\";\n\nexport const REQUEST_CANCELLED = \"Request was cancelled\";\n\nexport const FETCH_ERROR_PREFIX = \"Fetch error: \";\n\nexport const NO_SERVER_FUNCTION_FOUND = \"No server function found.\";\n\nexport const ERROR_LOADING_FILE = \"Error loading file:\";\n\nexport const FUNCTION_NOT_FOUND = \"Function not found\";\n\nexport const METHOD_NOT_ALLOWED = \"Method Not Allowed\";\n\nexport const REQUEST_FORBIDDEN = \"Forbidden\";\n\nexport const UNSUPPORTED_MEDIA_TYPE = \"Unsupported Media Type\";\n\nexport const INTERNAL_SERVER_ERROR = \"Internal Server Error\";\n\nexport const CLIENT_DISCONNECTED = \"client disconnected\";\n\n/** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */\nexport const MIDDLEWARE_NAME_USED = (name: string) =>\n `The middleware name \"${name}\" is already used.`;\n\n/** Error message when a value fails the safe-identifier validation. @param label - What kind of value was being validated. @param name - The rejected value */\nexport const INVALID_IDENTIFIER = (label: string, name: string) =>\n `Invalid ${label}: \"${name}\" must match /^[A-Za-z_$][A-Za-z0-9_$]*$/`;\n\n/** Error message when a value fails the safe-path-segment validation. @param label - What kind of value was being validated. @param segment - The rejected value */\nexport const INVALID_PATH_SEGMENT = (label: string, segment: string) =>\n `Invalid ${label}: \"${segment}\" must match /^[A-Za-z0-9_$][A-Za-z0-9_$/-]*$/`;\n\n/** Warning message when a specified RPC config file cannot be resolved on disk. @param configFile - The requested config filename. @param configFilePath - The resolved absolute path */\nexport const CONFIG_FILE_NOT_FOUND = (\n configFile: string,\n configFilePath: string,\n) =>\n ` ⚠︎ The specified RPC config file ${configFile} cannot be found at ${configFilePath}, loading the defaults..`;\n\nexport const NO_CONFIG_FOUND = ` ⚡︎ No RPC config found, loading the defaults..`;\n\nexport const FAILED_LOAD_CONFIG = ` ⚠︎ Failed to load RPC config:`;\n\n/** Error template for duplicate server function names across files. @param name - The duplicate registered name */\nexport const DUPLICATE_FUNCTION_NAME = (name: string) =>\n `Duplicate server function \"${name}\" detected. Each server function must have a unique name. Remove or rename the duplicate.`;\n","import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\nexport const defaultServerFnOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n} satisfies ServerFunctionOptions;\n\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: \"__rpc\",\n adapter: \"express\",\n serverFiles: \"exact\",\n scanRoot: undefined,\n};\n\nexport const defaultMiddlewareOptions = {\n rpcPrefix: undefined,\n path: undefined,\n origin: undefined,\n} satisfies MiddlewareOptions;\n","// src/koa/helpers.ts\nimport type { Buffer } from \"node:buffer\";\nimport type { ViteDevServer } from \"vite\";\nimport { createRPCMiddleware } from \"./createMiddleware.ts\";\nimport type { BodyResult } from \"@thednp/rpc\";\nimport type { Koa } from \"./index.ts\";\nimport type { KoaContext } from \"./types.d.ts\";\n\n/**\n * Convenience function to load RPC config and attach the RPC middleware to a Koa app.\n * Dynamically imports loadRPCConfig and registers the middleware.\n * @param app - Koa application instance\n */\nexport async function attachRPC(app: Koa) {\n // The main plugin entry statically imports Vite, so loadRPCConfig is\n // imported lazily: function bundles that never call attachRPC (e.g.\n // serverless functions) keep Vite out of the bundle (or externalized).\n const { loadRPCConfig } = await import(\"@thednp/rpc\");\n\n const config = await loadRPCConfig();\n const { adapter: _adapter, ...options } = config;\n app.use(createRPCMiddleware(options));\n}\n\n/**\n * Attaches Vite's dev server middlewares to a Koa app for development mode.\n * Bridges Koa's context-based middleware to Vite's Connect-compatible middleware stack\n * by forwarding Koa body, wrapping res.end, and delegating back to Koa on 404 or unhandled routes.\n * @param app - Koa application instance\n * @param vite - Running Vite dev server\n */\nexport function attachVite(app: Koa, vite: ViteDevServer): void {\n app.use(async (ctx: KoaContext, next) => {\n const req = ctx.req;\n const res = ctx.res;\n\n // Forward Koa body to req.body for Express/RPC middleware compatibility\n const requestBody = ctx.request?.body;\n if (requestBody !== undefined) {\n Object.assign(req, { body: requestBody });\n }\n\n const originalEnd = res.end.bind(res);\n let viteHandled = false;\n // @ts-ignore - Koa res.end type mismatch with Node's\n res.end = function (...args: unknown[]) {\n viteHandled = true;\n return originalEnd(args[0]);\n };\n\n await new Promise<void>((resolve) => {\n vite.middlewares(req, res, () => resolve(undefined));\n });\n\n // @ts-ignore - Koa res.end type mismatch with Node's\n res.end = originalEnd;\n\n if (!viteHandled || res.statusCode === 404) {\n await next();\n }\n });\n}\n\n/**\n * Reads and parses the HTTP request body from a Koa context.\n * If koa-body or another body parser already consumed the stream,\n * uses the pre-parsed body from `ctx.request.body`.\n * @param ctx - Koa context\n * @returns A promise resolving to the parsed body with its content type\n */\nexport const readBody = (\n ctx: KoaContext,\n): Promise<BodyResult> => {\n const contentType = ctx.request.headers[\"content-type\"]?.toLowerCase() || \"\";\n\n return new Promise((resolve, reject) => {\n // If an koa-body already consumed the stream\n // via app.use(koaBody()), use ctx.request.body directly\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n const isUrlEncoded = contentType.includes(\"urlencoded\");\n const reqBody = ctx.request.body;\n if (reqBody !== undefined) {\n resolve({\n contentType: isMultipart\n ? \"multipart/form-data\"\n : isJSON\n ? \"application/json\"\n : isUrlEncoded\n ? \"application/x-www-form-urlencoded\"\n : \"text/plain\",\n data: isMultipart\n ? (reqBody as Record<string, unknown>)\n : isJSON\n ? reqBody\n : isUrlEncoded\n ? (reqBody as Record<string, unknown>)\n : String(reqBody),\n } as BodyResult);\n return;\n }\n\n // OR read the body normally\n let body = \"\";\n\n const toggleListeners = (add?: boolean) => {\n const method = add ? \"on\" : \"off\";\n ctx.req[method](\"data\", onData);\n ctx.req[method](\"end\", onEnd);\n ctx.req[method](\"error\", onError);\n };\n\n const onData = (chunk: Buffer) => {\n // chunks.push(chunk);\n body += chunk.toString();\n };\n\n const onEnd = () => {\n toggleListeners();\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n const isUrlEncoded = contentType.includes(\"urlencoded\");\n try {\n const data = isMultipart\n ? { raw: body }\n : isUrlEncoded\n ? Object.fromEntries(new URLSearchParams(body))\n : JSON.parse(body);\n resolve({\n contentType: isMultipart\n ? \"multipart/form-data\"\n : isJSON\n ? \"application/json\"\n : isUrlEncoded\n ? \"application/x-www-form-urlencoded\"\n : \"text/plain\",\n data: isMultipart ? (data as Record<string, unknown>) : data,\n } as BodyResult);\n } catch (_er) {\n resolve({ contentType: \"text/plain\", data: String(body) });\n }\n };\n\n const onError = (err: Error) => {\n toggleListeners();\n reject(err);\n };\n\n toggleListeners(true);\n });\n};\n\n/**\n * Issues an HTTP redirect on a Koa context. Koa's `ctx.redirect(location)`\n * defaults to `302` and sets the `Location` header; the status code must be\n * overridden *after* the call (setting it before is ignored, see\n * koajs/koa#857). Defaults to `303 See Other` for convention\n * (Post/Redirect/Get).\n * @param ctx - Koa context\n * @param location - The URL to redirect to\n * @param status - HTTP status code, defaults to 303\n */\nexport const redirect = (\n ctx: KoaContext,\n location: string,\n status = 303,\n): void => {\n ctx.redirect(location);\n ctx.status = status;\n};\n","// src/koa/createMiddleware.ts\nimport type { Context, Next } from \"koa\";\nimport type { KoaMiddlewareFn, KoaMiddlewareOptions } from \"./types.d.ts\";\nimport type { JsonValue } from \"@thednp/rpc\";\nimport type { RequestEvent } from \"@thednp/rpc/server\";\nimport {\n escapeRegExp,\n formatError,\n hasContentTypeMismatch,\n provideRequestContext,\n scanForServerFiles,\n serverFunctionsMap,\n} from \"@thednp/rpc/server\";\nimport {\n CLIENT_DISCONNECTED,\n FUNCTION_NOT_FOUND,\n METHOD_NOT_ALLOWED,\n MIDDLEWARE_NAME_USED,\n REQUEST_FORBIDDEN,\n UNSUPPORTED_MEDIA_TYPE,\n} from \"../constants.ts\";\n\nimport { defaultMiddlewareOptions, defaultRPCOptions } from \"../options.ts\";\nimport { readBody, redirect as koaRedirect } from \"./helpers.ts\";\n\nlet middlewareCount = 0;\nconst middlewareStack = new Set<string>();\n\n/**\n * Creates a Koa middleware with optional path and rpcPrefix filtering.\n * Middleware names are deduplicated. Prefix and path regexes are compiled once at creation time.\n * Koa URL is normalized via `new URL()` to strip query strings before matching.\n * @param initialOptions - Options for rpcPrefix, path matching, and the handler function\n * @returns A Koa middleware function\n */\nexport const createMiddleware: KoaMiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as KoaMiddlewareOptions;\n\n const middlewareName = options.name;\n const rpcPrefix = options.rpcPrefix;\n const path = options.path;\n const handler = options.handler;\n\n let name = middlewareName;\n if (!name) {\n name = \"viteRPCMiddleware-\" + middlewareCount;\n middlewareCount += 1;\n }\n if (middlewareStack.has(name)) {\n throw new Error(MIDDLEWARE_NAME_USED(name));\n }\n middlewareStack.add(name);\n\n // Hoist regex compilation out of per-request path. Escape the prefix to\n // prevent regex injection via metacharacters in the config string.\n const prefixRegex: RegExp | null = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`)\n : null;\n const pathMatcher: RegExp | null = path\n ? (typeof path === \"string\" ? new RegExp(path) : path)\n : null;\n\n const middlewareHandler = async (ctx: Context, next: Next) => {\n const url = new URL(ctx.url, \"http://localhost\").pathname;\n\n if (serverFunctionsMap.size === 0) {\n await scanForServerFiles();\n }\n\n // No need to continue when no handler provided\n if (!handler) {\n return next();\n }\n\n if (pathMatcher && !pathMatcher.test(url)) {\n return next();\n }\n\n if (prefixRegex && !prefixRegex.test(url)) {\n return next();\n }\n\n await handler(ctx, next);\n };\n\n Object.defineProperty(middlewareHandler, \"name\", {\n value: name,\n });\n\n return middlewareHandler;\n};\n\n/**\n * Creates the Koa RPC middleware that routes incoming requests to registered server functions.\n * Wraps the generic createMiddleware with the RPC handler that reads the body, dispatches\n * to the matching function, and sets the JSON-serialized result on ctx.body.\n * @param initialOptions - Options including rpcPrefix for URL routing\n * @returns A Koa middleware function\n */\nexport const createRPCMiddleware: KoaMiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n { rpcPrefix: defaultRPCOptions.rpcPrefix },\n initialOptions,\n ) as KoaMiddlewareOptions;\n\n // Hoist prefix regex (escaped) and the literal prefix-for-replace out of the\n // per-request handler to avoid regex injection and per-request compilation.\n const rpcPrefix = options.rpcPrefix;\n const prefixRegex = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`)\n : /* istanbul ignore next */ null;\n const prefixReplace = `/${rpcPrefix}/`;\n\n return createMiddleware({\n ...options,\n handler: async (ctx: Context, _next: Next) => {\n const reqUrl = new URL(ctx.url, \"http://localhost\");\n const url = reqUrl.pathname;\n // const { rpcPrefix } = options;\n\n // Defense-in-depth: validate prefix match via escaped regex even though\n // the outer createMiddleware gates on the same prefix already.\n // istanbul ignore next\n if (prefixRegex && !prefixRegex.test(url)) {\n return;\n }\n\n // Optional origin check: reject requests whose Origin header does not\n // match the configured origin. Requests without an Origin header\n // (curl, native clients) pass through unchecked.\n const origin = options.origin;\n const requestOrigin = ctx.headers.origin;\n if (origin && requestOrigin && requestOrigin !== origin) {\n ctx.status = 403;\n ctx.body = { error: REQUEST_FORBIDDEN };\n return;\n }\n\n const functionName = url.replace(prefixReplace, \"\");\n const serverFunction = serverFunctionsMap.get(functionName);\n\n if (!serverFunction) {\n ctx.status = 404;\n ctx.body = { error: FUNCTION_NOT_FOUND };\n return;\n }\n\n try {\n const method = serverFunction.options?.method || \"POST\";\n if (ctx.method.toUpperCase() !== method) {\n ctx.status = 405;\n ctx.body = { error: METHOD_NOT_ALLOWED };\n return;\n }\n\n let args: JsonValue[] = [];\n if (method === \"GET\") {\n const raw = reqUrl.searchParams.get(\"args\");\n if (raw) args = JSON.parse(raw);\n } else {\n // Content-type enforcement: strict for json/text, lenient between forms.\n // Requests without a Content-Type header are exempt (curl/GET compat).\n // Checked BEFORE readBody so mismatched bodies are never buffered.\n if (\n hasContentTypeMismatch(\n serverFunction.options?.contentType ?? \"application/json\",\n ctx.headers[\"content-type\"],\n )\n ) {\n ctx.status = 415;\n ctx.body = { error: UNSUPPORTED_MEDIA_TYPE };\n return;\n }\n const body = await readBody(ctx);\n args = Array.isArray(body.data)\n ? body.data as JsonValue[]\n : [body.data as JsonValue];\n }\n const requestEvent: RequestEvent = {\n request: ctx.req,\n response: ctx,\n nativeEvent: ctx,\n locals: ctx.state,\n redirect: (location, status = 303) => {\n requestEvent.redirected = { location, status };\n koaRedirect(ctx, location, status);\n },\n };\n const { data: resultData, cancel } = provideRequestContext(\n requestEvent,\n () => serverFunction.handler(...args),\n );\n const onClose = () => cancel(CLIENT_DISCONNECTED);\n ctx.req.on(\"close\", onClose);\n const result = await resultData;\n ctx.req.off(\"close\", onClose);\n\n // Skip the JSON send when the server function issued a redirect;\n // the bound Koa redirect already set ctx.status/ctx.body.\n // istanbul ignore else\n if (!requestEvent.redirected) {\n ctx.status = 200;\n ctx.body = { data: result };\n }\n } catch (err) {\n console.error(String(err));\n const isProduction = process.env.NODE_ENV === \"production\";\n ctx.status = 500;\n ctx.body = formatError(err, isProduction);\n }\n },\n });\n};\n"],"mappings":"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"}
|
package/dist/server/server.d.mts
CHANGED
|
@@ -3,15 +3,17 @@ import "@thednp/rpc";
|
|
|
3
3
|
import "express";
|
|
4
4
|
import "hono";
|
|
5
5
|
import "@hono/node-server";
|
|
6
|
+
import "hono/utils/http-status";
|
|
6
7
|
import "hono/factory";
|
|
7
8
|
import "fastify";
|
|
8
9
|
import "fastify-plugin";
|
|
9
10
|
import "koa";
|
|
11
|
+
import "h3";
|
|
10
12
|
//#region src/types.d.ts
|
|
11
13
|
/**
|
|
12
14
|
* Content types the RPC client modules send with each request.
|
|
13
15
|
*/
|
|
14
|
-
type ContentType = "application/json" | "text/plain" | "multipart/form-data";
|
|
16
|
+
type ContentType = "application/json" | "text/plain" | "application/x-www-form-urlencoded" | "multipart/form-data";
|
|
15
17
|
/**
|
|
16
18
|
* Fetch `credentials` policy used by the generated client modules.
|
|
17
19
|
*/
|
|
@@ -140,7 +142,7 @@ interface RpcPluginOptions {
|
|
|
140
142
|
* also compatible with the vite's Connect development server.
|
|
141
143
|
* @default express
|
|
142
144
|
*/
|
|
143
|
-
adapter: "express" | "hono" | "fastify" | "koa";
|
|
145
|
+
adapter: "express" | "hono" | "h3" | "fastify" | "koa";
|
|
144
146
|
/**
|
|
145
147
|
* Root directory from which the plugin scans for server files.
|
|
146
148
|
* Defaults to `<root>/src/api`. Use this in monorepos where server files
|
|
@@ -225,6 +227,99 @@ declare class RPCError extends Error {
|
|
|
225
227
|
* middleware's `console.error` logging.
|
|
226
228
|
*/
|
|
227
229
|
declare const formatError: (err: unknown, isProduction: boolean) => JsonObject;
|
|
230
|
+
/**
|
|
231
|
+
* Checks whether a content type maps to a form encoding
|
|
232
|
+
* (`multipart/form-data` or `application/x-www-form-urlencoded`).
|
|
233
|
+
* Form-declared functions accept either encoding so native browser
|
|
234
|
+
* submissions (urlencoded) keep working without JavaScript.
|
|
235
|
+
*/
|
|
236
|
+
declare const isFormContentType: (contentType: string) => boolean;
|
|
237
|
+
/**
|
|
238
|
+
* Detects whether an incoming request's `Content-Type` header conflicts
|
|
239
|
+
* with the function's declared content type. JSON and text functions are
|
|
240
|
+
* enforced strictly (exact match wins), while form functions accept both
|
|
241
|
+
* form encodings because the nojs fallback submits urlencoded forms to
|
|
242
|
+
* multipart-declared endpoints. Requests without a `Content-Type` header
|
|
243
|
+
* (curl, GET, legacy clients) are exempt from enforcement.
|
|
244
|
+
* @param declared - The declared `contentType` from the server function options
|
|
245
|
+
* @param rawHeader - The raw `Content-Type` request header, if present
|
|
246
|
+
*/
|
|
247
|
+
declare const hasContentTypeMismatch: (declared: ContentType, rawHeader: string | undefined) => boolean;
|
|
248
|
+
/**
|
|
249
|
+
* Escapes special regex metacharacters in a string.
|
|
250
|
+
* Used to safely embed user-configurable values (like rpcPrefix) into regular expressions,
|
|
251
|
+
* preventing ReDoS and regex injection attacks.
|
|
252
|
+
* @param s - The raw string to escape
|
|
253
|
+
* @returns The escaped string safe for use in new RegExp()
|
|
254
|
+
*/
|
|
255
|
+
declare function escapeRegExp(s: string): string;
|
|
256
|
+
//#endregion
|
|
257
|
+
//#region src/context.d.ts
|
|
258
|
+
/** @module Server-side request context. Exports the `RequestEvent` shape, `provideRequestContext` to establish it around a dispatch, `getRequestContext` to read it from anywhere inside the async tree, and `redirect` for framework-level redirects. Never import this module in client code — it is server-only. */
|
|
259
|
+
/**
|
|
260
|
+
* A per-request context established by the framework adapters around
|
|
261
|
+
* server-function dispatch, mirroring Solid Start's `FetchEvent`. Any code
|
|
262
|
+
* running in the async tree of a dispatch can read the current context through
|
|
263
|
+
* {@link getRequestContext} instead of threading `req`/`res` (or the framework
|
|
264
|
+
* `Context` object) through every nested call. This module is server-only and
|
|
265
|
+
* must never be imported by client code.
|
|
266
|
+
*
|
|
267
|
+
* Each adapter extends this with framework-specific request/response accessors:
|
|
268
|
+
* - Express: `req`/`res` plus `nativeEvent = { req, res }`
|
|
269
|
+
* - Fastify: `request`/`reply` plus `nativeEvent = request`
|
|
270
|
+
* - Koa: `ctx` plus `nativeEvent = ctx`
|
|
271
|
+
* - Hono: `c` (the Hono `Context`) plus `nativeEvent = c`
|
|
272
|
+
* - h3: `event` (the h3 `H3Event`) plus `nativeEvent = event`
|
|
273
|
+
*/
|
|
274
|
+
interface RequestEvent {
|
|
275
|
+
/** Adapter-specific native event kept for deep framework access */
|
|
276
|
+
nativeEvent?: unknown;
|
|
277
|
+
/** Adapter request object */
|
|
278
|
+
request: unknown;
|
|
279
|
+
/** Adapter response object */
|
|
280
|
+
response: unknown;
|
|
281
|
+
/**
|
|
282
|
+
* Bound adapter-native redirect. Performing a redirect sets `redirected`
|
|
283
|
+
* so the middleware can skip the JSON `{ data }` send.
|
|
284
|
+
* @param location - The URL to redirect to
|
|
285
|
+
* @param status - HTTP status code, defaults to `303 See Other`
|
|
286
|
+
*/
|
|
287
|
+
redirect: (location: string, status?: number) => void;
|
|
288
|
+
/**
|
|
289
|
+
* Set by `redirect` once a redirect has been issued. The middleware checks
|
|
290
|
+
* this after `await`ing the server function to avoid double-responding.
|
|
291
|
+
*/
|
|
292
|
+
redirected?: {
|
|
293
|
+
location: string;
|
|
294
|
+
status: number;
|
|
295
|
+
};
|
|
296
|
+
/** Per-request app data shared across the async tree of the dispatch */
|
|
297
|
+
locals: Record<string, unknown>;
|
|
298
|
+
[prop: string]: unknown;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Runs `cb` with `init` as the current request context. Use inside the
|
|
302
|
+
* adapters around server-function dispatch (the async tree under `cb` can then
|
|
303
|
+
* read the context via {@link getRequestContext}).
|
|
304
|
+
* @param init - The request context for the duration of `cb`
|
|
305
|
+
* @param cb - The work that needs access to the request context
|
|
306
|
+
*/
|
|
307
|
+
declare const provideRequestContext: <T>(init: RequestEvent, cb: () => T) => T;
|
|
308
|
+
/**
|
|
309
|
+
* Returns the current request context, or throws when called outside of a
|
|
310
|
+
* request (e.g. module scope or a background task).
|
|
311
|
+
* @throws When no request context is established
|
|
312
|
+
*/
|
|
313
|
+
declare const getRequestContext: () => RequestEvent;
|
|
314
|
+
/**
|
|
315
|
+
* Redirects the current request to `location`. Reads the adapter-bound
|
|
316
|
+
* `redirect` from the current request context — callable from anywhere inside
|
|
317
|
+
* a server-function tree (no `res` threading needed).
|
|
318
|
+
* @param location - The URL to redirect to
|
|
319
|
+
* @param status - HTTP status code, defaults to `303 See Other`
|
|
320
|
+
* @throws When called outside of a request
|
|
321
|
+
*/
|
|
322
|
+
declare const redirect: (location: string, status?: number) => void;
|
|
228
323
|
//#endregion
|
|
229
324
|
//#region src/options.d.ts
|
|
230
325
|
declare const defaultServerFnOptions: {
|
|
@@ -239,5 +334,5 @@ declare const defaultMiddlewareOptions: {
|
|
|
239
334
|
origin: undefined;
|
|
240
335
|
};
|
|
241
336
|
//#endregion
|
|
242
|
-
export { RPCError, createServerFunction, defaultMiddlewareOptions, defaultRPCOptions, defaultServerFnOptions, formatError, getClientModules, scanForServerFiles, scannedServerFiles, serverFunctionsMap, walkGlobFiles };
|
|
337
|
+
export { RPCError, RequestEvent, createServerFunction, defaultMiddlewareOptions, defaultRPCOptions, defaultServerFnOptions, escapeRegExp, formatError, getClientModules, getRequestContext, hasContentTypeMismatch, isFormContentType, provideRequestContext, redirect, scanForServerFiles, scannedServerFiles, serverFunctionsMap, walkGlobFiles };
|
|
243
338
|
//# sourceMappingURL=server.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.mts","names":[],"sources":["../../src/types.d.ts","../../src/functionsMap.ts","../../src/scanForServerFiles.ts","../../src/createFunction.ts","../../src/getClientModules.ts","../../src/server-helpers.ts","../../src/options.ts"],"mappings":"
|
|
1
|
+
{"version":3,"file":"server.d.mts","names":[],"sources":["../../src/types.d.ts","../../src/functionsMap.ts","../../src/scanForServerFiles.ts","../../src/createFunction.ts","../../src/getClientModules.ts","../../src/server-helpers.ts","../../src/context.ts","../../src/options.ts"],"mappings":";;;;;;;;;;;;;;;KAkEY;;;;KASA;;;;;UAkBK;;;;;EAKf,aAAa;;;;;EAKb,cAAc;;;;;;;EAOd;;;;;;KAOU;;;;KAIA;GAAgB,cAAc,YAAY;;;;;KAI1C,aAAa,WAAW;;;;KAIxB,YAAY,gBAAgB,YAAY;;;;;KAsCxC,mBACV,cAAc,WAAW,YAAY,WACrC,gBAAgB,YAAY,cACzB,QAAQ,gBAAgB,MAAM,UAAU,QAAQ;;;;;;KAOzC,eACV,cAAc,YAAY,WAC1B,gBAAgB,YAAY,iBACtB,MAAM;;EAEZ,MAAM,QAAQ;;EAEd,SAAS;;;;;;KAOC,4BAA4B;;EAEtC;;EAEA,UAAU;;;;;UAMK;;EAEf;;EAEA;;;;;KAMU,aAAa,KAAK;EAC5B;EACA,SAAS,QAAQ;EACjB;EACA;;;;;;UAOe;;EAEf;;EAEA,SAAS;;EAET,UAAU;;EAEV;;;;;;;;UASe;;;;;;;;;;;EAWf;;;;;;;EAQA;;;;;;;EAQA;;;;;;;EAQA;;;;cChRW,oBAAoB,YAAY;;;;cCehC,oBAAoB;;;;;;;;;;;;cAepB,qBAAkB,aAChB,YAAU,YACX,kBACX;;;;;;;;;;;;iBCda,qBACd,cAAc,YAAY,WAC1B,gBAAgB,YAAY,WAE5B,cACA,SAAS,mBAAmB,OAAO,UACnC,YAAW,QAAQ,yBAClB,eAAe,OAAO;;;;;;;;;;cCgEZ,mBAAgB,gBACX;;;;;;;cChFL,gBAAa,gBAAwB;;;;;;cA4BrC,iBAAiB;;EAE5B;;EAEA,OAAO;EACP,YAAY,iBAAiB,eAAmB,OAAO;;;;;;;;;;cAgB5C,cAAW,cACV,0BAEX;;;;;;;cAqBU,oBAAiB;;;;;;;;;;;cAcjB,yBAAsB,UACvB,aAAW;;;;;;;;iBAqBP,aAAa;;;;;;;;;;;;;;;;;;;UC9FZ;;EAEf;;EAEA;;EAEA;;;;;;;EAOA,WAAW,kBAAkB;;;;;EAK7B;IAAe;IAAkB;;;EAEjC,QAAQ;GACP;;;;;;;;;cAiBU,wBAAyB,GAAC,MAC/B,cAAY,UACR,MACT;;;;;;cAOU,yBAAwB;;;;;;;;;cAgBxB,WAAQ,kBAAoB;;;cCtF5B;;;;;cAMA,mBAAmB;cAOnB"}
|
package/dist/server/server.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { readdir } from "node:fs/promises";
|
|
2
2
|
import { join, resolve } from "node:path";
|
|
3
3
|
import process from "node:process";
|
|
4
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
4
5
|
//#region src/functionsMap.ts
|
|
5
6
|
const serverFunctionsMap = /* @__PURE__ */ new Map();
|
|
6
7
|
//#endregion
|
|
@@ -78,6 +79,39 @@ const formatError = (err, isProduction) => {
|
|
|
78
79
|
}
|
|
79
80
|
return { error: INTERNAL_SERVER_ERROR };
|
|
80
81
|
};
|
|
82
|
+
/**
|
|
83
|
+
* Checks whether a content type maps to a form encoding
|
|
84
|
+
* (`multipart/form-data` or `application/x-www-form-urlencoded`).
|
|
85
|
+
* Form-declared functions accept either encoding so native browser
|
|
86
|
+
* submissions (urlencoded) keep working without JavaScript.
|
|
87
|
+
*/
|
|
88
|
+
const isFormContentType = (contentType) => contentType === "multipart/form-data" || contentType === "application/x-www-form-urlencoded";
|
|
89
|
+
/**
|
|
90
|
+
* Detects whether an incoming request's `Content-Type` header conflicts
|
|
91
|
+
* with the function's declared content type. JSON and text functions are
|
|
92
|
+
* enforced strictly (exact match wins), while form functions accept both
|
|
93
|
+
* form encodings because the nojs fallback submits urlencoded forms to
|
|
94
|
+
* multipart-declared endpoints. Requests without a `Content-Type` header
|
|
95
|
+
* (curl, GET, legacy clients) are exempt from enforcement.
|
|
96
|
+
* @param declared - The declared `contentType` from the server function options
|
|
97
|
+
* @param rawHeader - The raw `Content-Type` request header, if present
|
|
98
|
+
*/
|
|
99
|
+
const hasContentTypeMismatch = (declared, rawHeader) => {
|
|
100
|
+
if (!rawHeader) return false;
|
|
101
|
+
const incomingType = rawHeader.trim().toLowerCase().split(";")[0].trim();
|
|
102
|
+
if (isFormContentType(declared)) return !isFormContentType(incomingType);
|
|
103
|
+
return incomingType !== declared;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* Escapes special regex metacharacters in a string.
|
|
107
|
+
* Used to safely embed user-configurable values (like rpcPrefix) into regular expressions,
|
|
108
|
+
* preventing ReDoS and regex injection attacks.
|
|
109
|
+
* @param s - The raw string to escape
|
|
110
|
+
* @returns The escaped string safe for use in new RegExp()
|
|
111
|
+
*/
|
|
112
|
+
function escapeRegExp(s) {
|
|
113
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
114
|
+
}
|
|
81
115
|
//#endregion
|
|
82
116
|
//#region src/scanForServerFiles.ts
|
|
83
117
|
let isScanned = false;
|
|
@@ -310,6 +344,10 @@ const getModule = (fnName, fnEntry, options) => {
|
|
|
310
344
|
body = `args[0]`;
|
|
311
345
|
headers = `{ 'Content-Type': 'text/plain' }`;
|
|
312
346
|
break;
|
|
347
|
+
case "application/x-www-form-urlencoded":
|
|
348
|
+
body = `new URLSearchParams(args[0]).toString()`;
|
|
349
|
+
headers = `{ 'Content-Type': 'application/x-www-form-urlencoded' }`;
|
|
350
|
+
break;
|
|
313
351
|
case "multipart/form-data":
|
|
314
352
|
body = `args[0]`;
|
|
315
353
|
headers = `{}`;
|
|
@@ -351,6 +389,46 @@ ${Array.from(serverFunctionsMap.entries()).filter(([, entry]) => entry.exportNam
|
|
|
351
389
|
})).join("\n")}`.trim();
|
|
352
390
|
};
|
|
353
391
|
//#endregion
|
|
354
|
-
|
|
392
|
+
//#region src/context.ts
|
|
393
|
+
/** @module Server-side request context. Exports the `RequestEvent` shape, `provideRequestContext` to establish it around a dispatch, `getRequestContext` to read it from anywhere inside the async tree, and `redirect` for framework-level redirects. Never import this module in client code — it is server-only. */
|
|
394
|
+
/**
|
|
395
|
+
* Global symbol under which the shared `AsyncLocalStorage` instance is stored
|
|
396
|
+
* on `globalThis`. Keeping it on a `Symbol.for` key makes it instance-stable
|
|
397
|
+
* across module copies and dev-server hot reloads, mirroring
|
|
398
|
+
* `solid-js/web`'s own request-context storage.
|
|
399
|
+
*/
|
|
400
|
+
const requestContextSymbol = Symbol.for("thednp.rpc.requestContext");
|
|
401
|
+
const requestContextStorage = globalThis[requestContextSymbol] ??= new AsyncLocalStorage();
|
|
402
|
+
/**
|
|
403
|
+
* Runs `cb` with `init` as the current request context. Use inside the
|
|
404
|
+
* adapters around server-function dispatch (the async tree under `cb` can then
|
|
405
|
+
* read the context via {@link getRequestContext}).
|
|
406
|
+
* @param init - The request context for the duration of `cb`
|
|
407
|
+
* @param cb - The work that needs access to the request context
|
|
408
|
+
*/
|
|
409
|
+
const provideRequestContext = (init, cb) => requestContextStorage.run(init, cb);
|
|
410
|
+
/**
|
|
411
|
+
* Returns the current request context, or throws when called outside of a
|
|
412
|
+
* request (e.g. module scope or a background task).
|
|
413
|
+
* @throws When no request context is established
|
|
414
|
+
*/
|
|
415
|
+
const getRequestContext = () => {
|
|
416
|
+
const ctx = requestContextStorage.getStore();
|
|
417
|
+
if (!ctx) throw new Error("RequestEvent is not available outside of a request");
|
|
418
|
+
return ctx;
|
|
419
|
+
};
|
|
420
|
+
/**
|
|
421
|
+
* Redirects the current request to `location`. Reads the adapter-bound
|
|
422
|
+
* `redirect` from the current request context — callable from anywhere inside
|
|
423
|
+
* a server-function tree (no `res` threading needed).
|
|
424
|
+
* @param location - The URL to redirect to
|
|
425
|
+
* @param status - HTTP status code, defaults to `303 See Other`
|
|
426
|
+
* @throws When called outside of a request
|
|
427
|
+
*/
|
|
428
|
+
const redirect = (location, status = 303) => {
|
|
429
|
+
getRequestContext().redirect(location, status);
|
|
430
|
+
};
|
|
431
|
+
//#endregion
|
|
432
|
+
export { RPCError, createServerFunction, defaultMiddlewareOptions, defaultRPCOptions, defaultServerFnOptions, escapeRegExp, formatError, getClientModules, getRequestContext, hasContentTypeMismatch, isFormContentType, provideRequestContext, redirect, scanForServerFiles, scannedServerFiles, serverFunctionsMap, walkGlobFiles };
|
|
355
433
|
|
|
356
434
|
//# sourceMappingURL=server.mjs.map
|