Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion packages/next/src/utilities/handleServerFunctions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <RootLayout /> (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}`)
Expand Down
4 changes: 4 additions & 0 deletions packages/payload/src/config/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export const defaults: Omit<Config, 'db' | 'editor' | 'secret'> = {
reset: '/reset',
unauthorized: '/unauthorized',
},
serverFunctions: {},
theme: 'all',
},
auth: {
Expand Down Expand Up @@ -111,6 +112,9 @@ export const addDefaultsToConfig = (config: Config): Config => {
unauthorized: '/unauthorized',
...(config?.admin?.routes || {}),
},
serverFunctions: {
...(config?.admin?.serverFunctions || {}),
},
}

config.bin = config.bin ?? []
Expand Down
66 changes: 66 additions & 0 deletions packages/payload/src/config/sanitize.serverFunctions.spec.ts
Original file line number Diff line number Diff line change
@@ -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/,
)
})
})
23 changes: 23 additions & 0 deletions packages/payload/src/config/sanitize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,29 @@ const sanitizeAdminConfig = (configToSanitize: Config): Partial<SanitizedConfig>
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<SanitizedConfig>
}

Expand Down
18 changes: 18 additions & 0 deletions packages/payload/src/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 `<RootLayout />` 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 `<RootLayout />` (highest priority — back-compat)
* 2. `config.admin.serverFunctions`
* 3. built-in payload server functions
*/
serverFunctions?: Record<string, ServerFunction>
/**
* Suppresses React hydration mismatch warnings during the hydration of the root <html> tag.
* Useful in scenarios where the server-rendered HTML might intentionally differ from the client-rendered DOM.
Expand Down
Loading