@thednp/rpc 0.3.6 → 0.3.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/AGENTS.md +25 -7
  2. package/CHANGELOG.md +116 -0
  3. package/README.md +2 -2
  4. package/dist/config/config.d.mts +0 -7
  5. package/dist/config/config.d.mts.map +1 -1
  6. package/dist/config/config.mjs +5 -1
  7. package/dist/config/config.mjs.map +1 -1
  8. package/dist/express/express.d.mts +2 -9
  9. package/dist/express/express.d.mts.map +1 -1
  10. package/dist/express/express.mjs +70 -22
  11. package/dist/express/express.mjs.map +1 -1
  12. package/dist/fastify/fastify.d.mts +2 -8
  13. package/dist/fastify/fastify.d.mts.map +1 -1
  14. package/dist/fastify/fastify.mjs +72 -22
  15. package/dist/fastify/fastify.mjs.map +1 -1
  16. package/dist/fastify/plugin/fastify/plugin.d.mts +13 -49
  17. package/dist/fastify/plugin/fastify/plugin.d.mts.map +1 -1
  18. package/dist/fastify/plugin/fastify/plugin.mjs +71 -21
  19. package/dist/fastify/plugin/fastify/plugin.mjs.map +1 -1
  20. package/dist/h3/h3.d.mts +2 -2
  21. package/dist/h3/h3.d.mts.map +1 -1
  22. package/dist/h3/h3.mjs +68 -21
  23. package/dist/h3/h3.mjs.map +1 -1
  24. package/dist/helpers/helpers.d.mts.map +1 -1
  25. package/dist/helpers/helpers.mjs +2 -0
  26. package/dist/helpers/helpers.mjs.map +1 -1
  27. package/dist/hono/hono.d.mts +2 -2
  28. package/dist/hono/hono.d.mts.map +1 -1
  29. package/dist/hono/hono.mjs +93 -23
  30. package/dist/hono/hono.mjs.map +1 -1
  31. package/dist/index.d.mts +26 -19
  32. package/dist/index.d.mts.map +1 -1
  33. package/dist/index.mjs +62 -28
  34. package/dist/index.mjs.map +1 -1
  35. package/dist/koa/koa.d.mts +2 -9
  36. package/dist/koa/koa.d.mts.map +1 -1
  37. package/dist/koa/koa.mjs +74 -22
  38. package/dist/koa/koa.mjs.map +1 -1
  39. package/dist/server/server.d.mts +107 -13
  40. package/dist/server/server.d.mts.map +1 -1
  41. package/dist/server/server.mjs +146 -16
  42. package/dist/server/server.mjs.map +1 -1
  43. package/llms.txt +4 -2
  44. package/package.json +5 -3
@@ -1 +1 @@
1
- {"version":3,"file":"plugin.mjs","names":[],"sources":["../../../../src/options.ts","../../../../src/functionsMap.ts","../../../../src/constants.ts","../../../../src/fastify/helpers.ts","../../../../src/fastify/createMiddleware.ts","../../../../src/fastify/plugin.ts"],"sourcesContent":["import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\nexport const defaultServerFnOptions: ServerFunctionOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n};\n\nexport const defaultPrefix = \"__rpc\";\n\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: defaultPrefix,\n adapter: \"express\",\n serverFiles: \"exact\",\n scanRoot: undefined,\n};\n\nexport const defaultMiddlewareOptions: MiddlewareOptions = {\n rpcPrefix: undefined,\n path: undefined,\n origin: undefined,\n};\n","import type { ServerFnEntry } from \"./types.d.ts\";\nimport { defaultPrefix } from \"./options.ts\";\n\n/**\n * Global symbol under which the shared `serverFunctionsByPrefix` map is stored\n * on `globalThis`. Keeping it on a `Symbol.for` key makes it instance-stable\n * across the bundled entry copies (`index.mjs`, `server.mjs`, `express.mjs`,\n * ...) and dev-server hot reloads, exactly like the request-context storage in\n * `context.ts`. Without this, `scanForServerFiles` (bundled into the plugin)\n * would populate a map copy the adapter middleware could not read.\n */\nconst functionsMapSymbol = Symbol.for(\"thednp.rpc.functionsMap\");\n\n/**\n * Map of rpcPrefix -> Map of function names -> ServerFnEntry\n * Enables multiple RPC instances with different prefixes to coexist\n * without name collisions.\n */\nexport const serverFunctionsByPrefix: Map<\n string,\n Map<string, ServerFnEntry>\n> = ((globalThis as Record<symbol, Map<string, Map<string, ServerFnEntry>>>)[\n functionsMapSymbol\n] ??= new Map());\n\n/**\n * Gets or creates the function map for a specific prefix.\n * @param prefix - The RPC prefix (e.g., \"__rpc\", \"v1:rpc\", \"admin:rpc\")\n * @returns Map of function names to ServerFnEntry for that prefix\n */\nexport const getFunctionsForPrefix = (\n prefix: string,\n): Map<string, ServerFnEntry> => {\n if (!serverFunctionsByPrefix.has(prefix)) {\n serverFunctionsByPrefix.set(prefix, new Map());\n }\n return serverFunctionsByPrefix.get(prefix)!;\n};\n\n/**\n * Backward compatibility: default map for the default prefix.\n * Legacy code can still use serverFunctionsMap.set(name, entry).\n */\nexport const serverFunctionsMap: Map<string, ServerFnEntry> = {\n get: (key: string) => getFunctionsForPrefix(defaultPrefix).get(key),\n set: (key: string, value: ServerFnEntry) =>\n getFunctionsForPrefix(defaultPrefix).set(key, value),\n has: (key: string) => getFunctionsForPrefix(defaultPrefix).has(key),\n delete: (key: string) => getFunctionsForPrefix(defaultPrefix).delete(key),\n clear: () => getFunctionsForPrefix(defaultPrefix).clear(),\n get size() {\n return getFunctionsForPrefix(defaultPrefix).size;\n },\n entries: () => getFunctionsForPrefix(defaultPrefix).entries(),\n keys: () => getFunctionsForPrefix(defaultPrefix).keys(),\n values: () => getFunctionsForPrefix(defaultPrefix).values(),\n forEach: (\n callback: (\n value: ServerFnEntry,\n key: string,\n map: Map<string, ServerFnEntry>,\n ) => void,\n ) => getFunctionsForPrefix(defaultPrefix).forEach(callback),\n [Symbol.iterator]: () =>\n getFunctionsForPrefix(defaultPrefix)[Symbol.iterator](),\n} as unknown as Map<string, ServerFnEntry>;\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 UNSUPPORTED_MEDIA_TYPE = \"Unsupported Media Type\";\n\nexport const BAD_REQUEST = \"Bad Request\";\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 =\n ` ⚡︎ 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","// @thednp/rpc/src/fastify/helpers.ts\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport type { ViteDevServer } from \"vite\";\nimport type { FastifyInstance } from \"fastify\";\nimport type { Buffer } from \"node:buffer\";\nimport type { BodyResult, JsonValue } from \"../types.d.ts\";\nimport fastifyRpcPlugin from \"./plugin.ts\";\n\n/**\n * Convenience function to load RPC config and register the RPC plugin to a Fastify instance.\n * Dynamically imports loadRPCConfig and registers the fastify-rpc plugin.\n * @param app - Fastify instance\n */\nexport async function attachRPC(app: FastifyInstance) {\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 const { adapter: _adapter, ...options } = await loadRPCConfig();\n await app.register(fastifyRpcPlugin, options);\n}\n\n/**\n * Attaches Vite's dev server middlewares to a Fastify instance for development mode.\n * Uses an `onRequest` hook to delegate to Vite's connect-compatible middleware stack.\n * @param app - Fastify instance\n * @param vite - Running Vite dev server\n */\nexport function attachVite(app: FastifyInstance, vite: ViteDevServer) {\n app.addHook(\"onRequest\", async (request, reply) => {\n const next = () =>\n new Promise((resolve) => {\n vite.middlewares(request.raw, reply.raw, resolve);\n });\n await next();\n });\n}\n\n/**\n * Creates a Fastify `onRequest` hook handler that delegates to Vite's\n * connect-compatible middleware stack. Use with `app.addHook(\"onRequest\", ...)`.\n *\n * @example\n * ```ts\n * import Fastify from \"fastify\";\n * import { createServer } from \"vite\";\n * import { viteMiddleware } from \"@thednp/rpc/fastify\";\n *\n * const app = Fastify();\n * const vite = await createServer({ server: { middlewareMode: true } });\n * app.addHook(\"onRequest\", viteMiddleware(vite));\n * ```\n * @param vite - Running Vite dev server\n * @returns A Fastify `onRequest` hook handler\n */\nexport function viteMiddleware(\n vite: ViteDevServer,\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async (request, reply) => {\n reply.hijack();\n await new Promise<void>((resolve, reject) => {\n const next = (err?: unknown) => (err ? reject(err) : resolve());\n vite.middlewares(request.raw, reply.raw, next);\n });\n };\n}\n\n/**\n * Reads and parses the HTTP request body from a Fastify request.\n * If Fastify's body parser already consumed the stream, uses the pre-parsed body from `req.body`.\n * @param req - Fastify request object\n * @returns A promise resolving to the parsed body with its content type\n */\nexport const readBody = (\n req: FastifyRequest,\n): Promise<BodyResult> => {\n return new Promise((resolve, reject) => {\n const contentType = req.headers[\"content-type\"]?.toLowerCase() || \"\";\n const reqBody = req.body as JsonValue;\n\n if (reqBody !== undefined) {\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n const isUrlEncoded = contentType.includes(\"urlencoded\");\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 const toggleListeners = (add?: boolean) => {\n const method = add ? \"on\" : \"off\";\n req.raw[method](\"data\", onData);\n req.raw[method](\"end\", onEnd);\n req.raw[method](\"error\", onError);\n };\n\n let body = \"\";\n\n const onData = (chunk: Buffer) => {\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 (_e) {\n resolve({ contentType: \"text/plain\", data: String(body) });\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 Fastify reply using the native\n * `reply.redirect(location, status)` API (Fastify v5 signature: destination\n * URL first, status code optional). Defaults to `303 See Other` for\n * convention (Post/Redirect/Get).\n * @param reply - Fastify reply object\n * @param location - The URL to redirect to\n * @param status - HTTP status code, defaults to 303\n */\nexport const redirect = (\n reply: FastifyReply,\n location: string,\n status = 303,\n): void => {\n reply.redirect(location, status);\n};\n","// src/fastify/createMiddleware.ts\nimport type {\n FastifyReply,\n FastifyRequest,\n HookHandlerDoneFunction,\n} from \"fastify\";\nimport type {\n FastifyMiddlewareFn,\n FastifyMiddlewareOptions,\n} from \"./types.d.ts\";\nimport type { JsonValue } from \"@thednp/rpc\";\nimport type { RequestEvent } from \"@thednp/rpc/server\";\nimport {\n escapeRegExp,\n formatError,\n getGlobalPrefix,\n hasContentTypeMismatch,\n isOriginRequestAllowed,\n provideRequestContext,\n safeURL,\n scanForServerFiles,\n} from \"@thednp/rpc/server\";\nimport { getFunctionsForPrefix } from \"../functionsMap.ts\";\nimport {\n defaultMiddlewareOptions,\n defaultPrefix,\n defaultRPCOptions,\n} from \"../options.ts\";\nimport {\n BAD_REQUEST,\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\";\nimport { readBody, redirect as fastifyRedirect } from \"./helpers.ts\";\n\nlet middlewareCount = 0;\nconst middlewareStack = new Set<string>();\n\n/**\n * Creates a Fastify preHandler hook with optional path and rpcPrefix filtering.\n * Middleware names are deduplicated. Prefix and path regexes are compiled once at creation time.\n * @param initialOptions - Options for rpcPrefix, path matching, and the handler function\n * @returns A Fastify preHandler hook function\n */\nexport const createMiddleware: FastifyMiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as FastifyMiddlewareOptions;\n\n const middlewareName = options.name;\n let 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 (\n req: FastifyRequest,\n reply: FastifyReply,\n done: HookHandlerDoneFunction,\n ) => {\n const reqUrl = safeURL(req.url);\n const url = reqUrl.pathname;\n\n // No need to continue when no handler provided\n if (!handler) {\n done();\n return;\n }\n\n // Path matching\n if (pathMatcher && !pathMatcher.test(url)) {\n done();\n return;\n }\n\n // rpcPrefix matching (boundary-safe via escaped regex)\n if (prefixRegex && !prefixRegex.test(url)) {\n done();\n return;\n }\n\n rpcPrefix = (rpcPrefix ?? defaultPrefix) as string;\n\n // When serving from production server, scan for server files\n if (getFunctionsForPrefix(rpcPrefix).size === 0) {\n await scanForServerFiles({\n rpcPrefix,\n serverFiles: (options as unknown as { serverFiles?: \"exact\" | \"glob\" })\n .serverFiles,\n scanRoot: (options as unknown as { scanRoot?: string }).scanRoot,\n } as never);\n }\n\n // Execute handler\n await handler(req, reply, done);\n };\n\n Object.defineProperty(middlewareHandler, \"name\", {\n value: name,\n });\n\n return middlewareHandler;\n};\n\n/**\n * Creates the Fastify 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 sends the JSON-serialized result.\n * @param initialOptions - Options including rpcPrefix for URL routing\n * @returns A Fastify preHandler hook function\n */\nexport const createRPCMiddleware: FastifyMiddlewareFn = (\n initialOptions = {},\n) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n { rpcPrefix: defaultRPCOptions.rpcPrefix },\n initialOptions,\n ) as FastifyMiddlewareOptions;\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 prefix = rpcPrefix || getGlobalPrefix() || defaultPrefix;\n const prefixRegex = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`)\n : /* istanbul ignore next */ null;\n const prefixReplace = `/${prefix}/`;\n\n return createMiddleware({\n ...options,\n handler: async (\n req: FastifyRequest,\n reply: FastifyReply,\n _done: HookHandlerDoneFunction,\n ) => {\n const reqUrl = safeURL(req.url);\n const url = reqUrl.pathname;\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 if\n if (prefixRegex && !prefixRegex.test(url)) {\n return;\n }\n\n // Optional origin check. When Origin survives, the allowlist decides;\n // when it has been stripped, Sec-Fetch-Site is consulted instead and\n // fails closed. See `isOriginRequestAllowed` for the four tiers.\n if (\n !isOriginRequestAllowed(\n options.origin,\n req.headers.origin,\n req.headers[\"sec-fetch-site\"],\n )\n ) {\n reply.status(403).send({ error: REQUEST_FORBIDDEN });\n return;\n }\n\n const functionName = url.replace(prefixReplace, \"\");\n const serverFunction = getFunctionsForPrefix(prefix).get(functionName);\n\n if (!serverFunction) {\n reply.status(404).send({\n error: FUNCTION_NOT_FOUND,\n });\n return;\n }\n\n try {\n const method = serverFunction.options?.method || \"POST\";\n if (req.method.toUpperCase() !== method) {\n reply.status(405).send({ 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) {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) {\n reply.status(400).send({ error: BAD_REQUEST });\n return;\n }\n args = parsed as JsonValue[];\n }\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 req.headers[\"content-type\"],\n )\n ) {\n reply.status(415).send({ error: UNSUPPORTED_MEDIA_TYPE });\n return;\n }\n const body = await readBody(req);\n args = Array.isArray(body.data)\n ? body.data as JsonValue[]\n : [body.data as JsonValue];\n }\n const requestEvent: RequestEvent = {\n request: req,\n response: reply,\n nativeEvent: req,\n locals: {},\n functionName,\n redirect: (location, status = 303) => {\n requestEvent.redirected = { location, status };\n fastifyRedirect(reply, location, status);\n },\n send: (status, body, headers) => {\n requestEvent.sent = { status, body, headers };\n if (headers) {\n for (const [name, value] of Object.entries(headers)) {\n reply.header(name, value);\n }\n }\n reply.status(status).send(body);\n },\n };\n const { data: dataResult, cancel } = provideRequestContext(\n requestEvent,\n () => serverFunction.handler(...args),\n );\n const onClose = () => cancel(CLIENT_DISCONNECTED);\n\n req.raw.on(\"close\", onClose);\n const data = await dataResult;\n req.raw.off(\"close\", onClose);\n\n // istanbul ignore else\n if (\n !requestEvent.redirected &&\n !requestEvent.sent &&\n !reply.raw.headersSent\n ) {\n reply.status(200).send({ data });\n }\n } catch (err) {\n console.error(String(err));\n const isProduction = process.env.NODE_ENV === \"production\";\n reply.status(500).send(formatError(err, isProduction));\n }\n },\n });\n};\n","/** @module Fastify plugin. Exports the RPC plugin wrapped with `fastify-plugin` for lifecycle-compatible registration. */\n// inspired by https://github.com/royalswe/vike-fastify-boilerplate/blob/main/server/index.ts\nimport fp from \"fastify-plugin\";\nimport type { MiddlewareOptions } from \"../types.d.ts\";\nimport type {\n FastifyRPCPlugin,\n RegisteredFastifyRPCPlugin,\n} from \"./types.d.ts\";\nimport { createRPCMiddleware } from \"./createMiddleware.ts\";\n\nexport type { MiddlewareOptions };\n\nconst RpcPlugin: FastifyRPCPlugin = (\n fastify,\n initialOptions,\n done,\n) => {\n // Register RPC middleware as preHandler hook\n const rpcMiddleware = createRPCMiddleware(initialOptions);\n fastify.addHook(\"preHandler\", async (request, reply) => {\n const next = () =>\n new Promise((resolve) => {\n rpcMiddleware(request, reply, resolve);\n });\n await next();\n });\n\n done();\n};\n\n// Export the plugin wrapped with fastify-plugin\nconst rpcPlugin = fp(RpcPlugin, {\n name: \"uni-rpc-fastify-plugin\",\n}) as RegisteredFastifyRPCPlugin;\n\nexport { rpcPlugin as default };\n"],"mappings":"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"}
1
+ {"version":3,"file":"plugin.mjs","names":[],"sources":["../../../../src/options.ts","../../../../src/functionsMap.ts","../../../../src/constants.ts","../../../../src/server-helpers.ts","../../../../src/fastify/helpers.ts","../../../../src/fastify/createMiddleware.ts","../../../../src/fastify/plugin.ts"],"sourcesContent":["import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\n/**\n * Defaults applied to a server function that declares no `method`,\n * `credentials`, or `contentType` of its own.\n */\nexport const defaultServerFnOptions: ServerFunctionOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n};\n\n/**\n * The built-in RPC endpoint prefix, used when neither an explicit prefix nor a\n * global one (`getGlobalPrefix`) is supplied. Kept for backward compatibility\n * with pre-multi-prefix setups, where every function lived under this one map.\n */\nexport const defaultPrefix = \"__rpc\";\n\n/**\n * Baseline plugin options. `defineConfig` merges a user's partial config over\n * these, and `loadRPCConfig` merges a loaded config file over them, so every\n * option has a defined value even when a config file omits it.\n */\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: defaultPrefix,\n serverFiles: \"exact\",\n scanRoot: undefined,\n};\n\n/**\n * Baseline middleware options. Note `rpcPrefix` is `undefined` rather than\n * `defaultPrefix` on purpose: leaving it unset lets `resolveRPCPrefix` fall\n * through to the global prefix, which is what makes a published global prefix\n * reach the middleware.\n */\nexport const defaultMiddlewareOptions: MiddlewareOptions = {\n rpcPrefix: undefined,\n path: undefined,\n origin: undefined,\n};\n","import type { ServerFnEntry } from \"./types.d.ts\";\nimport { defaultPrefix } from \"./options.ts\";\n\n/**\n * Global symbol under which the shared `serverFunctionsByPrefix` map is stored\n * on `globalThis`. Keeping it on a `Symbol.for` key makes it instance-stable\n * across the bundled entry copies (`index.mjs`, `server.mjs`, `express.mjs`,\n * ...) and dev-server hot reloads, exactly like the request-context storage in\n * `context.ts`. Without this, `scanForServerFiles` (bundled into the plugin)\n * would populate a map copy the adapter middleware could not read.\n */\nconst functionsMapSymbol = Symbol.for(\"thednp.rpc.functionsMap\");\n\n/**\n * Map of rpcPrefix -> Map of function names -> ServerFnEntry\n * Enables multiple RPC instances with different prefixes to coexist\n * without name collisions.\n */\nexport const serverFunctionsByPrefix: Map<\n string,\n Map<string, ServerFnEntry>\n> = ((globalThis as Record<symbol, Map<string, Map<string, ServerFnEntry>>>)[\n functionsMapSymbol\n] ??= new Map());\n\n/**\n * Gets or creates the function map for a specific prefix.\n * @param prefix - The RPC prefix (e.g., \"__rpc\", \"v1:rpc\", \"admin:rpc\")\n * @returns Map of function names to ServerFnEntry for that prefix\n */\nexport const getFunctionsForPrefix = (\n prefix: string,\n): Map<string, ServerFnEntry> => {\n if (!serverFunctionsByPrefix.has(prefix)) {\n serverFunctionsByPrefix.set(prefix, new Map());\n }\n return serverFunctionsByPrefix.get(prefix)!;\n};\n\n/**\n * Backward compatibility: default map for the default prefix.\n * Legacy code can still use serverFunctionsMap.set(name, entry).\n */\nexport const serverFunctionsMap: Map<string, ServerFnEntry> = {\n get: (key: string) => getFunctionsForPrefix(defaultPrefix).get(key),\n set: (key: string, value: ServerFnEntry) =>\n getFunctionsForPrefix(defaultPrefix).set(key, value),\n has: (key: string) => getFunctionsForPrefix(defaultPrefix).has(key),\n delete: (key: string) => getFunctionsForPrefix(defaultPrefix).delete(key),\n clear: () => getFunctionsForPrefix(defaultPrefix).clear(),\n get size() {\n return getFunctionsForPrefix(defaultPrefix).size;\n },\n entries: () => getFunctionsForPrefix(defaultPrefix).entries(),\n keys: () => getFunctionsForPrefix(defaultPrefix).keys(),\n values: () => getFunctionsForPrefix(defaultPrefix).values(),\n forEach: (\n callback: (\n value: ServerFnEntry,\n key: string,\n map: Map<string, ServerFnEntry>,\n ) => void,\n ) => getFunctionsForPrefix(defaultPrefix).forEach(callback),\n [Symbol.iterator]: () =>\n getFunctionsForPrefix(defaultPrefix)[Symbol.iterator](),\n} as unknown as Map<string, ServerFnEntry>;\n","/**\n * @module User-facing message strings.\n *\n * Two shapes live here: plain message constants (the exact text an RPC\n * response body carries) and message *factories* for the cases that need a\n * value interpolated. Both are part of the wire contract for the bodies below,\n * so the casing is deliberate — e.g. a client matching on\n * `METHOD_NOT_ALLOWED` must see `\"Method Not Allowed\"`, not `\"Method not\n * allowed\"`. These strings are also what keeps error responses generic: they\n * never include the requested function name, so a response cannot be used to\n * enumerate what exists.\n */\n/** Thrown-name for an operation stopped by its own `cancel()`. */\nexport const OPERATION_ABORTED = \"Operation aborted\";\n\n/** Warning text used when a request is cancelled by an HTTP 408/499 response. */\nexport const REQUEST_CANCELLED = \"Request was cancelled\";\n\n/** Prefix of the `Error` message the client helpers throw for a non-OK HTTP response. The status text is appended; the response body is deliberately not read, so server-side detail never reaches the client through this path. */\nexport const FETCH_ERROR_PREFIX = \"Fetch error: \";\n\n/** Warning logged when a scanned server module exports nothing. */\nexport const NO_SERVER_FUNCTION_FOUND = \"No server function found.\";\n\n/** Error logged when a server function file cannot be loaded by Vite's SSR loader. */\nexport const ERROR_LOADING_FILE = \"Error loading file:\";\n\n/** Body of a 404. Deliberately does not name the requested function. */\nexport const FUNCTION_NOT_FOUND = \"Function not found\";\n\n/** Body of a 405, returned when the HTTP method does not match the function's declared method. */\nexport const METHOD_NOT_ALLOWED = \"Method Not Allowed\";\n\n/** Body of a 403, returned when the optional origin allowlist rejects the request. */\nexport const REQUEST_FORBIDDEN = \"Forbidden\";\n\n/** Body of a 415, returned when the request's `Content-Type` does not satisfy the function's declared `contentType`. */\nexport const UNSUPPORTED_MEDIA_TYPE = \"Unsupported Media Type\";\n\n/** Body of a 413, returned when the request body exceeds the host's configured size limit. */\nexport const PAYLOAD_TOO_LARGE = \"Payload Too Large\";\n\n/** Body of a 400, returned when a GET `?args=` value parses but is not an array. */\nexport const BAD_REQUEST = \"Bad Request\";\n\n/** Body of a 500. Always generic — never the underlying error, so internals cannot leak. */\nexport const INTERNAL_SERVER_ERROR = \"Internal Server Error\";\n\n/** Abort reason used when the client disconnects mid-dispatch. */\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\n/** Warning logged when no config file is discovered and the defaults are used. */\nexport const NO_CONFIG_FOUND =\n ` ⚡︎ No RPC config found, loading the defaults..`;\n\n/** Warning logged when a config file exists but could not be loaded; the defaults are used. */\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, `isFormContentType` and `hasContentTypeMismatch` for content-type validation, and `walkGlobFiles` for recursively discovering `*.server.*` files. Never import this module in client code — it is server-only. */\nimport type { ContentType, JsonObject, JsonValue } from \"./types.d.ts\";\nimport { readdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport {\n BAD_REQUEST,\n INTERNAL_SERVER_ERROR,\n PAYLOAD_TOO_LARGE,\n UNSUPPORTED_MEDIA_TYPE,\n} from \"./constants.ts\";\nimport { defaultPrefix } from \"./options.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\n/**\n * Checks whether a content type maps to a form encoding\n * (`multipart/form-data` or `application/x-www-form-urlencoded`).\n * Form-declared functions accept either encoding so native browser\n * submissions (urlencoded) keep working without JavaScript.\n */\n/**\n * An error carrying an HTTP status, so the dispatch can answer that status\n * instead of flattening every failure to a `500`.\n */\nexport interface ClientHttpError extends Error {\n status?: number;\n statusCode?: number;\n}\n\n/**\n * Tags an error with an HTTP status for the dispatch to surface.\n *\n * Used where a malformed *request* is the fault — a body that does not parse\n * under a declared JSON `Content-Type`, a GET `?args=` value that is not valid\n * JSON. Every host framework rpc supports answers `400` for these (Express\n * `entity.parse.failed`, Fastify `FST_ERR_CTP_INVALID_JSON_BODY`, koa-bodyparser,\n * and h3's own `readBody`), and treating one as a server fault both misreports\n * the fault and turns a trivial client mistake into a log entry.\n * @param status - The HTTP status to answer with\n * @param message - Internal diagnostic message; never sent to the client\n * @returns An `Error` carrying `status`\n */\nexport const httpError = (status: number, message: string): ClientHttpError => {\n const err = new Error(message) as ClientHttpError;\n err.status = status;\n return err;\n};\n\n/**\n * Recognises an error that should produce a `4xx` response rather than a `500`.\n *\n * Matches the `status` / `statusCode` convention used by h3's `HTTPError`, the\n * `http-errors` objects Express's `body-parser` throws, and anything else that\n * carries a numeric 4xx. Shared by all five adapters so a host-framework\n * signal and an rpc-raised one are handled by the same rule.\n * @param err - The caught error\n * @returns True when the error denotes a client (4xx) fault\n */\nconst readClientStatus = (err: unknown): number | undefined => {\n const candidate = err as ClientHttpError | null | undefined;\n // Reads both conventions: h3's `HTTPError` and rpc's `httpError` use\n // `status`, while the `http-errors` objects Express's `body-parser` throws\n // and Koa's `ctx.throw` use `statusCode`.\n const status = candidate?.status ?? candidate?.statusCode;\n return typeof status === \"number\" && status >= 400 && status < 500\n ? status\n : undefined;\n};\n\n/**\n * Recognises an error that should produce a `4xx` response rather than a `500`.\n * Matches the `status` / `statusCode` convention used by h3's `HTTPError`, the\n * `http-errors` objects Express's `body-parser` throws, and anything else\n * carrying a numeric 4xx. Shared by all five adapters so a host-framework\n * signal and an rpc-raised one are handled by the same rule.\n * @param err - The caught error\n * @returns True when the error denotes a client (4xx) fault\n */\nexport const isClientHttpError = (err: unknown): boolean =>\n readClientStatus(err) !== undefined;\n\n/**\n * Reads the status to answer for a client error. Defaults to `400` rather than\n * `500` so an unrecognised 4xx is never reported as a server fault.\n * @param err - The caught error\n * @returns The 4xx status to answer with\n */\nexport const clientErrorStatus = (err: unknown): number =>\n readClientStatus(err) ?? 400;\n\nexport const clientErrorMessage = (status: number): string => {\n if (status === 413) return PAYLOAD_TOO_LARGE;\n if (status === 415) return UNSUPPORTED_MEDIA_TYPE;\n return BAD_REQUEST;\n};\n\nexport const isFormContentType = (contentType: string): boolean =>\n contentType === \"multipart/form-data\" ||\n contentType === \"application/x-www-form-urlencoded\";\n\n/**\n * Detects whether an incoming request's `Content-Type` header conflicts\n * with the function's declared content type. JSON and text functions are\n * enforced strictly (exact match wins), while form functions accept both\n * form encodings because the nojs fallback submits urlencoded forms to\n * multipart-declared endpoints. Requests without a `Content-Type` header\n * (curl, GET, legacy clients) are exempt from enforcement.\n * @param declared - The declared `contentType` from the server function options\n * @param rawHeader - The raw `Content-Type` request header, if present\n */\nexport const hasContentTypeMismatch = (\n declared: ContentType,\n rawHeader: string | undefined,\n): boolean => {\n // No Content-Type header → exempt (url bar, GET, curl compatibility)\n if (!rawHeader) return false;\n // Strip parameters (charset, boundary) before comparison\n const incomingType = rawHeader.trim().toLowerCase().split(\";\")[0].trim();\n if (isFormContentType(declared)) {\n // Forms: reject only non-form encodings (lenient between the two)\n return !isFormContentType(incomingType);\n }\n return incomingType !== declared;\n};\n\n/**\n * Decides whether a request may proceed, given the configured origin allowlist\n * and the two headers a browser can be made to reveal.\n *\n * Four tiers, evaluated in order — the first tier with a signal decides:\n *\n * 1. `origin` option unset → everything passes. No validation is performed.\n * 2. `Origin` present → the allowlist decides, exactly as {@link isOriginAllowed}.\n * 3. `Origin` absent but `Sec-Fetch-Site` present → allow only `same-origin`\n * and `none`; anything else (including an unrecognised value) is rejected.\n * 4. Both absent → passes. This is the deliberate, documented curl/native hole.\n *\n * Tier 2 must short-circuit ahead of tier 3. `Sec-Fetch-Site` is a coarse\n * four-value enum that cannot name a host, so on its own it would reject a\n * legitimate request from an allowlisted sibling subdomain (`same-site`). The\n * allowlist exists precisely to admit that case, and it can only do so while\n * `Origin` survives. `Sec-Fetch-Site` earns a vote only once the precise signal\n * has been stripped away by something in the chain — at which point there is\n * nothing left to trust, so it fails closed.\n *\n * Browsers never strip `Origin` themselves, so tier 3 can only fire when a\n * proxy, sanitising middleware, or misconfigured CDN removed it. No legitimate\n * browser request can regress.\n *\n * An empty (or whitespace-only) header value counts as **absent**, not as an\n * unrecognised signal. No browser emits an empty `Sec-Fetch-Site`, and adapters\n * disagree on what their header accessor returns for a missing header (Node's\n * `req.headers` yields `undefined`, Hono's `c.req.header()` may yield `\"\"`).\n * Normalising here keeps all five adapters behaving identically instead of\n * inheriting whichever convention their framework happens to use.\n * @param allowed - The configured `origin` option, if any\n * @param origin - The raw `Origin` request header, if present\n * @param site - The raw `Sec-Fetch-Site` request header, if present\n * @returns `true` when the request may proceed\n */\nexport const isOriginRequestAllowed = (\n allowed: string | string[] | undefined,\n origin: string | undefined,\n site: string | undefined,\n): boolean => {\n if (!allowed) return true; // tier 1 — the check is opt-in\n if (origin?.trim()) return isOriginAllowed(allowed, origin); // tier 2 — precise\n if (!site?.trim()) return true; // tier 4 — curl / native client\n // tier 3 — precision lost, so fail closed\n return site === \"same-origin\" || site === \"none\";\n};\n\n/**\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\n/**\n * Decides whether a request's `Origin` header is allowed by the configured\n * allowlist. Shared by all five adapters so the rule lives in exactly one place.\n *\n * - No `allowed` value (option unset) → everything passes: no validation.\n * - No `requestOrigin` header → passes, preserving curl/native-client access.\n * - Otherwise the header must match one of the entries exactly.\n *\n * A single string and a one-element array behave identically, so widening\n * `origin` to `string | string[]` is backward compatible.\n *\n * `Origin: null` (sandboxed iframes, `file://`, extension pages) is rejected\n * whenever an allowlist is set, because it never equals a real origin.\n * @param allowed - The configured `origin` option, if any\n * @param requestOrigin - The raw `Origin` request header, if present\n * @returns `true` when the request may proceed\n */\nexport const isOriginAllowed = (\n allowed: string | string[] | undefined,\n requestOrigin: string | undefined,\n): boolean => {\n if (!allowed || !requestOrigin) return true;\n return Array.isArray(allowed)\n ? allowed.includes(requestOrigin)\n : requestOrigin === allowed;\n};\n\nconst SAFE_URL_BASE = \"http://localhost\";\n\n/**\n * Parses a raw request URL against a fixed base without ever throwing.\n * Malformed request-targets (e.g. `/\\`, `//`, `/\\/`) make the WHATWG URL\n * parser throw `TypeError: Invalid URL`; the adapters call this while\n * building the per-request URL **before** their dispatch `try` block, so an\n * unhandled rejection there crashes raw `node:http` hosts (and Express 4).\n * On failure we fall back to the base root: the resulting pathname never\n * matches the RPC prefix, so the request is treated as non-RPC and falls\n * through to `next()` / 404 instead of crashing the process.\n * @param rawUrl - Raw request URL (path + optional query string)\n * @param base - Optional base URL, defaults to a fixed localhost origin\n * @returns A URL object; never throws\n */\nexport const safeURL = (rawUrl: string, base = SAFE_URL_BASE): URL => {\n try {\n return new URL(rawUrl, base);\n } catch {\n return new URL(\"/\", base);\n }\n};\n\nconst globalPrefixSymbol = Symbol.for(\"thednp.rpc.globalPrefix\");\n\n/** Global rpcPrefix from the last loaded config / middleware — fallback for functions without explicit prefix. */\nexport const getGlobalPrefix = (): string | undefined =>\n (globalThis as unknown as Record<symbol, string | undefined>)[\n globalPrefixSymbol\n ];\n\n/**\n * Publishes the global RPC prefix, consulted by `resolveRPCPrefix` whenever no\n * explicit prefix is supplied. `loadRPCConfig` calls this on every return path\n * so a loaded config is the fallback for later registrations and dispatches.\n *\n * Stored on a `Symbol.for` key on `globalThis` so it stays instance-stable\n * across the bundled entry copies (`server.mjs`, `express.mjs`, ...) and dev\n * server hot reloads — the same technique as the request-context storage.\n * @param prefix - The prefix to publish, or `undefined` to clear it\n */\nexport const setGlobalPrefix = (prefix: string | undefined): void => {\n if (prefix) {\n (globalThis as unknown as Record<symbol, string | undefined>)[\n globalPrefixSymbol\n ] = prefix;\n } else {\n delete (globalThis as unknown as Record<symbol, string | undefined>)[\n globalPrefixSymbol\n ];\n }\n};\n\n/**\n * Resolves the effective RPC prefix: the explicit one when given, otherwise\n * the global prefix set by `setGlobalPrefix` / `loadRPCConfig`, otherwise the\n * built-in default.\n *\n * Every adapter resolves its prefix through this single function — in both the\n * outer `createMiddleware` gate and the `createRPCMiddleware` dispatch — so the\n * two halves of a request can never disagree, and so a prefix registered by\n * `createServerFunction` (which resolves the same way) is always the prefix the\n * middleware looks up. Resolving the two sides independently is what allowed\n * h3 to drift from the other four adapters, and what left the documented\n * global-prefix flow returning 404 on all of them.\n * @param rpcPrefix - Explicit prefix from config or middleware options\n * @returns The prefix to gate on, look up in, and strip from the request path\n */\nexport const resolveRPCPrefix = (rpcPrefix?: string): string =>\n rpcPrefix || getGlobalPrefix() || defaultPrefix;\n","// @thednp/rpc/src/fastify/helpers.ts\nimport type { FastifyReply, FastifyRequest } from \"fastify\";\nimport type { ViteDevServer } from \"vite\";\nimport type { FastifyInstance } from \"fastify\";\nimport type { Buffer } from \"node:buffer\";\nimport type { BodyResult, JsonValue } from \"../types.d.ts\";\nimport fastifyRpcPlugin from \"./plugin.ts\";\nimport { httpError } from \"../server-helpers.ts\";\n\n/**\n * Convenience function to load RPC config and register the RPC plugin to a Fastify instance.\n * Dynamically imports loadRPCConfig and registers the fastify-rpc plugin.\n * @param app - Fastify instance\n */\nexport async function attachRPC(app: FastifyInstance) {\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 const options = await loadRPCConfig();\n await app.register(fastifyRpcPlugin, options);\n}\n\n/**\n * Attaches Vite's dev server middlewares to a Fastify instance for development mode.\n * Uses an `onRequest` hook to delegate to Vite's connect-compatible middleware stack.\n * @param app - Fastify instance\n * @param vite - Running Vite dev server\n */\nexport function attachVite(app: FastifyInstance, vite: ViteDevServer) {\n app.addHook(\"onRequest\", async (request, reply) => {\n const next = () =>\n new Promise((resolve) => {\n vite.middlewares(request.raw, reply.raw, resolve);\n });\n await next();\n });\n}\n\n/**\n * Creates a Fastify `onRequest` hook handler that delegates to Vite's\n * connect-compatible middleware stack. Use with `app.addHook(\"onRequest\", ...)`.\n *\n * @example\n * ```ts\n * import Fastify from \"fastify\";\n * import { createServer } from \"vite\";\n * import { viteMiddleware } from \"@thednp/rpc/fastify\";\n *\n * const app = Fastify();\n * const vite = await createServer({ server: { middlewareMode: true } });\n * app.addHook(\"onRequest\", viteMiddleware(vite));\n * ```\n * @param vite - Running Vite dev server\n * @returns A Fastify `onRequest` hook handler\n */\nexport function viteMiddleware(\n vite: ViteDevServer,\n): (request: FastifyRequest, reply: FastifyReply) => Promise<void> {\n return async (request, reply) => {\n reply.hijack();\n await new Promise<void>((resolve, reject) => {\n const next = (err?: unknown) => (err ? reject(err) : resolve());\n vite.middlewares(request.raw, reply.raw, next);\n });\n };\n}\n\n/**\n * Reads and parses the HTTP request body from a Fastify request.\n * If Fastify's body parser already consumed the stream, uses the pre-parsed body from `req.body`.\n * @param req - Fastify request object\n * @returns A promise resolving to the parsed body with its content type\n */\n/**\n * Parses a body leniently: JSON when it parses, otherwise the raw string.\n * Used for bodies that did not declare JSON — notably a request with no\n * `Content-Type` header, which must still arrive parsed if it carries JSON.\n * @param body - The raw body text\n * @returns The parsed JSON value, or the original string\n */\nconst parseJsonOrRawText = (body: string): unknown => {\n try {\n return JSON.parse(body);\n } catch {\n return body;\n }\n};\n\nexport const readBody = (\n req: FastifyRequest,\n): Promise<BodyResult> => {\n return new Promise((resolve, reject) => {\n const contentType = req.headers[\"content-type\"]?.toLowerCase() || \"\";\n const reqBody = req.body as JsonValue;\n\n if (reqBody !== undefined) {\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n const isUrlEncoded = contentType.includes(\"urlencoded\");\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 const toggleListeners = (add?: boolean) => {\n const method = add ? \"on\" : \"off\";\n req.raw[method](\"data\", onData);\n req.raw[method](\"end\", onEnd);\n req.raw[method](\"error\", onError);\n };\n\n let body = \"\";\n\n const onData = (chunk: Buffer) => {\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 // Only a *declared* JSON body is parsed strictly; everything else keeps\n // the lenient sniff. Previously all three fell through to a single\n // `JSON.parse(body)`, so a malformed JSON body and a legitimate text\n // body produced the same exception and were indistinguishable — the\n // catch \"recovered\" both into a text/plain string, which silently\n // handed a JSON-declared function a string and answered 200.\n //\n // The lenient branch is deliberate and must stay: a request with no\n // `Content-Type` at all (curl, and the nojs form fallback) that\n // happens to carry JSON still has to arrive parsed.\n const data = isMultipart\n ? { raw: body }\n : isUrlEncoded\n ? Object.fromEntries(new URLSearchParams(body))\n : isJSON\n ? JSON.parse(body)\n : parseJsonOrRawText(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 (_e) {\n // A body that does not parse under a declared JSON Content-Type is a\n // client error. It used to resolve as `text/plain` with the raw string,\n // which silently handed a JSON-declared function a string and answered\n // 200 — failing open on malformed input. Every host framework rpc\n // supports answers 400 here (Express `entity.parse.failed`, Fastify\n // `FST_ERR_CTP_INVALID_JSON_BODY`, koa-bodyparser, h3's own readBody).\n reject(httpError(400, \"Invalid JSON body\"));\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 Fastify reply using the native\n * `reply.redirect(location, status)` API (Fastify v5 signature: destination\n * URL first, status code optional). Defaults to `303 See Other` for\n * convention (Post/Redirect/Get).\n * @param reply - Fastify reply object\n * @param location - The URL to redirect to\n * @param status - HTTP status code, defaults to 303\n */\nexport const redirect = (\n reply: FastifyReply,\n location: string,\n status = 303,\n): void => {\n reply.redirect(location, status);\n};\n","// src/fastify/createMiddleware.ts\nimport type {\n FastifyReply,\n FastifyRequest,\n HookHandlerDoneFunction,\n} from \"fastify\";\nimport type {\n FastifyMiddlewareFn,\n FastifyMiddlewareOptions,\n} from \"./types.d.ts\";\nimport type { JsonValue } from \"@thednp/rpc\";\nimport type { RequestEvent } from \"@thednp/rpc/server\";\nimport {\n clientErrorMessage,\n clientErrorStatus,\n escapeRegExp,\n formatError,\n hasContentTypeMismatch,\n isClientHttpError,\n isOriginRequestAllowed,\n provideRequestContext,\n resolveRPCPrefix,\n safeURL,\n scanForServerFiles,\n} from \"@thednp/rpc/server\";\nimport { getFunctionsForPrefix } from \"../functionsMap.ts\";\nimport { defaultMiddlewareOptions } from \"../options.ts\";\nimport {\n BAD_REQUEST,\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\";\nimport { readBody, redirect as fastifyRedirect } from \"./helpers.ts\";\n\nlet middlewareCount = 0;\nconst middlewareStack = new Set<string>();\n\n/**\n * Creates a Fastify preHandler hook with optional path and rpcPrefix filtering.\n * Middleware names are deduplicated. Prefix and path regexes are compiled once at creation time.\n * @param initialOptions - Options for rpcPrefix, path matching, and the handler function\n * @returns A Fastify preHandler hook function\n */\nexport const createMiddleware: FastifyMiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as FastifyMiddlewareOptions;\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 // Resolved once at creation time so the hoisted regex and the\n // function-map lookup can never disagree. `createRPCMiddleware` hands\n // over its already-resolved prefix, making this a no-op in that path.\n const resolvedPrefix = resolveRPCPrefix(rpcPrefix);\n // Gated only when an explicit prefix was supplied: a bare\n // `createMiddleware({ path, handler })` has never prefix-gated.\n // `createRPCMiddleware` always supplies one, so RPC dispatch does.\n const prefixRegex: RegExp | null = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(resolvedPrefix)}/`)\n : null;\n const pathMatcher: RegExp | null = path\n ? (typeof path === \"string\" ? new RegExp(path) : path)\n : null;\n\n const middlewareHandler = async (\n req: FastifyRequest,\n reply: FastifyReply,\n done: HookHandlerDoneFunction,\n ) => {\n const reqUrl = safeURL(req.url);\n const url = reqUrl.pathname;\n\n // No need to continue when no handler provided\n if (!handler) {\n done();\n return;\n }\n\n // Path matching\n if (pathMatcher && !pathMatcher.test(url)) {\n done();\n return;\n }\n\n // rpcPrefix matching (boundary-safe via escaped regex)\n if (prefixRegex && !prefixRegex.test(url)) {\n done();\n return;\n }\n\n // When serving from production server, scan for server files\n if (getFunctionsForPrefix(resolvedPrefix).size === 0) {\n await scanForServerFiles({\n rpcPrefix: resolvedPrefix,\n serverFiles: (options as unknown as { serverFiles?: \"exact\" | \"glob\" })\n .serverFiles,\n scanRoot: (options as unknown as { scanRoot?: string }).scanRoot,\n } as never);\n }\n\n // Execute handler\n await handler(req, reply, done);\n };\n\n Object.defineProperty(middlewareHandler, \"name\", {\n value: name,\n });\n\n return middlewareHandler;\n};\n\n/**\n * Creates the Fastify 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 sends the JSON-serialized result.\n * @param initialOptions - Options including rpcPrefix for URL routing\n * @returns A Fastify preHandler hook function\n */\nexport const createRPCMiddleware: FastifyMiddlewareFn = (\n initialOptions = {},\n) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as FastifyMiddlewareOptions;\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 prefix = resolveRPCPrefix(rpcPrefix);\n const prefixRegex = new RegExp(`^/${escapeRegExp(prefix)}/`);\n const prefixReplace = `/${prefix}/`;\n\n return createMiddleware({\n ...options,\n // Hand the resolved prefix down so the gate and the dispatch agree.\n rpcPrefix: prefix,\n handler: async (\n req: FastifyRequest,\n reply: FastifyReply,\n _done: HookHandlerDoneFunction,\n ) => {\n const reqUrl = safeURL(req.url);\n const url = reqUrl.pathname;\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 if\n if (prefixRegex && !prefixRegex.test(url)) {\n return;\n }\n\n // Optional origin check. When Origin survives, the allowlist decides;\n // when it has been stripped, Sec-Fetch-Site is consulted instead and\n // fails closed. See `isOriginRequestAllowed` for the four tiers.\n if (\n !isOriginRequestAllowed(\n options.origin,\n req.headers.origin,\n req.headers[\"sec-fetch-site\"],\n )\n ) {\n reply.status(403).send({ error: REQUEST_FORBIDDEN });\n return;\n }\n\n const functionName = url.replace(prefixReplace, \"\");\n const serverFunction = getFunctionsForPrefix(prefix).get(functionName);\n\n if (!serverFunction) {\n reply.status(404).send({\n error: FUNCTION_NOT_FOUND,\n });\n return;\n }\n\n try {\n const method = serverFunction.options?.method || \"POST\";\n if (req.method.toUpperCase() !== method) {\n reply.status(405).send({ 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) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // A malformed `?args=` is a malformed request, not a server\n // fault, so it answers 400 like the non-array case above.\n reply.status(400).send({ error: BAD_REQUEST });\n return;\n }\n if (!Array.isArray(parsed)) {\n reply.status(400).send({ error: BAD_REQUEST });\n return;\n }\n args = parsed as JsonValue[];\n }\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 req.headers[\"content-type\"],\n )\n ) {\n reply.status(415).send({ error: UNSUPPORTED_MEDIA_TYPE });\n return;\n }\n const body = await readBody(req);\n args = Array.isArray(body.data)\n ? body.data as JsonValue[]\n : [body.data as JsonValue];\n }\n const requestEvent: RequestEvent = {\n request: req,\n response: reply,\n nativeEvent: req,\n locals: {},\n functionName,\n redirect: (location, status = 303) => {\n requestEvent.redirected = { location, status };\n fastifyRedirect(reply, location, status);\n },\n send: (status, body, headers) => {\n requestEvent.sent = { status, body, headers };\n if (headers) {\n for (const [name, value] of Object.entries(headers)) {\n reply.header(name, value);\n }\n }\n reply.status(status).send(body);\n },\n };\n const { data: dataResult, cancel } = provideRequestContext(\n requestEvent,\n () => serverFunction.handler(...args),\n );\n const onClose = () => cancel(CLIENT_DISCONNECTED);\n\n req.raw.on(\"close\", onClose);\n const data = await dataResult;\n req.raw.off(\"close\", onClose);\n\n // istanbul ignore else\n if (\n !requestEvent.redirected &&\n !requestEvent.sent &&\n !reply.raw.headersSent\n ) {\n reply.status(200).send({ data });\n }\n } catch (err) {\n // A malformed request is a client error, not a server fault. rpc raises\n // these with a status (see `httpError`), and host frameworks signal the\n // same class the same way — h3's body limit throws 413 from inside the\n // read, Express's body-parser throws 400, and Fastify's parser does the\n // same before rpc is reached. Answering 500 for any of them both\n // misreports the fault and turns a trivial client mistake into a log\n // entry. The body comes from a fixed table, so nothing from the\n // underlying error is echoed back.\n if (isClientHttpError(err)) {\n const status = clientErrorStatus(err);\n reply.status(status).send({ error: clientErrorMessage(status) });\n return;\n }\n console.error(String(err));\n const isProduction = process.env.NODE_ENV === \"production\";\n reply.status(500).send(formatError(err, isProduction));\n }\n },\n });\n};\n","/** @module Fastify plugin. Exports the RPC plugin wrapped with `fastify-plugin` for lifecycle-compatible registration. */\n// inspired by https://github.com/royalswe/vike-fastify-boilerplate/blob/main/server/index.ts\nimport fp from \"fastify-plugin\";\nimport type { MiddlewareOptions } from \"../types.d.ts\";\nimport type {\n FastifyRPCPlugin,\n RegisteredFastifyRPCPlugin,\n} from \"./types.d.ts\";\nimport { createRPCMiddleware } from \"./createMiddleware.ts\";\n\nexport type { MiddlewareOptions };\n\nconst RpcPlugin: FastifyRPCPlugin = (\n fastify,\n initialOptions,\n done,\n) => {\n // Register RPC middleware as preHandler hook\n const rpcMiddleware = createRPCMiddleware(initialOptions);\n fastify.addHook(\"preHandler\", async (request, reply) => {\n const next = () =>\n new Promise((resolve) => {\n rpcMiddleware(request, reply, resolve);\n });\n await next();\n });\n\n done();\n};\n\n// Export the plugin wrapped with fastify-plugin\nconst rpcPlugin = fp(RpcPlugin, {\n name: \"uni-rpc-fastify-plugin\",\n}) as RegisteredFastifyRPCPlugin;\n\nexport { rpcPlugin as default };\n"],"mappings":"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"}
package/dist/h3/h3.d.mts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { EventHandler as H3Next, H3, H3 as H3$1, H3Event, H3Event as H3Event$1, H3Event as H3Request, H3Response, HTTPResponse, Middleware, Middleware as H3Middleware } from "h3";
2
- import { BodyResult, MiddlewareOptions, RpcPluginOptions } from "@thednp/rpc";
2
+ import { AdapterName, BodyResult, MiddlewareOptions } from "@thednp/rpc";
3
3
  import { IncomingHttpHeaders } from "node:http";
4
4
  import { ViteDevServer } from "vite";
5
5
  import "express";
@@ -90,7 +90,7 @@ interface H3MiddlewareHooks {
90
90
  * h3 middleware factory: takes optional initial options and returns
91
91
  * the h3-compatible handler.
92
92
  */
93
- type H3MiddlewareFn = <A extends RpcPluginOptions["adapter"] = "h3">(initialOptions?: Partial<H3MiddlewareOptions>) => H3MiddlewareHooks["handler"];
93
+ type H3MiddlewareFn = <A extends AdapterName = "h3">(initialOptions?: Partial<H3MiddlewareOptions>) => H3MiddlewareHooks["handler"];
94
94
  /**
95
95
  * h3 application reference used by helpers that attach middleware to an app.
96
96
  */
@@ -1 +1 @@
1
- {"version":3,"file":"h3.d.mts","names":["H3","H3Event","H3Event"],"sources":["../../src/types.d.ts","../../src/adapter-types.ts","../../src/h3/types.d.ts","../../src/h3/createMiddleware.ts","../../src/h3/helpers.ts"],"mappings":";;;;;;;;;;;;;;;;;KA0HY;;;;KAIA;GAAgB,cAAc,YAAY;;;;;KAI1C,aAAa,WAAW;;;;KAIxB,YAAY,gBAAgB,YAAY;;;;;;;;;;;KC1HxC;;EAEV;;EAEA,YAAY,cAAc;;EAE1B;;EAEA,gBAAgB;;EAEhB,eAAe,cAAc,QAAQ;;;;;;;KAQ3B;;EAEV;;EAEA;;EAEA,cAAc;;EAEd,SAAS;;EAET;;;;;;;KCjCU,sBAAsB;;;;UAKjB;;;;;;EAMf,SAAS;;;;;;KAOC,kBAAkB,UAAU,oCACtC,iBAAiB,QAAQ,yBACtB;;;;KAKO,QAAQA;;;;KAKR,kBAAkBC;EAAY;;;;;;;;;;;qBCI7B,kBAAkB;;;;;;;;qBA4ElB,qBAAqB;;;;;;;;wBCvGZ,UAAU,KAAK,QAAK;;;;;;;qBAgB7B,aAAU,KAAS,OAAK,MAAQ;;;;;;;;qBAWhC,iBAAc,MAAU,kBAAgB;;;;;;;qBA6ExC,WAAQ,OAAiBC,cAAU,QAAQ;;;;;;;;;qBAmC3C,WAAQ,kBACH,oBAEf"}
1
+ {"version":3,"file":"h3.d.mts","names":["H3","H3Event","H3Event"],"sources":["../../src/types.d.ts","../../src/adapter-types.ts","../../src/h3/types.d.ts","../../src/h3/createMiddleware.ts","../../src/h3/helpers.ts"],"mappings":";;;;;;;;;;;;;;;;;KAsIY;;;;KAIA;GAAgB,cAAc,YAAY;;;;;KAI1C,aAAa,WAAW;;;;KAIxB,YAAY,gBAAgB,YAAY;;;;;;;;;;;KCtIxC;;EAEV;;EAEA,YAAY,cAAc;;EAE1B;;EAEA,gBAAgB;;EAEhB,eAAe,cAAc,QAAQ;;;;;;;KAQ3B;;EAEV;;EAEA;;EAEA,cAAc;;EAEd,SAAS;;EAET;;;;;;;KCjCU,sBAAsB;;;;UAKjB;;;;;;EAMf,SAAS;;;;;;KAOC,kBAAkB,UAAU,oBACtC,iBAAiB,QAAQ,yBACtB;;;;KAKO,QAAQA;;;;KAKR,kBAAkBC;EAAY;;;;;;;;;;;qBCG7B,kBAAkB;;;;;;;;qBAiFlB,qBAAqB;;;;;;;;wBC1GZ,UAAU,KAAK,QAAK;;;;;;;qBAgB7B,aAAU,KAAS,OAAK,MAAQ;;;;;;;;qBAWhC,iBAAc,MAAU,kBAAgB;;;;;;;qBA6ExC,WAAQ,OAAiBC,cAAU,QAAQ;;;;;;;;;qBA8C3C,WAAQ,kBACH,oBAEf"}
package/dist/h3/h3.mjs CHANGED
@@ -1,11 +1,12 @@
1
- import { escapeRegExp, formatError, getGlobalPrefix, hasContentTypeMismatch, isOriginRequestAllowed, provideRequestContext, scanForServerFiles } from "@thednp/rpc/server";
1
+ import { clientErrorMessage, clientErrorStatus, escapeRegExp, formatError, hasContentTypeMismatch, isClientHttpError, isOriginRequestAllowed, provideRequestContext, resolveRPCPrefix, scanForServerFiles } from "@thednp/rpc/server";
2
2
  import { HTTPResponse, redirect as redirect$1 } from "h3";
3
- const defaultRPCOptions = {
4
- rpcPrefix: "__rpc",
5
- adapter: "express",
6
- serverFiles: "exact",
7
- scanRoot: void 0
8
- };
3
+ //#region src/options.ts
4
+ /**
5
+ * Baseline middleware options. Note `rpcPrefix` is `undefined` rather than
6
+ * `defaultPrefix` on purpose: leaving it unset lets `resolveRPCPrefix` fall
7
+ * through to the global prefix, which is what makes a published global prefix
8
+ * reach the middleware.
9
+ */
9
10
  const defaultMiddlewareOptions = {
10
11
  rpcPrefix: void 0,
11
12
  path: void 0,
@@ -39,15 +40,41 @@ const getFunctionsForPrefix = (prefix) => {
39
40
  };
40
41
  //#endregion
41
42
  //#region src/constants.ts
43
+ /** Body of a 404. Deliberately does not name the requested function. */
42
44
  const FUNCTION_NOT_FOUND = "Function not found";
45
+ /** Body of a 405, returned when the HTTP method does not match the function's declared method. */
43
46
  const METHOD_NOT_ALLOWED = "Method Not Allowed";
47
+ /** Body of a 403, returned when the optional origin allowlist rejects the request. */
44
48
  const REQUEST_FORBIDDEN = "Forbidden";
49
+ /** Body of a 415, returned when the request's `Content-Type` does not satisfy the function's declared `contentType`. */
45
50
  const UNSUPPORTED_MEDIA_TYPE = "Unsupported Media Type";
51
+ /** Body of a 400, returned when a GET `?args=` value parses but is not an array. */
46
52
  const BAD_REQUEST = "Bad Request";
53
+ /** Abort reason used when the client disconnects mid-dispatch. */
47
54
  const CLIENT_DISCONNECTED = "client disconnected";
48
55
  /** Returns a warning when a middleware name is reused, preventing registration conflicts. @param name - The duplicate middleware name */
49
56
  const MIDDLEWARE_NAME_USED = (name) => `The middleware name "${name}" is already used.`;
50
57
  //#endregion
58
+ //#region src/server-helpers.ts
59
+ /**
60
+ * Tags an error with an HTTP status for the dispatch to surface.
61
+ *
62
+ * Used where a malformed *request* is the fault — a body that does not parse
63
+ * under a declared JSON `Content-Type`, a GET `?args=` value that is not valid
64
+ * JSON. Every host framework rpc supports answers `400` for these (Express
65
+ * `entity.parse.failed`, Fastify `FST_ERR_CTP_INVALID_JSON_BODY`, koa-bodyparser,
66
+ * and h3's own `readBody`), and treating one as a server fault both misreports
67
+ * the fault and turns a trivial client mistake into a log entry.
68
+ * @param status - The HTTP status to answer with
69
+ * @param message - Internal diagnostic message; never sent to the client
70
+ * @returns An `Error` carrying `status`
71
+ */
72
+ const httpError = (status, message) => {
73
+ const err = new Error(message);
74
+ err.status = status;
75
+ return err;
76
+ };
77
+ //#endregion
51
78
  //#region src/h3/helpers.ts
52
79
  /**
53
80
  * Convenience function to load RPC config and attach the RPC middleware to an h3 app.
@@ -56,7 +83,7 @@ const MIDDLEWARE_NAME_USED = (name) => `The middleware name "${name}" is already
56
83
  */
57
84
  async function attachRPC(app) {
58
85
  const { loadRPCConfig } = await import("@thednp/rpc");
59
- const { adapter: _adapter, ...options } = await loadRPCConfig();
86
+ const options = await loadRPCConfig();
60
87
  app.use(createRPCMiddleware(options));
61
88
  }
62
89
  /**
@@ -132,10 +159,18 @@ const readBody = async (event) => {
132
159
  const isMultipart = contentType.includes("multipart/form-data");
133
160
  const isUrlEncoded = contentType.includes("urlencoded");
134
161
  const text = await event.req.text();
135
- if (isJSON) return {
136
- contentType: "application/json",
137
- data: JSON.parse(text)
138
- };
162
+ if (isJSON) {
163
+ let data;
164
+ try {
165
+ data = JSON.parse(text);
166
+ } catch {
167
+ throw httpError(400, "Invalid JSON body");
168
+ }
169
+ return {
170
+ contentType: "application/json",
171
+ data
172
+ };
173
+ }
139
174
  return {
140
175
  contentType: isMultipart ? "multipart/form-data" : isUrlEncoded ? "application/x-www-form-urlencoded" : "text/plain",
141
176
  data: isMultipart ? { raw: text } : isUrlEncoded ? Object.fromEntries(new URLSearchParams(text)) : String(text)
@@ -166,7 +201,7 @@ const middlewareStack = /* @__PURE__ */ new Set();
166
201
  const createMiddleware = (initialOptions = {}) => {
167
202
  const options = Object.assign({}, defaultMiddlewareOptions, initialOptions);
168
203
  const middlewareName = options.name;
169
- let rpcPrefix = options.rpcPrefix;
204
+ const rpcPrefix = options.rpcPrefix;
170
205
  const path = options.path;
171
206
  const handler = options.handler;
172
207
  let name = middlewareName;
@@ -176,16 +211,16 @@ const createMiddleware = (initialOptions = {}) => {
176
211
  }
177
212
  if (middlewareStack.has(name)) throw new Error(MIDDLEWARE_NAME_USED(name));
178
213
  middlewareStack.add(name);
179
- const prefixRegex = rpcPrefix ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`) : null;
214
+ const resolvedPrefix = resolveRPCPrefix(rpcPrefix);
215
+ const prefixRegex = rpcPrefix ? new RegExp(`^/${escapeRegExp(resolvedPrefix)}/`) : null;
180
216
  const pathMatcher = path ? typeof path === "string" ? new RegExp(path) : path : null;
181
217
  const middlewareHandler = async (event, next) => {
182
218
  const url = event.url.pathname;
183
219
  if (!handler) return next();
184
220
  if (pathMatcher && !pathMatcher.test(url)) return next();
185
221
  if (prefixRegex && !prefixRegex.test(url)) return next();
186
- rpcPrefix = rpcPrefix || getGlobalPrefix() || "__rpc";
187
- if (getFunctionsForPrefix(rpcPrefix).size === 0) await scanForServerFiles({
188
- rpcPrefix,
222
+ if (getFunctionsForPrefix(resolvedPrefix).size === 0) await scanForServerFiles({
223
+ rpcPrefix: resolvedPrefix,
189
224
  serverFiles: options.serverFiles,
190
225
  scanRoot: options.scanRoot
191
226
  });
@@ -202,13 +237,14 @@ const createMiddleware = (initialOptions = {}) => {
202
237
  * @returns An h3 middleware function
203
238
  */
204
239
  const createRPCMiddleware = (initialOptions = {}) => {
205
- const options = Object.assign({}, defaultMiddlewareOptions, { rpcPrefix: defaultRPCOptions.rpcPrefix }, initialOptions);
240
+ const options = Object.assign({}, defaultMiddlewareOptions, initialOptions);
206
241
  const rpcPrefix = options.rpcPrefix;
207
- const prefix = rpcPrefix || getGlobalPrefix() || "__rpc";
208
- const prefixRegex = rpcPrefix ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`) : null;
242
+ const prefix = resolveRPCPrefix(rpcPrefix);
243
+ const prefixRegex = new RegExp(`^/${escapeRegExp(prefix)}/`);
209
244
  const prefixReplace = `/${prefix}/`;
210
245
  return createMiddleware({
211
246
  ...options,
247
+ rpcPrefix: prefix,
212
248
  handler: async (event, _next) => {
213
249
  const url = event.url.pathname;
214
250
  if (prefixRegex && !prefixRegex.test(url)) return;
@@ -232,7 +268,13 @@ const createRPCMiddleware = (initialOptions = {}) => {
232
268
  if (method === "GET") {
233
269
  const raw = event.url.searchParams.get("args");
234
270
  if (raw) {
235
- const parsed = JSON.parse(raw);
271
+ let parsed;
272
+ try {
273
+ parsed = JSON.parse(raw);
274
+ } catch {
275
+ event.res.status = 400;
276
+ return { error: BAD_REQUEST };
277
+ }
236
278
  if (!Array.isArray(parsed)) {
237
279
  event.res.status = 400;
238
280
  return { error: BAD_REQUEST };
@@ -282,6 +324,11 @@ const createRPCMiddleware = (initialOptions = {}) => {
282
324
  }
283
325
  return { data: result };
284
326
  } catch (err) {
327
+ if (isClientHttpError(err)) {
328
+ const status = clientErrorStatus(err);
329
+ event.res.status = status;
330
+ return { error: clientErrorMessage(status) };
331
+ }
285
332
  console.error(String(err));
286
333
  const isProduction = process.env.NODE_ENV === "production";
287
334
  event.res.status = 500;
@@ -1 +1 @@
1
- {"version":3,"file":"h3.mjs","names":["h3Redirect","h3Redirect"],"sources":["../../src/options.ts","../../src/functionsMap.ts","../../src/constants.ts","../../src/h3/helpers.ts","../../src/h3/createMiddleware.ts"],"sourcesContent":["import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\nexport const defaultServerFnOptions: ServerFunctionOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n};\n\nexport const defaultPrefix = \"__rpc\";\n\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: defaultPrefix,\n adapter: \"express\",\n serverFiles: \"exact\",\n scanRoot: undefined,\n};\n\nexport const defaultMiddlewareOptions: MiddlewareOptions = {\n rpcPrefix: undefined,\n path: undefined,\n origin: undefined,\n};\n","import type { ServerFnEntry } from \"./types.d.ts\";\nimport { defaultPrefix } from \"./options.ts\";\n\n/**\n * Global symbol under which the shared `serverFunctionsByPrefix` map is stored\n * on `globalThis`. Keeping it on a `Symbol.for` key makes it instance-stable\n * across the bundled entry copies (`index.mjs`, `server.mjs`, `express.mjs`,\n * ...) and dev-server hot reloads, exactly like the request-context storage in\n * `context.ts`. Without this, `scanForServerFiles` (bundled into the plugin)\n * would populate a map copy the adapter middleware could not read.\n */\nconst functionsMapSymbol = Symbol.for(\"thednp.rpc.functionsMap\");\n\n/**\n * Map of rpcPrefix -> Map of function names -> ServerFnEntry\n * Enables multiple RPC instances with different prefixes to coexist\n * without name collisions.\n */\nexport const serverFunctionsByPrefix: Map<\n string,\n Map<string, ServerFnEntry>\n> = ((globalThis as Record<symbol, Map<string, Map<string, ServerFnEntry>>>)[\n functionsMapSymbol\n] ??= new Map());\n\n/**\n * Gets or creates the function map for a specific prefix.\n * @param prefix - The RPC prefix (e.g., \"__rpc\", \"v1:rpc\", \"admin:rpc\")\n * @returns Map of function names to ServerFnEntry for that prefix\n */\nexport const getFunctionsForPrefix = (\n prefix: string,\n): Map<string, ServerFnEntry> => {\n if (!serverFunctionsByPrefix.has(prefix)) {\n serverFunctionsByPrefix.set(prefix, new Map());\n }\n return serverFunctionsByPrefix.get(prefix)!;\n};\n\n/**\n * Backward compatibility: default map for the default prefix.\n * Legacy code can still use serverFunctionsMap.set(name, entry).\n */\nexport const serverFunctionsMap: Map<string, ServerFnEntry> = {\n get: (key: string) => getFunctionsForPrefix(defaultPrefix).get(key),\n set: (key: string, value: ServerFnEntry) =>\n getFunctionsForPrefix(defaultPrefix).set(key, value),\n has: (key: string) => getFunctionsForPrefix(defaultPrefix).has(key),\n delete: (key: string) => getFunctionsForPrefix(defaultPrefix).delete(key),\n clear: () => getFunctionsForPrefix(defaultPrefix).clear(),\n get size() {\n return getFunctionsForPrefix(defaultPrefix).size;\n },\n entries: () => getFunctionsForPrefix(defaultPrefix).entries(),\n keys: () => getFunctionsForPrefix(defaultPrefix).keys(),\n values: () => getFunctionsForPrefix(defaultPrefix).values(),\n forEach: (\n callback: (\n value: ServerFnEntry,\n key: string,\n map: Map<string, ServerFnEntry>,\n ) => void,\n ) => getFunctionsForPrefix(defaultPrefix).forEach(callback),\n [Symbol.iterator]: () =>\n getFunctionsForPrefix(defaultPrefix)[Symbol.iterator](),\n} as unknown as Map<string, ServerFnEntry>;\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 UNSUPPORTED_MEDIA_TYPE = \"Unsupported Media Type\";\n\nexport const BAD_REQUEST = \"Bad Request\";\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 =\n ` ⚡︎ 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","// src/h3/helpers.ts\nimport type { H3Event, Middleware } from \"h3\";\nimport { HTTPResponse, redirect as h3Redirect } from \"h3\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { ViteDevServer } from \"vite\";\nimport type { BodyResult } from \"@thednp/rpc\";\nimport type { H3App } from \"./types.d.ts\";\nimport { createRPCMiddleware } from \"./createMiddleware.ts\";\n\n/**\n * Convenience function to load RPC config and attach the RPC middleware to an h3 app.\n * Dynamically imports loadRPCConfig and registers the middleware.\n * @param app - h3 application instance\n */\nexport async function attachRPC(app: H3App) {\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 const { adapter: _adapter, ...options } = await loadRPCConfig();\n\n app.use(createRPCMiddleware(options));\n}\n\n/**\n * Attaches Vite's dev server middlewares to an h3 app for development mode.\n * Uses the viteMiddleware wrapper to bridge Vite's Connect-compatible stack into h3.\n * @param app - h3 application instance\n * @param vite - Running Vite dev server\n */\nexport const attachVite = (app: H3App, vite: ViteDevServer): void => {\n app.use(viteMiddleware(vite));\n};\n\n/**\n * Creates an h3-compatible middleware from a Vite dev server middleware stack.\n * Bridges the Connect/Express middleware interface to h3's event-based request/response model.\n * Supports both Node.js and web runtimes with separate polyfill paths.\n * @param vite - Running Vite dev server\n * @returns An h3 middleware function\n */\nexport const viteMiddleware = (vite: ViteDevServer): Middleware => {\n return (event, next) =>\n new Promise((resolve) => {\n const node = event.runtime?.node;\n if (node?.req && node?.res) {\n const nodeReq = node.req;\n const nodeRes = node.res;\n // ─── Node.js runtime ─────────────────────────────────────────────\n // Forward to the real node req/res: if the Vite/Connect stack writes\n // the response, the socket is already flushed (dev-mode asset serving,\n // HMR). Connect never calls the final callback once a middleware has\n // written the response, so also settle on the response lifecycle\n // events. Stop the chain with an empty response once the socket is\n // used; the runtime's write guard prevents a second write. When the\n // stack passes through, continue to the next middleware.\n let settled = false;\n const settle = (value: unknown) => {\n // istanbul ignore if\n if (settled) return;\n settled = true;\n resolve(value);\n };\n nodeRes.once(\"close\", () => settle(new Response(null)));\n nodeRes.once(\"finish\", () => settle(new Response(null)));\n vite.middlewares(\n nodeReq as IncomingMessage,\n nodeRes as ServerResponse,\n () => {\n if (nodeRes.writableEnded || nodeRes.headersSent) {\n settle(new Response(null));\n } else {\n settle(next());\n }\n },\n );\n return;\n }\n\n // ─── Web runtime fallback ──────────────────────────────────────────\n let sent = false;\n const headers = new Headers();\n const req = {\n url: event.url.pathname + event.url.search,\n method: event.req.method,\n headers: Object.fromEntries(event.req.headers),\n } as IncomingMessage;\n const res = {\n setHeader(name: string, value: unknown) {\n headers.set(name, String(value));\n return this;\n },\n writeHead(status: number) {\n void status;\n return this;\n },\n end(body?: unknown) {\n sent = true;\n resolve(\n new HTTPResponse(body == null ? \"\" : (body as BodyInit), {\n headers,\n }),\n );\n return this;\n },\n } as ServerResponse;\n vite.middlewares(req, res, () => {\n if (!sent) resolve(next());\n });\n });\n};\n\n/**\n * Reads and parses the HTTP request body from an h3 event.\n * Supports JSON, text, urlencoded, and multipart content types.\n * @param event - h3 event object\n * @returns A promise resolving to the parsed body with its content type\n */\nexport const readBody = async (event: H3Event): Promise<BodyResult> => {\n const contentType = event.req.headers.get(\"content-type\")?.toLowerCase() ||\n \"\";\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n const isUrlEncoded = contentType.includes(\"urlencoded\");\n const text = await event.req.text();\n if (isJSON) {\n return {\n contentType: \"application/json\",\n data: JSON.parse(text),\n } as BodyResult;\n }\n return {\n contentType: isMultipart\n ? \"multipart/form-data\"\n : isUrlEncoded\n ? \"application/x-www-form-urlencoded\"\n : \"text/plain\",\n data: isMultipart\n ? ({ raw: text } as Record<string, unknown>)\n : isUrlEncoded\n ? Object.fromEntries(new URLSearchParams(text))\n : String(text),\n } as BodyResult;\n};\n\n/**\n * Issues an HTTP redirect. h3's `redirect()` returns an `HTTPResponse`\n * object that the handler must return (it never writes directly). Defaults\n * to `303 See Other` for convention (Post/Redirect/Get).\n * @param location - The URL to redirect to\n * @param status - HTTP status code, defaults to 303\n * @returns An h3 `HTTPResponse` to return from the handler\n */\nexport const redirect = (\n location: string,\n status = 303,\n): HTTPResponse => {\n return h3Redirect(\n location,\n status,\n status === 303 ? \"See Other\" : undefined,\n );\n};\n","// src/h3/createMiddleware.ts\nimport type { H3Event, Middleware } from \"h3\";\nimport type { H3MiddlewareFn, H3MiddlewareOptions } from \"./types.d.ts\";\nimport type { JsonValue } from \"@thednp/rpc\";\nimport type { RequestEvent } from \"@thednp/rpc/server\";\nimport {\n escapeRegExp,\n formatError,\n getGlobalPrefix,\n hasContentTypeMismatch,\n isOriginRequestAllowed,\n provideRequestContext,\n scanForServerFiles,\n} from \"@thednp/rpc/server\";\nimport { getFunctionsForPrefix } from \"../functionsMap.ts\";\nimport {\n BAD_REQUEST,\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\";\nimport {\n defaultMiddlewareOptions,\n defaultPrefix,\n defaultRPCOptions,\n} from \"../options.ts\";\nimport { readBody, redirect as h3Redirect } from \"./helpers.ts\";\n\nlet middlewareCount = 0;\nconst middlewareStack = new Set<string>();\n\n/**\n * Creates an h3 middleware with optional path and rpcPrefix filtering.\n * Middleware names are deduplicated. Prefix and path regexes are compiled once at creation time.\n * h3 URL is normalized via `event.url` (query strings are not part of the pathname).\n * @param initialOptions - Options for rpcPrefix, path matching, and the handler function\n * @returns An h3 middleware function\n */\nexport const createMiddleware: H3MiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as H3MiddlewareOptions;\n\n const middlewareName = options.name;\n let 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: Middleware = async (event: H3Event, next) => {\n const url = event.url.pathname;\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 rpcPrefix = rpcPrefix || getGlobalPrefix() || defaultPrefix;\n\n // When serving from production server, scan for server files\n if (getFunctionsForPrefix(rpcPrefix).size === 0) {\n await scanForServerFiles({\n rpcPrefix,\n serverFiles: (options as unknown as { serverFiles?: \"exact\" | \"glob\" })\n .serverFiles,\n scanRoot: (options as unknown as { scanRoot?: string }).scanRoot,\n } as never);\n }\n\n return handler(event, next);\n };\n\n Object.defineProperty(middlewareHandler, \"name\", {\n value: name,\n });\n\n return middlewareHandler;\n};\n\n/**\n * Creates the h3 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 returns the JSON-serialized result.\n * @param initialOptions - Options including rpcPrefix for URL routing\n * @returns An h3 middleware function\n */\nexport const createRPCMiddleware: H3MiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n { rpcPrefix: defaultRPCOptions.rpcPrefix },\n initialOptions,\n ) as H3MiddlewareOptions;\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 prefix = rpcPrefix || getGlobalPrefix() || defaultPrefix;\n const prefixRegex = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(rpcPrefix)}/`)\n : /* istanbul ignore next */ null;\n const prefixReplace = `/${prefix}/`;\n\n return createMiddleware({\n ...options,\n handler: async (event: H3Event, _next?: () => unknown) => {\n const url = event.url.pathname;\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 if\n if (prefixRegex && !prefixRegex.test(url)) {\n /* istanbul ignore next */\n return undefined;\n }\n\n // Optional origin check. When Origin survives, the allowlist decides;\n // when it has been stripped, Sec-Fetch-Site is consulted instead and\n // fails closed. See `isOriginRequestAllowed` for the four tiers.\n if (\n !isOriginRequestAllowed(\n options.origin,\n event.req.headers.get(\"origin\") ?? undefined,\n event.req.headers.get(\"sec-fetch-site\") ?? undefined,\n )\n ) {\n event.res.status = 403;\n return { error: REQUEST_FORBIDDEN };\n }\n\n const functionName = url.replace(prefixReplace, \"\");\n const serverFunction = getFunctionsForPrefix(prefix).get(functionName);\n\n if (!serverFunction) {\n event.res.status = 404;\n return { error: FUNCTION_NOT_FOUND };\n }\n\n try {\n const method = serverFunction.options?.method || \"POST\";\n if (event.req.method.toUpperCase() !== method) {\n event.res.status = 405;\n return { error: METHOD_NOT_ALLOWED };\n }\n\n let args: JsonValue[] = [];\n if (method === \"GET\") {\n const raw = event.url.searchParams.get(\"args\");\n if (raw) {\n const parsed: unknown = JSON.parse(raw);\n if (!Array.isArray(parsed)) {\n event.res.status = 400;\n return { error: BAD_REQUEST };\n }\n args = parsed as JsonValue[];\n }\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 event.req.headers.get(\"content-type\") ?? undefined,\n )\n ) {\n event.res.status = 415;\n return { error: UNSUPPORTED_MEDIA_TYPE };\n }\n const body = await readBody(event);\n args = Array.isArray(body.data)\n ? body.data as JsonValue[]\n : [body.data as JsonValue];\n }\n const requestEvent: RequestEvent = {\n request: event.req,\n response: event.res,\n nativeEvent: event,\n locals: event.context,\n functionName,\n // h3's `redirect()` returns an `HTTPResponse` (never writes directly),\n // so the bound redirect/send only record the intent; the middleware\n // uses them after the dispatch to return the response body.\n redirect: (location, status = 303) => {\n requestEvent.redirected = { location, status };\n },\n send: (status, body, headers) => {\n requestEvent.sent = { status, body, headers };\n },\n };\n const fnResult = provideRequestContext(\n requestEvent,\n () => serverFunction.handler(...args),\n );\n const onClose = () => fnResult.cancel(CLIENT_DISCONNECTED);\n // The node runtime gives us the raw incoming stream for close events;\n // other runtimes have no node req, so the abort hook is skipped.\n const nodeReq = event.runtime?.node?.req;\n if (nodeReq) nodeReq.on(\"close\", onClose);\n const result = await fnResult.data;\n if (nodeReq) nodeReq.off(\"close\", onClose);\n\n if (requestEvent.redirected) {\n return h3Redirect(\n requestEvent.redirected.location,\n requestEvent.redirected.status,\n );\n }\n\n if (requestEvent.sent) {\n const { status, body, headers } = requestEvent.sent;\n event.res.status = status;\n if (headers) {\n for (const [name, value] of Object.entries(headers)) {\n event.res.headers.set(name, value);\n }\n }\n return body;\n }\n\n return { data: result };\n } catch (err) {\n console.error(String(err));\n const isProduction = process.env.NODE_ENV === \"production\";\n event.res.status = 500;\n\n return formatError(err, isProduction);\n }\n },\n });\n};\n"],"mappings":"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"}
1
+ {"version":3,"file":"h3.mjs","names":["h3Redirect","h3Redirect"],"sources":["../../src/options.ts","../../src/functionsMap.ts","../../src/constants.ts","../../src/server-helpers.ts","../../src/h3/helpers.ts","../../src/h3/createMiddleware.ts"],"sourcesContent":["import type {\n MiddlewareOptions,\n RpcPluginOptions,\n ServerFunctionOptions,\n} from \"./types.d.ts\";\n\n/**\n * Defaults applied to a server function that declares no `method`,\n * `credentials`, or `contentType` of its own.\n */\nexport const defaultServerFnOptions: ServerFunctionOptions = {\n contentType: \"application/json\",\n credentials: \"same-origin\",\n method: \"POST\",\n};\n\n/**\n * The built-in RPC endpoint prefix, used when neither an explicit prefix nor a\n * global one (`getGlobalPrefix`) is supplied. Kept for backward compatibility\n * with pre-multi-prefix setups, where every function lived under this one map.\n */\nexport const defaultPrefix = \"__rpc\";\n\n/**\n * Baseline plugin options. `defineConfig` merges a user's partial config over\n * these, and `loadRPCConfig` merges a loaded config file over them, so every\n * option has a defined value even when a config file omits it.\n */\nexport const defaultRPCOptions: RpcPluginOptions = {\n rpcPrefix: defaultPrefix,\n serverFiles: \"exact\",\n scanRoot: undefined,\n};\n\n/**\n * Baseline middleware options. Note `rpcPrefix` is `undefined` rather than\n * `defaultPrefix` on purpose: leaving it unset lets `resolveRPCPrefix` fall\n * through to the global prefix, which is what makes a published global prefix\n * reach the middleware.\n */\nexport const defaultMiddlewareOptions: MiddlewareOptions = {\n rpcPrefix: undefined,\n path: undefined,\n origin: undefined,\n};\n","import type { ServerFnEntry } from \"./types.d.ts\";\nimport { defaultPrefix } from \"./options.ts\";\n\n/**\n * Global symbol under which the shared `serverFunctionsByPrefix` map is stored\n * on `globalThis`. Keeping it on a `Symbol.for` key makes it instance-stable\n * across the bundled entry copies (`index.mjs`, `server.mjs`, `express.mjs`,\n * ...) and dev-server hot reloads, exactly like the request-context storage in\n * `context.ts`. Without this, `scanForServerFiles` (bundled into the plugin)\n * would populate a map copy the adapter middleware could not read.\n */\nconst functionsMapSymbol = Symbol.for(\"thednp.rpc.functionsMap\");\n\n/**\n * Map of rpcPrefix -> Map of function names -> ServerFnEntry\n * Enables multiple RPC instances with different prefixes to coexist\n * without name collisions.\n */\nexport const serverFunctionsByPrefix: Map<\n string,\n Map<string, ServerFnEntry>\n> = ((globalThis as Record<symbol, Map<string, Map<string, ServerFnEntry>>>)[\n functionsMapSymbol\n] ??= new Map());\n\n/**\n * Gets or creates the function map for a specific prefix.\n * @param prefix - The RPC prefix (e.g., \"__rpc\", \"v1:rpc\", \"admin:rpc\")\n * @returns Map of function names to ServerFnEntry for that prefix\n */\nexport const getFunctionsForPrefix = (\n prefix: string,\n): Map<string, ServerFnEntry> => {\n if (!serverFunctionsByPrefix.has(prefix)) {\n serverFunctionsByPrefix.set(prefix, new Map());\n }\n return serverFunctionsByPrefix.get(prefix)!;\n};\n\n/**\n * Backward compatibility: default map for the default prefix.\n * Legacy code can still use serverFunctionsMap.set(name, entry).\n */\nexport const serverFunctionsMap: Map<string, ServerFnEntry> = {\n get: (key: string) => getFunctionsForPrefix(defaultPrefix).get(key),\n set: (key: string, value: ServerFnEntry) =>\n getFunctionsForPrefix(defaultPrefix).set(key, value),\n has: (key: string) => getFunctionsForPrefix(defaultPrefix).has(key),\n delete: (key: string) => getFunctionsForPrefix(defaultPrefix).delete(key),\n clear: () => getFunctionsForPrefix(defaultPrefix).clear(),\n get size() {\n return getFunctionsForPrefix(defaultPrefix).size;\n },\n entries: () => getFunctionsForPrefix(defaultPrefix).entries(),\n keys: () => getFunctionsForPrefix(defaultPrefix).keys(),\n values: () => getFunctionsForPrefix(defaultPrefix).values(),\n forEach: (\n callback: (\n value: ServerFnEntry,\n key: string,\n map: Map<string, ServerFnEntry>,\n ) => void,\n ) => getFunctionsForPrefix(defaultPrefix).forEach(callback),\n [Symbol.iterator]: () =>\n getFunctionsForPrefix(defaultPrefix)[Symbol.iterator](),\n} as unknown as Map<string, ServerFnEntry>;\n","/**\n * @module User-facing message strings.\n *\n * Two shapes live here: plain message constants (the exact text an RPC\n * response body carries) and message *factories* for the cases that need a\n * value interpolated. Both are part of the wire contract for the bodies below,\n * so the casing is deliberate — e.g. a client matching on\n * `METHOD_NOT_ALLOWED` must see `\"Method Not Allowed\"`, not `\"Method not\n * allowed\"`. These strings are also what keeps error responses generic: they\n * never include the requested function name, so a response cannot be used to\n * enumerate what exists.\n */\n/** Thrown-name for an operation stopped by its own `cancel()`. */\nexport const OPERATION_ABORTED = \"Operation aborted\";\n\n/** Warning text used when a request is cancelled by an HTTP 408/499 response. */\nexport const REQUEST_CANCELLED = \"Request was cancelled\";\n\n/** Prefix of the `Error` message the client helpers throw for a non-OK HTTP response. The status text is appended; the response body is deliberately not read, so server-side detail never reaches the client through this path. */\nexport const FETCH_ERROR_PREFIX = \"Fetch error: \";\n\n/** Warning logged when a scanned server module exports nothing. */\nexport const NO_SERVER_FUNCTION_FOUND = \"No server function found.\";\n\n/** Error logged when a server function file cannot be loaded by Vite's SSR loader. */\nexport const ERROR_LOADING_FILE = \"Error loading file:\";\n\n/** Body of a 404. Deliberately does not name the requested function. */\nexport const FUNCTION_NOT_FOUND = \"Function not found\";\n\n/** Body of a 405, returned when the HTTP method does not match the function's declared method. */\nexport const METHOD_NOT_ALLOWED = \"Method Not Allowed\";\n\n/** Body of a 403, returned when the optional origin allowlist rejects the request. */\nexport const REQUEST_FORBIDDEN = \"Forbidden\";\n\n/** Body of a 415, returned when the request's `Content-Type` does not satisfy the function's declared `contentType`. */\nexport const UNSUPPORTED_MEDIA_TYPE = \"Unsupported Media Type\";\n\n/** Body of a 413, returned when the request body exceeds the host's configured size limit. */\nexport const PAYLOAD_TOO_LARGE = \"Payload Too Large\";\n\n/** Body of a 400, returned when a GET `?args=` value parses but is not an array. */\nexport const BAD_REQUEST = \"Bad Request\";\n\n/** Body of a 500. Always generic — never the underlying error, so internals cannot leak. */\nexport const INTERNAL_SERVER_ERROR = \"Internal Server Error\";\n\n/** Abort reason used when the client disconnects mid-dispatch. */\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\n/** Warning logged when no config file is discovered and the defaults are used. */\nexport const NO_CONFIG_FOUND =\n ` ⚡︎ No RPC config found, loading the defaults..`;\n\n/** Warning logged when a config file exists but could not be loaded; the defaults are used. */\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, `isFormContentType` and `hasContentTypeMismatch` for content-type validation, and `walkGlobFiles` for recursively discovering `*.server.*` files. Never import this module in client code — it is server-only. */\nimport type { ContentType, JsonObject, JsonValue } from \"./types.d.ts\";\nimport { readdir } from \"node:fs/promises\";\nimport { join } from \"node:path\";\n\nimport {\n BAD_REQUEST,\n INTERNAL_SERVER_ERROR,\n PAYLOAD_TOO_LARGE,\n UNSUPPORTED_MEDIA_TYPE,\n} from \"./constants.ts\";\nimport { defaultPrefix } from \"./options.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\n/**\n * Checks whether a content type maps to a form encoding\n * (`multipart/form-data` or `application/x-www-form-urlencoded`).\n * Form-declared functions accept either encoding so native browser\n * submissions (urlencoded) keep working without JavaScript.\n */\n/**\n * An error carrying an HTTP status, so the dispatch can answer that status\n * instead of flattening every failure to a `500`.\n */\nexport interface ClientHttpError extends Error {\n status?: number;\n statusCode?: number;\n}\n\n/**\n * Tags an error with an HTTP status for the dispatch to surface.\n *\n * Used where a malformed *request* is the fault — a body that does not parse\n * under a declared JSON `Content-Type`, a GET `?args=` value that is not valid\n * JSON. Every host framework rpc supports answers `400` for these (Express\n * `entity.parse.failed`, Fastify `FST_ERR_CTP_INVALID_JSON_BODY`, koa-bodyparser,\n * and h3's own `readBody`), and treating one as a server fault both misreports\n * the fault and turns a trivial client mistake into a log entry.\n * @param status - The HTTP status to answer with\n * @param message - Internal diagnostic message; never sent to the client\n * @returns An `Error` carrying `status`\n */\nexport const httpError = (status: number, message: string): ClientHttpError => {\n const err = new Error(message) as ClientHttpError;\n err.status = status;\n return err;\n};\n\n/**\n * Recognises an error that should produce a `4xx` response rather than a `500`.\n *\n * Matches the `status` / `statusCode` convention used by h3's `HTTPError`, the\n * `http-errors` objects Express's `body-parser` throws, and anything else that\n * carries a numeric 4xx. Shared by all five adapters so a host-framework\n * signal and an rpc-raised one are handled by the same rule.\n * @param err - The caught error\n * @returns True when the error denotes a client (4xx) fault\n */\nconst readClientStatus = (err: unknown): number | undefined => {\n const candidate = err as ClientHttpError | null | undefined;\n // Reads both conventions: h3's `HTTPError` and rpc's `httpError` use\n // `status`, while the `http-errors` objects Express's `body-parser` throws\n // and Koa's `ctx.throw` use `statusCode`.\n const status = candidate?.status ?? candidate?.statusCode;\n return typeof status === \"number\" && status >= 400 && status < 500\n ? status\n : undefined;\n};\n\n/**\n * Recognises an error that should produce a `4xx` response rather than a `500`.\n * Matches the `status` / `statusCode` convention used by h3's `HTTPError`, the\n * `http-errors` objects Express's `body-parser` throws, and anything else\n * carrying a numeric 4xx. Shared by all five adapters so a host-framework\n * signal and an rpc-raised one are handled by the same rule.\n * @param err - The caught error\n * @returns True when the error denotes a client (4xx) fault\n */\nexport const isClientHttpError = (err: unknown): boolean =>\n readClientStatus(err) !== undefined;\n\n/**\n * Reads the status to answer for a client error. Defaults to `400` rather than\n * `500` so an unrecognised 4xx is never reported as a server fault.\n * @param err - The caught error\n * @returns The 4xx status to answer with\n */\nexport const clientErrorStatus = (err: unknown): number =>\n readClientStatus(err) ?? 400;\n\nexport const clientErrorMessage = (status: number): string => {\n if (status === 413) return PAYLOAD_TOO_LARGE;\n if (status === 415) return UNSUPPORTED_MEDIA_TYPE;\n return BAD_REQUEST;\n};\n\nexport const isFormContentType = (contentType: string): boolean =>\n contentType === \"multipart/form-data\" ||\n contentType === \"application/x-www-form-urlencoded\";\n\n/**\n * Detects whether an incoming request's `Content-Type` header conflicts\n * with the function's declared content type. JSON and text functions are\n * enforced strictly (exact match wins), while form functions accept both\n * form encodings because the nojs fallback submits urlencoded forms to\n * multipart-declared endpoints. Requests without a `Content-Type` header\n * (curl, GET, legacy clients) are exempt from enforcement.\n * @param declared - The declared `contentType` from the server function options\n * @param rawHeader - The raw `Content-Type` request header, if present\n */\nexport const hasContentTypeMismatch = (\n declared: ContentType,\n rawHeader: string | undefined,\n): boolean => {\n // No Content-Type header → exempt (url bar, GET, curl compatibility)\n if (!rawHeader) return false;\n // Strip parameters (charset, boundary) before comparison\n const incomingType = rawHeader.trim().toLowerCase().split(\";\")[0].trim();\n if (isFormContentType(declared)) {\n // Forms: reject only non-form encodings (lenient between the two)\n return !isFormContentType(incomingType);\n }\n return incomingType !== declared;\n};\n\n/**\n * Decides whether a request may proceed, given the configured origin allowlist\n * and the two headers a browser can be made to reveal.\n *\n * Four tiers, evaluated in order — the first tier with a signal decides:\n *\n * 1. `origin` option unset → everything passes. No validation is performed.\n * 2. `Origin` present → the allowlist decides, exactly as {@link isOriginAllowed}.\n * 3. `Origin` absent but `Sec-Fetch-Site` present → allow only `same-origin`\n * and `none`; anything else (including an unrecognised value) is rejected.\n * 4. Both absent → passes. This is the deliberate, documented curl/native hole.\n *\n * Tier 2 must short-circuit ahead of tier 3. `Sec-Fetch-Site` is a coarse\n * four-value enum that cannot name a host, so on its own it would reject a\n * legitimate request from an allowlisted sibling subdomain (`same-site`). The\n * allowlist exists precisely to admit that case, and it can only do so while\n * `Origin` survives. `Sec-Fetch-Site` earns a vote only once the precise signal\n * has been stripped away by something in the chain — at which point there is\n * nothing left to trust, so it fails closed.\n *\n * Browsers never strip `Origin` themselves, so tier 3 can only fire when a\n * proxy, sanitising middleware, or misconfigured CDN removed it. No legitimate\n * browser request can regress.\n *\n * An empty (or whitespace-only) header value counts as **absent**, not as an\n * unrecognised signal. No browser emits an empty `Sec-Fetch-Site`, and adapters\n * disagree on what their header accessor returns for a missing header (Node's\n * `req.headers` yields `undefined`, Hono's `c.req.header()` may yield `\"\"`).\n * Normalising here keeps all five adapters behaving identically instead of\n * inheriting whichever convention their framework happens to use.\n * @param allowed - The configured `origin` option, if any\n * @param origin - The raw `Origin` request header, if present\n * @param site - The raw `Sec-Fetch-Site` request header, if present\n * @returns `true` when the request may proceed\n */\nexport const isOriginRequestAllowed = (\n allowed: string | string[] | undefined,\n origin: string | undefined,\n site: string | undefined,\n): boolean => {\n if (!allowed) return true; // tier 1 — the check is opt-in\n if (origin?.trim()) return isOriginAllowed(allowed, origin); // tier 2 — precise\n if (!site?.trim()) return true; // tier 4 — curl / native client\n // tier 3 — precision lost, so fail closed\n return site === \"same-origin\" || site === \"none\";\n};\n\n/**\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\n/**\n * Decides whether a request's `Origin` header is allowed by the configured\n * allowlist. Shared by all five adapters so the rule lives in exactly one place.\n *\n * - No `allowed` value (option unset) → everything passes: no validation.\n * - No `requestOrigin` header → passes, preserving curl/native-client access.\n * - Otherwise the header must match one of the entries exactly.\n *\n * A single string and a one-element array behave identically, so widening\n * `origin` to `string | string[]` is backward compatible.\n *\n * `Origin: null` (sandboxed iframes, `file://`, extension pages) is rejected\n * whenever an allowlist is set, because it never equals a real origin.\n * @param allowed - The configured `origin` option, if any\n * @param requestOrigin - The raw `Origin` request header, if present\n * @returns `true` when the request may proceed\n */\nexport const isOriginAllowed = (\n allowed: string | string[] | undefined,\n requestOrigin: string | undefined,\n): boolean => {\n if (!allowed || !requestOrigin) return true;\n return Array.isArray(allowed)\n ? allowed.includes(requestOrigin)\n : requestOrigin === allowed;\n};\n\nconst SAFE_URL_BASE = \"http://localhost\";\n\n/**\n * Parses a raw request URL against a fixed base without ever throwing.\n * Malformed request-targets (e.g. `/\\`, `//`, `/\\/`) make the WHATWG URL\n * parser throw `TypeError: Invalid URL`; the adapters call this while\n * building the per-request URL **before** their dispatch `try` block, so an\n * unhandled rejection there crashes raw `node:http` hosts (and Express 4).\n * On failure we fall back to the base root: the resulting pathname never\n * matches the RPC prefix, so the request is treated as non-RPC and falls\n * through to `next()` / 404 instead of crashing the process.\n * @param rawUrl - Raw request URL (path + optional query string)\n * @param base - Optional base URL, defaults to a fixed localhost origin\n * @returns A URL object; never throws\n */\nexport const safeURL = (rawUrl: string, base = SAFE_URL_BASE): URL => {\n try {\n return new URL(rawUrl, base);\n } catch {\n return new URL(\"/\", base);\n }\n};\n\nconst globalPrefixSymbol = Symbol.for(\"thednp.rpc.globalPrefix\");\n\n/** Global rpcPrefix from the last loaded config / middleware — fallback for functions without explicit prefix. */\nexport const getGlobalPrefix = (): string | undefined =>\n (globalThis as unknown as Record<symbol, string | undefined>)[\n globalPrefixSymbol\n ];\n\n/**\n * Publishes the global RPC prefix, consulted by `resolveRPCPrefix` whenever no\n * explicit prefix is supplied. `loadRPCConfig` calls this on every return path\n * so a loaded config is the fallback for later registrations and dispatches.\n *\n * Stored on a `Symbol.for` key on `globalThis` so it stays instance-stable\n * across the bundled entry copies (`server.mjs`, `express.mjs`, ...) and dev\n * server hot reloads — the same technique as the request-context storage.\n * @param prefix - The prefix to publish, or `undefined` to clear it\n */\nexport const setGlobalPrefix = (prefix: string | undefined): void => {\n if (prefix) {\n (globalThis as unknown as Record<symbol, string | undefined>)[\n globalPrefixSymbol\n ] = prefix;\n } else {\n delete (globalThis as unknown as Record<symbol, string | undefined>)[\n globalPrefixSymbol\n ];\n }\n};\n\n/**\n * Resolves the effective RPC prefix: the explicit one when given, otherwise\n * the global prefix set by `setGlobalPrefix` / `loadRPCConfig`, otherwise the\n * built-in default.\n *\n * Every adapter resolves its prefix through this single function — in both the\n * outer `createMiddleware` gate and the `createRPCMiddleware` dispatch — so the\n * two halves of a request can never disagree, and so a prefix registered by\n * `createServerFunction` (which resolves the same way) is always the prefix the\n * middleware looks up. Resolving the two sides independently is what allowed\n * h3 to drift from the other four adapters, and what left the documented\n * global-prefix flow returning 404 on all of them.\n * @param rpcPrefix - Explicit prefix from config or middleware options\n * @returns The prefix to gate on, look up in, and strip from the request path\n */\nexport const resolveRPCPrefix = (rpcPrefix?: string): string =>\n rpcPrefix || getGlobalPrefix() || defaultPrefix;\n","// src/h3/helpers.ts\nimport type { H3Event, Middleware } from \"h3\";\nimport { HTTPResponse, redirect as h3Redirect } from \"h3\";\nimport type { IncomingMessage, ServerResponse } from \"node:http\";\nimport type { ViteDevServer } from \"vite\";\nimport type { BodyResult } from \"@thednp/rpc\";\nimport type { H3App } from \"./types.d.ts\";\nimport { createRPCMiddleware } from \"./createMiddleware.ts\";\nimport { httpError } from \"../server-helpers.ts\";\n\n/**\n * Convenience function to load RPC config and attach the RPC middleware to an h3 app.\n * Dynamically imports loadRPCConfig and registers the middleware.\n * @param app - h3 application instance\n */\nexport async function attachRPC(app: H3App) {\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 const options = await loadRPCConfig();\n\n app.use(createRPCMiddleware(options));\n}\n\n/**\n * Attaches Vite's dev server middlewares to an h3 app for development mode.\n * Uses the viteMiddleware wrapper to bridge Vite's Connect-compatible stack into h3.\n * @param app - h3 application instance\n * @param vite - Running Vite dev server\n */\nexport const attachVite = (app: H3App, vite: ViteDevServer): void => {\n app.use(viteMiddleware(vite));\n};\n\n/**\n * Creates an h3-compatible middleware from a Vite dev server middleware stack.\n * Bridges the Connect/Express middleware interface to h3's event-based request/response model.\n * Supports both Node.js and web runtimes with separate polyfill paths.\n * @param vite - Running Vite dev server\n * @returns An h3 middleware function\n */\nexport const viteMiddleware = (vite: ViteDevServer): Middleware => {\n return (event, next) =>\n new Promise((resolve) => {\n const node = event.runtime?.node;\n if (node?.req && node?.res) {\n const nodeReq = node.req;\n const nodeRes = node.res;\n // ─── Node.js runtime ─────────────────────────────────────────────\n // Forward to the real node req/res: if the Vite/Connect stack writes\n // the response, the socket is already flushed (dev-mode asset serving,\n // HMR). Connect never calls the final callback once a middleware has\n // written the response, so also settle on the response lifecycle\n // events. Stop the chain with an empty response once the socket is\n // used; the runtime's write guard prevents a second write. When the\n // stack passes through, continue to the next middleware.\n let settled = false;\n const settle = (value: unknown) => {\n // istanbul ignore if\n if (settled) return;\n settled = true;\n resolve(value);\n };\n nodeRes.once(\"close\", () => settle(new Response(null)));\n nodeRes.once(\"finish\", () => settle(new Response(null)));\n vite.middlewares(\n nodeReq as IncomingMessage,\n nodeRes as ServerResponse,\n () => {\n if (nodeRes.writableEnded || nodeRes.headersSent) {\n settle(new Response(null));\n } else {\n settle(next());\n }\n },\n );\n return;\n }\n\n // ─── Web runtime fallback ──────────────────────────────────────────\n let sent = false;\n const headers = new Headers();\n const req = {\n url: event.url.pathname + event.url.search,\n method: event.req.method,\n headers: Object.fromEntries(event.req.headers),\n } as IncomingMessage;\n const res = {\n setHeader(name: string, value: unknown) {\n headers.set(name, String(value));\n return this;\n },\n writeHead(status: number) {\n void status;\n return this;\n },\n end(body?: unknown) {\n sent = true;\n resolve(\n new HTTPResponse(body == null ? \"\" : (body as BodyInit), {\n headers,\n }),\n );\n return this;\n },\n } as ServerResponse;\n vite.middlewares(req, res, () => {\n if (!sent) resolve(next());\n });\n });\n};\n\n/**\n * Reads and parses the HTTP request body from an h3 event.\n * Supports JSON, text, urlencoded, and multipart content types.\n * @param event - h3 event object\n * @returns A promise resolving to the parsed body with its content type\n */\nexport const readBody = async (event: H3Event): Promise<BodyResult> => {\n const contentType = event.req.headers.get(\"content-type\")?.toLowerCase() ||\n \"\";\n const isJSON = contentType.includes(\"json\");\n const isMultipart = contentType.includes(\"multipart/form-data\");\n const isUrlEncoded = contentType.includes(\"urlencoded\");\n // Note: `text()` stays outside the try below on purpose — h3's body limit\n // throws its own 413 from here, and relabelling that as a 400 would\n // misreport an oversize body as a malformed one.\n const text = await event.req.text();\n if (isJSON) {\n let data: unknown;\n try {\n data = JSON.parse(text);\n } catch {\n // h3's own `readBody` throws a 400 here; rpc parses the body itself and\n // used to let the SyntaxError escape as a 500. Match the host.\n throw httpError(400, \"Invalid JSON body\");\n }\n return {\n contentType: \"application/json\",\n data,\n } as BodyResult;\n }\n return {\n contentType: isMultipart\n ? \"multipart/form-data\"\n : isUrlEncoded\n ? \"application/x-www-form-urlencoded\"\n : \"text/plain\",\n data: isMultipart\n ? ({ raw: text } as Record<string, unknown>)\n : isUrlEncoded\n ? Object.fromEntries(new URLSearchParams(text))\n : String(text),\n } as BodyResult;\n};\n\n/**\n * Issues an HTTP redirect. h3's `redirect()` returns an `HTTPResponse`\n * object that the handler must return (it never writes directly). Defaults\n * to `303 See Other` for convention (Post/Redirect/Get).\n * @param location - The URL to redirect to\n * @param status - HTTP status code, defaults to 303\n * @returns An h3 `HTTPResponse` to return from the handler\n */\nexport const redirect = (\n location: string,\n status = 303,\n): HTTPResponse => {\n return h3Redirect(\n location,\n status,\n status === 303 ? \"See Other\" : undefined,\n );\n};\n","// src/h3/createMiddleware.ts\nimport type { H3Event, Middleware } from \"h3\";\nimport type { H3MiddlewareFn, H3MiddlewareOptions } from \"./types.d.ts\";\nimport type { JsonValue } from \"@thednp/rpc\";\nimport type { RequestEvent } from \"@thednp/rpc/server\";\nimport {\n clientErrorMessage,\n clientErrorStatus,\n escapeRegExp,\n formatError,\n hasContentTypeMismatch,\n isClientHttpError,\n isOriginRequestAllowed,\n provideRequestContext,\n resolveRPCPrefix,\n scanForServerFiles,\n} from \"@thednp/rpc/server\";\nimport { getFunctionsForPrefix } from \"../functionsMap.ts\";\nimport {\n BAD_REQUEST,\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\";\nimport { defaultMiddlewareOptions } from \"../options.ts\";\nimport { readBody, redirect as h3Redirect } from \"./helpers.ts\";\n\nlet middlewareCount = 0;\nconst middlewareStack = new Set<string>();\n\n/**\n * Creates an h3 middleware with optional path and rpcPrefix filtering.\n * Middleware names are deduplicated. Prefix and path regexes are compiled once at creation time.\n * h3 URL is normalized via `event.url` (query strings are not part of the pathname).\n * @param initialOptions - Options for rpcPrefix, path matching, and the handler function\n * @returns An h3 middleware function\n */\nexport const createMiddleware: H3MiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as H3MiddlewareOptions;\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 // Resolved once at creation time so the hoisted regex and the\n // function-map lookup can never disagree. `createRPCMiddleware` hands\n // over its already-resolved prefix, making this a no-op in that path.\n const resolvedPrefix = resolveRPCPrefix(rpcPrefix);\n // Gated only when an explicit prefix was supplied: a bare\n // `createMiddleware({ path, handler })` has never prefix-gated.\n // `createRPCMiddleware` always supplies one, so RPC dispatch does.\n const prefixRegex: RegExp | null = rpcPrefix\n ? new RegExp(`^/${escapeRegExp(resolvedPrefix)}/`)\n : null;\n const pathMatcher: RegExp | null = path\n ? (typeof path === \"string\" ? new RegExp(path) : path)\n : null;\n\n const middlewareHandler: Middleware = async (event: H3Event, next) => {\n const url = event.url.pathname;\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 // When serving from production server, scan for server files\n if (getFunctionsForPrefix(resolvedPrefix).size === 0) {\n await scanForServerFiles({\n rpcPrefix: resolvedPrefix,\n serverFiles: (options as unknown as { serverFiles?: \"exact\" | \"glob\" })\n .serverFiles,\n scanRoot: (options as unknown as { scanRoot?: string }).scanRoot,\n } as never);\n }\n\n return handler(event, next);\n };\n\n Object.defineProperty(middlewareHandler, \"name\", {\n value: name,\n });\n\n return middlewareHandler;\n};\n\n/**\n * Creates the h3 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 returns the JSON-serialized result.\n * @param initialOptions - Options including rpcPrefix for URL routing\n * @returns An h3 middleware function\n */\nexport const createRPCMiddleware: H3MiddlewareFn = (initialOptions = {}) => {\n const options = Object.assign(\n {},\n defaultMiddlewareOptions,\n initialOptions,\n ) as H3MiddlewareOptions;\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 prefix = resolveRPCPrefix(rpcPrefix);\n const prefixRegex = new RegExp(`^/${escapeRegExp(prefix)}/`);\n const prefixReplace = `/${prefix}/`;\n\n return createMiddleware({\n ...options,\n // Hand the resolved prefix down so the gate and the dispatch agree.\n rpcPrefix: prefix,\n handler: async (event: H3Event, _next?: () => unknown) => {\n const url = event.url.pathname;\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 if\n if (prefixRegex && !prefixRegex.test(url)) {\n /* istanbul ignore next */\n return undefined;\n }\n\n // Optional origin check. When Origin survives, the allowlist decides;\n // when it has been stripped, Sec-Fetch-Site is consulted instead and\n // fails closed. See `isOriginRequestAllowed` for the four tiers.\n if (\n !isOriginRequestAllowed(\n options.origin,\n event.req.headers.get(\"origin\") ?? undefined,\n event.req.headers.get(\"sec-fetch-site\") ?? undefined,\n )\n ) {\n event.res.status = 403;\n return { error: REQUEST_FORBIDDEN };\n }\n\n const functionName = url.replace(prefixReplace, \"\");\n const serverFunction = getFunctionsForPrefix(prefix).get(functionName);\n\n if (!serverFunction) {\n event.res.status = 404;\n return { error: FUNCTION_NOT_FOUND };\n }\n\n try {\n const method = serverFunction.options?.method || \"POST\";\n if (event.req.method.toUpperCase() !== method) {\n event.res.status = 405;\n return { error: METHOD_NOT_ALLOWED };\n }\n\n let args: JsonValue[] = [];\n if (method === \"GET\") {\n const raw = event.url.searchParams.get(\"args\");\n if (raw) {\n let parsed: unknown;\n try {\n parsed = JSON.parse(raw);\n } catch {\n // A malformed `?args=` is a malformed request, not a server\n // fault, so it answers 400 like the non-array case above.\n event.res.status = 400;\n return { error: BAD_REQUEST };\n }\n if (!Array.isArray(parsed)) {\n event.res.status = 400;\n return { error: BAD_REQUEST };\n }\n args = parsed as JsonValue[];\n }\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 event.req.headers.get(\"content-type\") ?? undefined,\n )\n ) {\n event.res.status = 415;\n return { error: UNSUPPORTED_MEDIA_TYPE };\n }\n const body = await readBody(event);\n args = Array.isArray(body.data)\n ? body.data as JsonValue[]\n : [body.data as JsonValue];\n }\n const requestEvent: RequestEvent = {\n request: event.req,\n response: event.res,\n nativeEvent: event,\n locals: event.context,\n functionName,\n // h3's `redirect()` returns an `HTTPResponse` (never writes directly),\n // so the bound redirect/send only record the intent; the middleware\n // uses them after the dispatch to return the response body.\n redirect: (location, status = 303) => {\n requestEvent.redirected = { location, status };\n },\n send: (status, body, headers) => {\n requestEvent.sent = { status, body, headers };\n },\n };\n const fnResult = provideRequestContext(\n requestEvent,\n () => serverFunction.handler(...args),\n );\n const onClose = () => fnResult.cancel(CLIENT_DISCONNECTED);\n // The node runtime gives us the raw incoming stream for close events;\n // other runtimes have no node req, so the abort hook is skipped.\n const nodeReq = event.runtime?.node?.req;\n if (nodeReq) nodeReq.on(\"close\", onClose);\n const result = await fnResult.data;\n if (nodeReq) nodeReq.off(\"close\", onClose);\n\n if (requestEvent.redirected) {\n return h3Redirect(\n requestEvent.redirected.location,\n requestEvent.redirected.status,\n );\n }\n\n if (requestEvent.sent) {\n const { status, body, headers } = requestEvent.sent;\n event.res.status = status;\n if (headers) {\n for (const [name, value] of Object.entries(headers)) {\n event.res.headers.set(name, value);\n }\n }\n return body;\n }\n\n return { data: result };\n } catch (err) {\n // h3 enforces its body limit while the stream is *read*, so an\n // oversized chunked request (one with no `Content-Length` to check up\n // front) throws here — inside this try — as an h3 error carrying status\n // 413. The other four adapters get their 413 from the host body parser\n // before rpc is reached, so flattening this to 500 would make h3 the\n // only adapter that reports an oversize body as a server fault. The\n // same rule covers rpc's own 400s for a malformed body or `?args`.\n if (isClientHttpError(err)) {\n const status = clientErrorStatus(err);\n event.res.status = status;\n return { error: clientErrorMessage(status) };\n }\n console.error(String(err));\n const isProduction = process.env.NODE_ENV === \"production\";\n event.res.status = 500;\n return formatError(err, isProduction);\n }\n },\n });\n};\n"],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.d.mts","names":[],"sources":["../../src/types.d.ts","../../src/client-helpers.ts"],"mappings":";;;;;;;;;;;;;;;KAkEY;;;;KASA;;;;;KA+CA;;;;KAIA;GAAgB,cAAc,YAAY;;;;;KAI1C,aAAa,WAAW;;;;KAIxB,YAAY,gBAAgB,YAAY;;;;;;KAgDxC,eACV,cAAc,YAAY,WAC1B,UAAU,iBACJ,MAAM;;EAEZ,MAAM,QAAQ;;EAEd,SAAS;;;;;;UAwMM;;;;;EAKf;;;;;EAKA,aAAa;;;;;;;EAOb,aAAa;;;;;;KAOH,eAAe,UAAU;;EAEnC,MAAM,QAAQ;;EAEd,SAAS;;;;;;;;;;;;qBC9YE,iBAAwB,UAAU,WAAS,UAC5C,aACT,QAAQ;;;;;;;;;;;;;;;;;;;;;;;qBAkCE,iBAAkB,GAAC,kBAAkB;;;;;;;;;;;;;;;;;;;;wBA0HlC,cAAc,UAAU,WAAW,UAAU,WAC3D,gBACA,cACA,UAAU,QAAQ,eACjB,eAAe,GAAG;;;;;;;;;;;;;;qBAiBR,cAAe,UAAU,WAAS,MACvC,UAAQ,SACL,aAAW,aACP,aAAW,gBACV,cACF,4BAEX,eAAe"}
1
+ {"version":3,"file":"helpers.d.mts","names":[],"sources":["../../src/types.d.ts","../../src/client-helpers.ts"],"mappings":";;;;;;;;;;;;;;;KA8EY;;;;KASA;;;;;KA+CA;;;;KAIA;GAAgB,cAAc,YAAY;;;;;KAI1C,aAAa,WAAW;;;;KAIxB,YAAY,gBAAgB,YAAY;;;;;;KAgDxC,eACV,cAAc,YAAY,WAC1B,UAAU,iBACJ,MAAM;;EAEZ,MAAM,QAAQ;;EAEd,SAAS;;;;;;UAiMM;;;;;EAKf;;;;;EAKA,aAAa;;;;;;;EAOb,aAAa;;;;;;KAOH,eAAe,UAAU;;EAEnC,MAAM,QAAQ;;EAEd,SAAS;;;;;;;;;;;;qBCnZE,iBAAwB,UAAU,WAAS,UAC5C,aACT,QAAQ;;;;;;;;;;;;;;;;;;;;;;;qBAkCE,iBAAkB,GAAC,kBAAkB;;;;;;;;;;;;;;;;;;;;wBA0HlC,cAAc,UAAU,WAAW,UAAU,WAC3D,gBACA,cACA,UAAU,QAAQ,eACjB,eAAe,GAAG;;;;;;;;;;;;;;qBAiBR,cAAe,UAAU,WAAS,MACvC,UAAQ,SACL,aAAW,aACP,aAAW,gBACV,cACF,4BAEX,eAAe"}
@@ -1,5 +1,7 @@
1
1
  //#region src/constants.ts
2
+ /** Warning text used when a request is cancelled by an HTTP 408/499 response. */
2
3
  const REQUEST_CANCELLED = "Request was cancelled";
4
+ /** Prefix of the `Error` message the client helpers throw for a non-OK HTTP response. The status text is appended; the response body is deliberately not read, so server-side detail never reaches the client through this path. */
3
5
  const FETCH_ERROR_PREFIX = "Fetch error: ";
4
6
  //#endregion
5
7
  //#region src/client-helpers.ts