///
import crypto from 'node:crypto'
import assert from 'node:assert'
import { TanStackDirectiveFunctionsPluginEnv } from '@tanstack/directive-functions-plugin'
import type { Plugin } from 'vite'
import type {
DirectiveFn,
GenerateFunctionIdFn,
ReplacerFn,
} from '@tanstack/directive-functions-plugin'
export type GenerateFunctionIdFnOptional = (
opts: Omit[0], 'extractedFilename'>,
) => string | undefined
export type TanStackServerFnPluginOpts = {
/**
* This virtual import ID will be used in the server build to import the manifest
* and its modules.
*/
manifestVirtualImportId: string
generateFunctionId?: GenerateFunctionIdFnOptional
callers: Array<
ServerFnPluginEnvOpts & {
envConsumer: 'client' | 'server'
/**
* Custom getServerFnById implementation for server callers.
* Required for server callers that need to load modules from a different
* environment.
*/
getServerFnById?: string
}
>
provider: ServerFnPluginEnvOpts
directive: string
}
export type ServerFnPluginEnvOpts = {
getRuntimeCode: () => string
replacer: ReplacerFn
envName: string
}
const debug =
process.env.TSR_VITE_DEBUG &&
['true', 'server-functions-plugin'].includes(process.env.TSR_VITE_DEBUG)
const validateServerFnIdVirtualModule = `virtual:tanstack-start-validate-server-fn-id`
function parseIdQuery(id: string): {
filename: string
query: {
[k: string]: string
}
} {
if (!id.includes('?')) return { filename: id, query: {} }
const [filename, rawQuery] = id.split(`?`, 2) as [string, string]
const query = Object.fromEntries(new URLSearchParams(rawQuery))
return { filename, query }
}
export function TanStackServerFnPlugin(
opts: TanStackServerFnPluginOpts,
): Array {
const directiveFnsById: Record = {}
const onDirectiveFnsById = (d: Record) => {
if (debug) {
console.info(`onDirectiveFnsById received: `, d)
}
Object.assign(directiveFnsById, d)
if (debug) {
console.info(`directiveFnsById after update: `, directiveFnsById)
}
}
const entryIdToFunctionId = new Map()
const functionIds = new Set()
function withTrailingSlash(path: string): string {
if (path[path.length - 1] !== '/') {
return `${path}/`
}
return path
}
let root = process.cwd()
let command: 'build' | 'serve' = 'build'
const generateFunctionId: GenerateFunctionIdFn = ({
extractedFilename,
functionName,
filename,
}) => {
if (command === 'serve') {
const rootWithTrailingSlash = withTrailingSlash(root)
let file = extractedFilename
if (extractedFilename.startsWith(rootWithTrailingSlash)) {
file = extractedFilename.slice(rootWithTrailingSlash.length)
}
file = `/@id/${file}`
const serverFn: {
file: string
export: string
} = {
file,
export: functionName,
}
const base64 = Buffer.from(JSON.stringify(serverFn), 'utf8').toString(
'base64url',
)
return base64
}
// production build allows to override the function ID generation
const entryId = `${filename}--${functionName}`
let functionId = entryIdToFunctionId.get(entryId)
if (functionId === undefined) {
if (opts.generateFunctionId) {
functionId = opts.generateFunctionId({
functionName,
filename,
})
}
if (!functionId) {
functionId = crypto.createHash('sha256').update(entryId).digest('hex')
}
// Deduplicate in case the generated id conflicts with an existing id
if (functionIds.has(functionId)) {
let deduplicatedId
let iteration = 0
do {
deduplicatedId = `${functionId}_${++iteration}`
} while (functionIds.has(deduplicatedId))
functionId = deduplicatedId
}
entryIdToFunctionId.set(entryId, functionId)
functionIds.add(functionId)
}
return functionId
}
const resolvedManifestVirtualImportId = resolveViteId(
opts.manifestVirtualImportId,
)
const appliedEnvironments = new Set([
...opts.callers
.filter((c) => c.envConsumer === 'server')
.map((c) => c.envName),
opts.provider.envName,
])
const serverCallerEnvironments = new Map(
opts.callers
.filter((c) => c.envConsumer === 'server')
.map((c) => [c.envName, c]),
)
// SSR is the provider when the provider environment is also a server caller environment
// In this case, server-only-referenced functions won't be in the manifest (they're handled via direct imports)
// When SSR is NOT the provider, server-only-referenced functions ARE in the manifest and need isClientReferenced check
const ssrIsProvider = serverCallerEnvironments.has(opts.provider.envName)
return [
// The client plugin is used to compile the client directives
// and save them so we can create a manifest
TanStackDirectiveFunctionsPluginEnv({
directive: opts.directive,
onDirectiveFnsById,
generateFunctionId,
provider: opts.provider,
callers: opts.callers,
// Provide access to known directive functions so SSR callers can use
// canonical extracted filenames from the client build
getKnownDirectiveFns: () => directiveFnsById,
}),
{
name: 'tanstack-start-server-fn-vite-plugin-validate-serverfn-id',
apply: 'serve',
load: {
filter: {
id: new RegExp(resolveViteId(validateServerFnIdVirtualModule)),
},
handler(id) {
const parsed = parseIdQuery(id)
assert(parsed)
assert(parsed.query.id)
if (directiveFnsById[parsed.query.id]) {
return `export {}`
}
this.error(`Invalid server function ID: ${parsed.query.id}`)
},
},
},
{
// On the server, we need to be able to read the server-function manifest from the client build.
// This is likely used in the handler for server functions, so we can find the server function
// by its ID, import it, and call it.
name: 'tanstack-start-server-fn-vite-plugin-manifest-server',
enforce: 'pre',
applyToEnvironment: (env) => {
return appliedEnvironments.has(env.name)
},
configResolved(config) {
root = config.root
command = config.command
},
resolveId: {
filter: { id: new RegExp(opts.manifestVirtualImportId) },
handler() {
return resolvedManifestVirtualImportId
},
},
load: {
filter: { id: new RegExp(resolvedManifestVirtualImportId) },
handler() {
// a different server side environment is used for e.g. SSR and server functions
if (this.environment.name !== opts.provider.envName) {
const getServerFnById = serverCallerEnvironments.get(
this.environment.name,
)?.getServerFnById
if (!getServerFnById) {
throw new Error(
`No getServerFnById implementation found for environment ${this.environment.name}`,
)
}
return getServerFnById
}
if (this.environment.mode !== 'build') {
const mod = `
export async function getServerFnById(id) {
const validateIdImport = ${JSON.stringify(validateServerFnIdVirtualModule)} + '?id=' + id
await import(/* @vite-ignore */ '/@id/__x00__' + validateIdImport)
const decoded = Buffer.from(id, 'base64url').toString('utf8')
const devServerFn = JSON.parse(decoded)
const mod = await import(/* @vite-ignore */ devServerFn.file)
return mod[devServerFn.export]
}
`
return mod
}
// When SSR is the provider, server-only-referenced functions aren't in the manifest,
// so no isClientReferenced check is needed.
// When SSR is NOT the provider (custom provider env), server-only-referenced
// functions ARE in the manifest and need the isClientReferenced check to
// block direct client HTTP requests to server-only-referenced functions.
const includeClientReferencedCheck = !ssrIsProvider
return generateManifestModule(
directiveFnsById,
includeClientReferencedCheck,
)
},
},
},
]
}
/**
* Generates the manifest module code for server functions.
* @param directiveFnsById - Map of function IDs to their directive function info
* @param includeClientReferencedCheck - Whether to include isClientReferenced flag and runtime check.
* This is needed when SSR is NOT the provider, so server-only-referenced functions in the manifest
* can be blocked from client HTTP requests.
*/
function generateManifestModule(
directiveFnsById: Record,
includeClientReferencedCheck: boolean,
): string {
const manifestEntries = Object.entries(directiveFnsById)
.map(([id, fn]) => {
const baseEntry = `'${id}': {
functionName: '${fn.functionName}',
importer: () => import(${JSON.stringify(fn.extractedFilename)})${
includeClientReferencedCheck
? `,
isClientReferenced: ${fn.isClientReferenced ?? true}`
: ''
}
}`
return baseEntry
})
.join(',')
const getServerFnByIdParams = includeClientReferencedCheck ? 'id, opts' : 'id'
const clientReferencedCheck = includeClientReferencedCheck
? `
// If called from client, only allow client-referenced functions
if (opts?.fromClient && !serverFnInfo.isClientReferenced) {
throw new Error('Server function not accessible from client: ' + id)
}
`
: ''
return `
const manifest = {${manifestEntries}}
export async function getServerFnById(${getServerFnByIdParams}) {
const serverFnInfo = manifest[id]
if (!serverFnInfo) {
throw new Error('Server function info not found for ' + id)
}
${clientReferencedCheck}
const fnModule = await serverFnInfo.importer()
if (!fnModule) {
console.info('serverFnInfo', serverFnInfo)
throw new Error('Server function module not resolved for ' + id)
}
const action = fnModule[serverFnInfo.functionName]
if (!action) {
console.info('serverFnInfo', serverFnInfo)
console.info('fnModule', fnModule)
throw new Error(
\`Server function module export not resolved for serverFn ID: \${id}\`,
)
}
return action
}
`
}
function resolveViteId(id: string) {
return `\0${id}`
}