diff --git a/packages/next/src/utilities/handleServerFunctions.ts b/packages/next/src/utilities/handleServerFunctions.ts index 3af73b1f71e..c44e831eb61 100644 --- a/packages/next/src/utilities/handleServerFunctions.ts +++ b/packages/next/src/utilities/handleServerFunctions.ts @@ -53,7 +53,13 @@ export const handleServerFunctions: ServerFunctionHandler = async (args) => { req, } - const fn = extraServerFunctions?.[fnKey] || baseServerFunctions[fnKey] + // Lookup order: + // 1. integrator-supplied `serverFunctions` prop on (highest priority — back-compat) + // 2. `config.admin.serverFunctions` registry (populated by plugins via payload.config.ts) + // 3. built-in baseServerFunctions + const adminServerFunctions = req.payload.config.admin?.serverFunctions + const fn = + extraServerFunctions?.[fnKey] || adminServerFunctions?.[fnKey] || baseServerFunctions[fnKey] if (!fn) { throw new Error(`Unknown Server Function: ${fnKey}`) diff --git a/packages/payload/src/config/defaults.ts b/packages/payload/src/config/defaults.ts index 1cf5e2b7aa0..498a6cfc6d5 100644 --- a/packages/payload/src/config/defaults.ts +++ b/packages/payload/src/config/defaults.ts @@ -32,6 +32,7 @@ export const defaults: Omit = { reset: '/reset', unauthorized: '/unauthorized', }, + serverFunctions: {}, theme: 'all', }, auth: { @@ -111,6 +112,9 @@ export const addDefaultsToConfig = (config: Config): Config => { unauthorized: '/unauthorized', ...(config?.admin?.routes || {}), }, + serverFunctions: { + ...(config?.admin?.serverFunctions || {}), + }, } config.bin = config.bin ?? [] diff --git a/packages/payload/src/config/sanitize.serverFunctions.spec.ts b/packages/payload/src/config/sanitize.serverFunctions.spec.ts new file mode 100644 index 00000000000..99e713bbdb4 --- /dev/null +++ b/packages/payload/src/config/sanitize.serverFunctions.spec.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vitest' + +import type { Config } from './types.js' + +import { sanitizeConfig } from './sanitize.js' + +/** + * Tests for the `admin.serverFunctions` registry added to the Config surface. + * + * The lookup-order behavior in `handleServerFunctions` is intentionally NOT tested + * here because that handler depends on the full Next.js / payload runtime stack + * (`initReq`, `getPayload`, request headers). Those code paths are exercised by + * the e2e suite in `test/server-functions/`. This file only covers the new + * sanitize-time validation and defaulting that lives in `sanitizeAdminConfig`. + */ +describe('admin.serverFunctions registry — sanitize', () => { + const minimalConfig = (): Config => + // Cast: a fully-typed Config requires fields we don't care about for these tests. + ({ + collections: [], + }) as unknown as Config + + it('defaults `admin.serverFunctions` to an empty object when not provided', async () => { + const sanitized = await sanitizeConfig(minimalConfig()) + + expect(sanitized.admin?.serverFunctions).toBeDefined() + expect(sanitized.admin?.serverFunctions).toEqual({}) + }) + + it('preserves user-supplied server functions on the sanitized config', async () => { + const handler = async () => 'ok' + const config = minimalConfig() + config.admin = { + serverFunctions: { + '@my-plugin/foo': handler, + }, + } + + const sanitized = await sanitizeConfig(config) + + expect(sanitized.admin?.serverFunctions?.['@my-plugin/foo']).toBe(handler) + }) + + it('throws when a server function value is not a function', async () => { + const config = minimalConfig() + config.admin = { + // @ts-expect-error — intentionally invalid + serverFunctions: { 'bad/key': 'not a function' }, + } + + await expect(sanitizeConfig(config)).rejects.toThrow(/admin\.serverFunctions\["bad\/key"\]/) + }) + + it('throws when a server function key is an empty string', async () => { + const config = minimalConfig() + config.admin = { + serverFunctions: { + '': async () => 'ok', + }, + } + + await expect(sanitizeConfig(config)).rejects.toThrow( + /admin\.serverFunctions: keys must be non-empty strings/, + ) + }) +}) diff --git a/packages/payload/src/config/sanitize.ts b/packages/payload/src/config/sanitize.ts index e65799c6e6d..d41d4f2b910 100644 --- a/packages/payload/src/config/sanitize.ts +++ b/packages/payload/src/config/sanitize.ts @@ -110,6 +110,29 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial timezones: sanitizedConfig.admin!.timezones.supportedTimezones as Timezone[], }) + // Validate `admin.serverFunctions` registry. This is intentionally lightweight: + // - Keys must be non-empty strings (`Object.entries` already gives us strings, so + // we only need to reject the empty-string case which would silently break lookup). + // - Values must be functions; anything else means the plugin/integrator wired it up + // incorrectly and would otherwise fail with an opaque error at call time. + // We do not warn on cross-plugin key collisions here because plugins mutate the + // config one-by-one before `sanitizeConfig` runs — by the time we see the merged + // map the colliding entry is already overwritten. Plugins are expected to + // namespace their keys (e.g. `'@my-plugin/foo'`). + const serverFunctions = sanitizedConfig.admin?.serverFunctions + if (serverFunctions) { + for (const [key, value] of Object.entries(serverFunctions)) { + if (!key) { + throw new InvalidConfiguration(`admin.serverFunctions: keys must be non-empty strings.`) + } + if (typeof value !== 'function') { + throw new InvalidConfiguration( + `admin.serverFunctions["${key}"]: value must be a function, received ${typeof value}.`, + ) + } + } + } + return sanitizedConfig as unknown as Partial } diff --git a/packages/payload/src/config/types.ts b/packages/payload/src/config/types.ts index 8bbfcbf8eb3..416fa47d1ad 100644 --- a/packages/payload/src/config/types.ts +++ b/packages/payload/src/config/types.ts @@ -16,6 +16,7 @@ import type React from 'react' import type { default as sharp } from 'sharp' import type { DeepRequired } from 'ts-essentials' +import type { ServerFunction } from '../admin/functions/index.js' import type { RichTextAdapterProvider } from '../admin/RichText.js' import type { CustomStatus, @@ -1084,6 +1085,23 @@ export type Config = { */ unauthorized?: `/${string}` } + /** + * Register custom server functions callable from the admin via + * `useServerFunctions().serverFunction({ name, args })`. + * + * This registry is intended for plugins that need to ship a server function + * without forcing every integrator to import and pass it into the + * `serverFunctions` prop on `` in `(payload)/layout.tsx`. + * + * Plugins should namespace their keys to avoid collisions + * (e.g. `'@my-plugin/render-foo'`). + * + * Lookup order in `handleServerFunctions`: + * 1. integrator-supplied `serverFunctions` prop on `` (highest priority — back-compat) + * 2. `config.admin.serverFunctions` + * 3. built-in payload server functions + */ + serverFunctions?: Record /** * Suppresses React hydration mismatch warnings during the hydration of the root tag. * Useful in scenarios where the server-rendered HTML might intentionally differ from the client-rendered DOM.