diff --git a/e2e/react-start/server-functions/src/routeTree.gen.ts b/e2e/react-start/server-functions/src/routeTree.gen.ts index ad1492ba6d6..f83dc9146be 100644 --- a/e2e/react-start/server-functions/src/routeTree.gen.ts +++ b/e2e/react-start/server-functions/src/routeTree.gen.ts @@ -37,6 +37,7 @@ import { Route as RedirectTestSsrTargetRouteImport } from './routes/redirect-tes import { Route as MiddlewareServerImportMiddlewareRouteImport } from './routes/middleware/server-import-middleware' import { Route as MiddlewareSendServerFnRouteImport } from './routes/middleware/send-serverFn' import { Route as MiddlewareRequestMiddlewareRouteImport } from './routes/middleware/request-middleware' +import { Route as MiddlewareMiddlewareFactoryRouteImport } from './routes/middleware/middleware-factory' import { Route as MiddlewareClientMiddlewareRouterRouteImport } from './routes/middleware/client-middleware-router' import { Route as CookiesSetRouteImport } from './routes/cookies/set' import { Route as AbortSignalMethodRouteImport } from './routes/abort-signal/$method' @@ -184,6 +185,12 @@ const MiddlewareRequestMiddlewareRoute = path: '/middleware/request-middleware', getParentRoute: () => rootRouteImport, } as any) +const MiddlewareMiddlewareFactoryRoute = + MiddlewareMiddlewareFactoryRouteImport.update({ + id: '/middleware/middleware-factory', + path: '/middleware/middleware-factory', + getParentRoute: () => rootRouteImport, + } as any) const MiddlewareClientMiddlewareRouterRoute = MiddlewareClientMiddlewareRouterRouteImport.update({ id: '/middleware/client-middleware-router', @@ -226,6 +233,7 @@ export interface FileRoutesByFullPath { '/abort-signal/$method': typeof AbortSignalMethodRoute '/cookies/set': typeof CookiesSetRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute + '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute @@ -260,6 +268,7 @@ export interface FileRoutesByTo { '/abort-signal/$method': typeof AbortSignalMethodRoute '/cookies/set': typeof CookiesSetRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute + '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute @@ -295,6 +304,7 @@ export interface FileRoutesById { '/abort-signal/$method': typeof AbortSignalMethodRoute '/cookies/set': typeof CookiesSetRoute '/middleware/client-middleware-router': typeof MiddlewareClientMiddlewareRouterRoute + '/middleware/middleware-factory': typeof MiddlewareMiddlewareFactoryRoute '/middleware/request-middleware': typeof MiddlewareRequestMiddlewareRoute '/middleware/send-serverFn': typeof MiddlewareSendServerFnRoute '/middleware/server-import-middleware': typeof MiddlewareServerImportMiddlewareRoute @@ -331,6 +341,7 @@ export interface FileRouteTypes { | '/abort-signal/$method' | '/cookies/set' | '/middleware/client-middleware-router' + | '/middleware/middleware-factory' | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' @@ -365,6 +376,7 @@ export interface FileRouteTypes { | '/abort-signal/$method' | '/cookies/set' | '/middleware/client-middleware-router' + | '/middleware/middleware-factory' | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' @@ -399,6 +411,7 @@ export interface FileRouteTypes { | '/abort-signal/$method' | '/cookies/set' | '/middleware/client-middleware-router' + | '/middleware/middleware-factory' | '/middleware/request-middleware' | '/middleware/send-serverFn' | '/middleware/server-import-middleware' @@ -434,6 +447,7 @@ export interface RootRouteChildren { AbortSignalMethodRoute: typeof AbortSignalMethodRoute CookiesSetRoute: typeof CookiesSetRoute MiddlewareClientMiddlewareRouterRoute: typeof MiddlewareClientMiddlewareRouterRoute + MiddlewareMiddlewareFactoryRoute: typeof MiddlewareMiddlewareFactoryRoute MiddlewareRequestMiddlewareRoute: typeof MiddlewareRequestMiddlewareRoute MiddlewareSendServerFnRoute: typeof MiddlewareSendServerFnRoute MiddlewareServerImportMiddlewareRoute: typeof MiddlewareServerImportMiddlewareRoute @@ -648,6 +662,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MiddlewareRequestMiddlewareRouteImport parentRoute: typeof rootRouteImport } + '/middleware/middleware-factory': { + id: '/middleware/middleware-factory' + path: '/middleware/middleware-factory' + fullPath: '/middleware/middleware-factory' + preLoaderRoute: typeof MiddlewareMiddlewareFactoryRouteImport + parentRoute: typeof rootRouteImport + } '/middleware/client-middleware-router': { id: '/middleware/client-middleware-router' path: '/middleware/client-middleware-router' @@ -698,6 +719,7 @@ const rootRouteChildren: RootRouteChildren = { AbortSignalMethodRoute: AbortSignalMethodRoute, CookiesSetRoute: CookiesSetRoute, MiddlewareClientMiddlewareRouterRoute: MiddlewareClientMiddlewareRouterRoute, + MiddlewareMiddlewareFactoryRoute: MiddlewareMiddlewareFactoryRoute, MiddlewareRequestMiddlewareRoute: MiddlewareRequestMiddlewareRoute, MiddlewareSendServerFnRoute: MiddlewareSendServerFnRoute, MiddlewareServerImportMiddlewareRoute: MiddlewareServerImportMiddlewareRoute, diff --git a/e2e/react-start/server-functions/src/routes/middleware/index.tsx b/e2e/react-start/server-functions/src/routes/middleware/index.tsx index 060d16f6359..8a12ac30537 100644 --- a/e2e/react-start/server-functions/src/routes/middleware/index.tsx +++ b/e2e/react-start/server-functions/src/routes/middleware/index.tsx @@ -41,6 +41,15 @@ function RouteComponent() { Server imports in middleware are stripped from client build +
  • + + Middleware factories with server imports are stripped from client + build + +
  • ) diff --git a/e2e/react-start/server-functions/src/routes/middleware/middleware-factory.tsx b/e2e/react-start/server-functions/src/routes/middleware/middleware-factory.tsx new file mode 100644 index 00000000000..2c4979ad370 --- /dev/null +++ b/e2e/react-start/server-functions/src/routes/middleware/middleware-factory.tsx @@ -0,0 +1,126 @@ +import { createFileRoute } from '@tanstack/react-router' +import { createMiddleware, createServerFn } from '@tanstack/react-start' +import { getRequestHeaders } from '@tanstack/react-start/server' +import React from 'react' + +/** + * This test verifies that middleware factories (functions that return createMiddleware().server()) + * have their server-only code properly stripped from the client bundle. + * + * If the .server() part inside the factory is not stripped from the client build, this will fail with: + * "Module node:async_hooks has been externalized for browser compatibility" + * because @tanstack/react-start/server uses node:async_hooks internally. + */ + +// Middleware factory function - returns a middleware with .server() call +function createHeaderMiddleware(headerName: string) { + return createMiddleware({ type: 'function' }).server(async ({ next }) => { + // Use a server-only import - this should be stripped from client build + const headers = getRequestHeaders() + const headerValue = headers.get(headerName) ?? 'missing' + + console.log(`[middleware-factory] ${headerName}:`, headerValue) + + return next({ + context: { + headerName, + headerValue, + }, + }) + }) +} + +// Arrow function factory variant +const createPrefixedHeaderMiddleware = (prefix: string) => { + return createMiddleware({ type: 'function' }).server(async ({ next }) => { + // Use a server-only import - this should be stripped from client build + const headers = getRequestHeaders() + const allHeaderNames = [...headers.keys()] + const prefixedHeaders = allHeaderNames.filter((name) => + name.toLowerCase().startsWith(prefix.toLowerCase()), + ) + + console.log( + `[middleware-factory] Prefixed headers (${prefix}):`, + prefixedHeaders, + ) + + return next({ + context: { + prefix, + matchedHeaders: prefixedHeaders, + }, + }) + }) +} + +// Create middleware instances using the factories +const customHeaderMiddleware = createHeaderMiddleware('x-custom-factory-header') +const prefixedMiddleware = createPrefixedHeaderMiddleware('x-factory-') + +const serverFn = createServerFn() + .middleware([customHeaderMiddleware, prefixedMiddleware]) + .handler(async ({ context }) => { + return { + headerName: context.headerName, + headerValue: context.headerValue, + prefix: context.prefix, + matchedHeaders: context.matchedHeaders, + } + }) + +export const Route = createFileRoute('/middleware/middleware-factory')({ + component: RouteComponent, +}) + +function RouteComponent() { + const [result, setResult] = React.useState<{ + headerName: string + headerValue: string + prefix: string + matchedHeaders: Array + } | null>(null) + const [error, setError] = React.useState(null) + + async function handleClick() { + try { + const data = await serverFn({ + headers: { + 'x-custom-factory-header': 'factory-header-value', + 'x-factory-one': 'one', + 'x-factory-two': 'two', + }, + }) + setResult(data) + setError(null) + } catch (e) { + setResult(null) + setError(e instanceof Error ? e.message : String(e)) + } + } + + return ( +
    +

    Middleware Factory Test

    +

    + This test verifies that middleware factories (functions returning + createMiddleware().server()) have their server-only code properly + stripped from the client build. +

    + + {result && ( +
    +
    {result.headerValue}
    +
    + {result.matchedHeaders.join(',')} +
    +
    + )} + {error && ( +
    Error: {error}
    + )} +
    + ) +} diff --git a/e2e/react-start/server-functions/tests/server-functions.spec.ts b/e2e/react-start/server-functions/tests/server-functions.spec.ts index 5848f73a612..a428f5640ea 100644 --- a/e2e/react-start/server-functions/tests/server-functions.spec.ts +++ b/e2e/react-start/server-functions/tests/server-functions.spec.ts @@ -635,3 +635,32 @@ test('server-only imports in middleware.server() are stripped from client build' page.getByTestId('server-import-middleware-result'), ).toContainText('test-header-value') }) + +test('middleware factories with server-only imports are stripped from client build', async ({ + page, +}) => { + // This test verifies that middleware factories (functions returning createMiddleware().server()) + // with server-only imports are properly stripped from the client build. + // If the .server() part inside the factory is not removed, the build would fail with + // node:async_hooks externalization errors because getRequestHeaders uses node:async_hooks internally. + // The fact that this page loads at all proves the server code was stripped correctly. + await page.goto('/middleware/middleware-factory') + + await page.waitForLoadState('networkidle') + + // Click the button to call the server function with factory middlewares + await page.getByTestId('test-middleware-factory-btn').click() + + // Wait for the result - should contain our custom header value from the factory middleware + await expect(page.getByTestId('header-value')).toContainText( + 'factory-header-value', + ) + + // Also verify the prefixed headers were matched correctly + await expect(page.getByTestId('matched-headers')).toContainText( + 'x-factory-one', + ) + await expect(page.getByTestId('matched-headers')).toContainText( + 'x-factory-two', + ) +}) diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts index 9ad4c4e5aa8..ef5b7457de6 100644 --- a/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts +++ b/packages/start-plugin-core/src/create-server-fn-plugin/compiler.ts @@ -68,6 +68,57 @@ const LookupSetup: Record = { ClientOnlyFn: { type: 'directCall' }, } +// Single source of truth for detecting which kinds are present in code +// These patterns are used for: +// 1. Pre-scanning code to determine which kinds to look for (before AST parsing) +// 2. Deriving the plugin's transform code filter +export const KindDetectionPatterns: Record = { + ServerFn: /\.handler\s*\(/, + Middleware: /createMiddleware/, + IsomorphicFn: /createIsomorphicFn/, + ServerOnlyFn: /createServerOnlyFn/, + ClientOnlyFn: /createClientOnlyFn/, +} + +// Which kinds are valid for each environment +export const LookupKindsPerEnv: Record<'client' | 'server', Set> = { + client: new Set([ + 'Middleware', + 'ServerFn', + 'IsomorphicFn', + 'ServerOnlyFn', + 'ClientOnlyFn', + ] as const), + server: new Set([ + 'ServerFn', + 'IsomorphicFn', + 'ServerOnlyFn', + 'ClientOnlyFn', + ] as const), +} + +/** + * Detects which LookupKinds are present in the code using string matching. + * This is a fast pre-scan before AST parsing to limit the work done during compilation. + */ +export function detectKindsInCode( + code: string, + env: 'client' | 'server', +): Set { + const detected = new Set() + const validForEnv = LookupKindsPerEnv[env] + + for (const [kind, pattern] of Object.entries(KindDetectionPatterns) as Array< + [LookupKind, RegExp] + >) { + if (validForEnv.has(kind) && pattern.test(code)) { + detected.add(kind) + } + } + + return detected +} + // Pre-computed map: identifier name -> Set for fast candidate detection (method chain only) // Multiple kinds can share the same identifier (e.g., 'server' and 'client' are used by both Middleware and IsomorphicFn) const IdentifierToKinds = new Map>() @@ -86,6 +137,16 @@ for (const [kind, setup] of Object.entries(LookupSetup) as Array< } } +// Known factory function names for direct call and root-as-candidate patterns +// These are the names that, when called directly, create a new function. +// Used to filter nested candidates - we only want to include actual factory calls, +// not invocations of already-created functions (e.g., `myServerFn()` should NOT be a candidate) +const DirectCallFactoryNames = new Set([ + 'createServerOnlyFn', + 'createClientOnlyFn', + 'createIsomorphicFn', +]) + export type LookupConfig = { libName: string rootExport: string @@ -100,13 +161,79 @@ interface ModuleInfo { reExportAllSources: Array } +/** + * Computes whether any file kinds need direct-call candidate detection. + * This includes both directCall types (ServerOnlyFn, ClientOnlyFn) and + * allowRootAsCandidate types (IsomorphicFn). + */ +function needsDirectCallDetection(kinds: Set): boolean { + for (const kind of kinds) { + const setup = LookupSetup[kind] + if (setup.type === 'directCall' || setup.allowRootAsCandidate) { + return true + } + } + return false +} + +/** + * Checks if a CallExpression is a direct-call candidate for NESTED detection. + * Returns true if the callee is a known factory function name. + * This is stricter than top-level detection because we need to filter out + * invocations of existing server functions (e.g., `myServerFn()`). + */ +function isNestedDirectCallCandidate(node: t.CallExpression): boolean { + let calleeName: string | undefined + if (t.isIdentifier(node.callee)) { + calleeName = node.callee.name + } else if ( + t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.property) + ) { + calleeName = node.callee.property.name + } + return calleeName !== undefined && DirectCallFactoryNames.has(calleeName) +} + +/** + * Checks if a CallExpression path is a top-level direct-call candidate. + * Top-level means the call is the init of a VariableDeclarator at program level. + * We accept any simple identifier call or namespace call at top level + * (e.g., `isomorphicFn()`, `TanStackStart.createServerOnlyFn()`) and let + * resolution verify it. This handles renamed imports. + */ +function isTopLevelDirectCallCandidate( + path: babel.NodePath, +): boolean { + const node = path.node + + // Must be a simple identifier call or namespace call + const isSimpleCall = + t.isIdentifier(node.callee) || + (t.isMemberExpression(node.callee) && + t.isIdentifier(node.callee.object) && + t.isIdentifier(node.callee.property)) + + if (!isSimpleCall) { + return false + } + + // Must be top-level: VariableDeclarator -> VariableDeclaration -> Program + const parent = path.parent + if (!t.isVariableDeclarator(parent) || parent.init !== node) { + return false + } + const grandParent = path.parentPath.parent + if (!t.isVariableDeclaration(grandParent)) { + return false + } + return t.isProgram(path.parentPath.parentPath?.parent) +} + export class ServerFnCompiler { private moduleCache = new Map() private initialized = false private validLookupKinds: Set - // Precomputed flags for candidate detection (avoid recomputing on each collectCandidates call) - private hasDirectCallKinds: boolean - private hasRootAsCandidateKinds: boolean // Fast lookup for direct imports from known libraries (e.g., '@tanstack/react-start') // Maps: libName → (exportName → Kind) // This allows O(1) resolution for the common case without async resolveId calls @@ -122,18 +249,6 @@ export class ServerFnCompiler { }, ) { this.validLookupKinds = options.lookupKinds - - // Precompute flags for candidate detection - this.hasDirectCallKinds = false - this.hasRootAsCandidateKinds = false - for (const kind of options.lookupKinds) { - const setup = LookupSetup[kind] - if (setup.type === 'directCall') { - this.hasDirectCallKinds = true - } else if (setup.allowRootAsCandidate) { - this.hasRootAsCandidateKinds = true - } - } } private async init() { @@ -310,68 +425,106 @@ export class ServerFnCompiler { code, id, isProviderFile, + detectedKinds, }: { code: string id: string isProviderFile: boolean + /** Pre-detected kinds present in this file. If not provided, all valid kinds are checked. */ + detectedKinds?: Set }) { if (!this.initialized) { await this.init() } - const { info, ast } = this.ingestModule({ code, id }) - const candidates = this.collectCandidates(info.bindings) - if (candidates.length === 0) { - // this hook will only be invoked if there is `.handler(` | `.server(` | `.client(` in the code, - // so not discovering a handler candidate is rather unlikely, but maybe possible? + + // Use detected kinds if provided, otherwise fall back to all valid kinds for this env + const fileKinds = detectedKinds + ? new Set([...detectedKinds].filter((k) => this.validLookupKinds.has(k))) + : this.validLookupKinds + + // Early exit if no kinds to process + if (fileKinds.size === 0) { return null } - // let's find out which of the candidates are actually server functions - // Resolve all candidates in parallel for better performance + const checkDirectCalls = needsDirectCallDetection(fileKinds) + + const { ast } = this.ingestModule({ code, id }) + + // Single-pass traversal to: + // 1. Collect candidate paths (only candidates, not all CallExpressions) + // 2. Build a map for looking up paths of nested calls in method chains + const candidatePaths: Array> = [] + // Map for nested chain lookup - only populated for CallExpressions that are + // part of a method chain (callee.object is a CallExpression) + const chainCallPaths = new Map< + t.CallExpression, + babel.NodePath + >() + + babel.traverse(ast, { + CallExpression: (path) => { + const node = path.node + const parent = path.parent + + // Check if this call is part of a larger chain (inner call) + // If so, store it for method chain lookup but don't treat as candidate + if ( + t.isMemberExpression(parent) && + t.isCallExpression(path.parentPath.parent) + ) { + // This is an inner call in a chain - store for later lookup + chainCallPaths.set(node, path) + return + } + + // Pattern 1: Method chain pattern (.handler(), .server(), .client(), etc.) + if (isMethodChainCandidate(node, fileKinds)) { + candidatePaths.push(path) + return + } + + // Pattern 2: Direct call pattern + if (checkDirectCalls) { + if (isTopLevelDirectCallCandidate(path)) { + candidatePaths.push(path) + } else if (isNestedDirectCallCandidate(node)) { + candidatePaths.push(path) + } + } + }, + }) + + if (candidatePaths.length === 0) { + return null + } + + // Resolve all candidates in parallel to determine their kinds const resolvedCandidates = await Promise.all( - candidates.map(async (candidate) => ({ - candidate, - kind: await this.resolveExprKind(candidate, id), + candidatePaths.map(async (path) => ({ + path, + kind: await this.resolveExprKind(path.node, id), })), ) - // Map from candidate/root node -> kind - // Note: For top-level variable declarations, candidate === root (the outermost CallExpression) - const toRewriteMap = new Map() - for (const { candidate, kind } of resolvedCandidates) { - if (this.validLookupKinds.has(kind as LookupKind)) { - toRewriteMap.set(candidate, kind as LookupKind) - } - } - if (toRewriteMap.size === 0) { + // Filter to valid candidates + const validCandidates = resolvedCandidates.filter(({ kind }) => + this.validLookupKinds.has(kind as LookupKind), + ) as Array<{ path: babel.NodePath; kind: LookupKind }> + + if (validCandidates.length === 0) { return null } - // Single-pass traversal to find NodePaths and collect method chains + // Process valid candidates to collect method chains const pathsToRewrite: Array<{ path: babel.NodePath kind: LookupKind methodChain: MethodChainPaths }> = [] - // First, collect all CallExpression paths in the AST for O(1) lookup - const callExprPaths = new Map< - t.CallExpression, - babel.NodePath - >() - - babel.traverse(ast, { - CallExpression(path) { - callExprPaths.set(path.node, path) - }, - }) - - // Now process candidates - we can look up any CallExpression path in O(1) - for (const [node, kind] of toRewriteMap) { - const path = callExprPaths.get(node) - if (!path) { - continue - } + for (const { path, kind } of validCandidates) { + const node = path.node // Collect method chain paths by walking DOWN from root through the chain const methodChain: MethodChainPaths = { @@ -384,6 +537,8 @@ export class ServerFnCompiler { // Walk down the call chain using nodes, look up paths from map let currentNode: t.CallExpression = node + let currentPath: babel.NodePath = path + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition while (true) { const callee = currentNode.callee @@ -395,7 +550,6 @@ export class ServerFnCompiler { if (t.isIdentifier(callee.property)) { const name = callee.property.name as keyof MethodChainPaths if (name in methodChain) { - const currentPath = callExprPaths.get(currentNode)! // Get first argument path const args = currentPath.get('arguments') const firstArgPath = @@ -412,18 +566,17 @@ export class ServerFnCompiler { break } currentNode = callee.object + // Look up path from chain map, or use candidate path if not found + const nextPath = chainCallPaths.get(currentNode) + if (!nextPath) { + break + } + currentPath = nextPath } pathsToRewrite.push({ path, kind, methodChain }) } - // Verify we found all candidates (pathsToRewrite should have same size as toRewriteMap had) - if (pathsToRewrite.length !== toRewriteMap.size) { - throw new Error( - `Internal error: could not find all paths to rewrite. please file an issue`, - ) - } - const refIdents = findReferencedIdentifiers(ast) for (const { path, kind, methodChain } of pathsToRewrite) { @@ -461,42 +614,6 @@ export class ServerFnCompiler { }) } - // collects all candidate CallExpressions at top-level - private collectCandidates(bindings: Map) { - const candidates: Array = [] - - for (const binding of bindings.values()) { - if (binding.type === 'var' && t.isCallExpression(binding.init)) { - // Pattern 1: Method chain pattern (.handler(), .server(), etc.) - const methodChainCandidate = isCandidateCallExpression( - binding.init, - this.validLookupKinds, - ) - if (methodChainCandidate) { - candidates.push(methodChainCandidate) - continue - } - - // Pattern 2: Direct call pattern - // Handles: - // - createServerOnlyFn(), createClientOnlyFn() (direct call kinds) - // - createIsomorphicFn() (root-as-candidate kinds) - // - TanStackStart.createServerOnlyFn() (namespace calls) - if (this.hasDirectCallKinds || this.hasRootAsCandidateKinds) { - if ( - t.isIdentifier(binding.init.callee) || - (t.isMemberExpression(binding.init.callee) && - t.isIdentifier(binding.init.callee.property)) - ) { - // Include as candidate - kind resolution will verify it's actually a known export - candidates.push(binding.init) - } - } - } - } - return candidates - } - private async resolveIdentifierKind( ident: string, id: string, @@ -793,15 +910,17 @@ export class ServerFnCompiler { } } -function isCandidateCallExpression( - node: t.Node | null | undefined, +/** + * Checks if a CallExpression has a method chain pattern that matches any of the lookup kinds. + * E.g., `.handler()`, `.server()`, `.client()`, `.createMiddlewares()` + */ +function isMethodChainCandidate( + node: t.CallExpression, lookupKinds: Set, -): t.CallExpression | undefined { - if (!t.isCallExpression(node)) return undefined - +): boolean { const callee = node.callee if (!t.isMemberExpression(callee) || !t.isIdentifier(callee.property)) { - return undefined + return false } // Use pre-computed map for O(1) lookup @@ -811,10 +930,10 @@ function isCandidateCallExpression( // Check if any of the possible kinds are in the valid lookup kinds for (const kind of possibleKinds) { if (lookupKinds.has(kind)) { - return node + return true } } } - return undefined + return false } diff --git a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts index f19ffbda102..ab6a618c741 100644 --- a/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts +++ b/packages/start-plugin-core/src/create-server-fn-plugin/plugin.ts @@ -1,28 +1,35 @@ import { TRANSFORM_ID_REGEX } from '../constants' -import { ServerFnCompiler } from './compiler' +import { + KindDetectionPatterns, + LookupKindsPerEnv, + ServerFnCompiler, + detectKindsInCode, +} from './compiler' import type { CompileStartFrameworkOptions } from '../types' import type { LookupConfig, LookupKind } from './compiler' import type { PluginOption } from 'vite' function cleanId(id: string): string { + // Remove null byte prefix used by Vite/Rollup for virtual modules + if (id.startsWith('\0')) { + id = id.slice(1) + } const queryIndex = id.indexOf('?') return queryIndex === -1 ? id : id.substring(0, queryIndex) } -const LookupKindsPerEnv: Record<'client' | 'server', Set> = { - client: new Set([ - 'Middleware', - 'ServerFn', - 'IsomorphicFn', - 'ServerOnlyFn', - 'ClientOnlyFn', - ] as const), - server: new Set([ - 'ServerFn', - 'IsomorphicFn', - 'ServerOnlyFn', - 'ClientOnlyFn', - ] as const), +// Derive transform code filter from KindDetectionPatterns (single source of truth) +function getTransformCodeFilterForEnv(env: 'client' | 'server'): Array { + const validKinds = LookupKindsPerEnv[env] + const patterns: Array = [] + for (const [kind, pattern] of Object.entries(KindDetectionPatterns) as Array< + [LookupKind, RegExp] + >) { + if (validKinds.has(kind)) { + patterns.push(pattern) + } + } + return patterns } const getLookupConfigurationsForEnv = ( @@ -77,12 +84,6 @@ function buildDirectiveSplitParam(directive: string) { return `tsr-directive-${directive.replace(/[^a-zA-Z0-9]/g, '-')}` } -const commonTransformCodeFilter = [ - /\.\s*handler\(/, - /createIsomorphicFn/, - /createServerOnlyFn/, - /createClientOnlyFn/, -] export function createServerFnPlugin(opts: { framework: CompileStartFrameworkOptions directive: string @@ -95,16 +96,8 @@ export function createServerFnPlugin(opts: { name: string type: 'client' | 'server' }): PluginOption { - // Code filter patterns for transform functions: - // - `.handler(` for createServerFn - // - `createMiddleware(` for middleware (client only) - // - `createIsomorphicFn` for isomorphic functions - // - `createServerOnlyFn` for server-only functions - // - `createClientOnlyFn` for client-only functions - const transformCodeFilter = - environment.type === 'client' - ? [...commonTransformCodeFilter, /createMiddleware\s*\(/] - : commonTransformCodeFilter + // Derive transform code filter from KindDetectionPatterns (single source of truth) + const transformCodeFilter = getTransformCodeFilterForEnv(environment.type) return { name: `tanstack-start-core::server-fn:${environment.name}`, @@ -172,8 +165,16 @@ export function createServerFnPlugin(opts: { const isProviderFile = id.includes(directiveSplitParam) + // Detect which kinds are present in this file before parsing + const detectedKinds = detectKindsInCode(code, environment.type) + id = cleanId(id) - const result = await compiler.compile({ id, code, isProviderFile }) + const result = await compiler.compile({ + id, + code, + isProviderFile, + detectedKinds, + }) return result }, }, diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts new file mode 100644 index 00000000000..14aac107532 --- /dev/null +++ b/packages/start-plugin-core/tests/compiler.test.ts @@ -0,0 +1,393 @@ +import { describe, expect, test } from 'vitest' +import { + detectKindsInCode, + ServerFnCompiler, +} from '../src/create-server-fn-plugin/compiler' +import type { + LookupConfig, + LookupKind, +} from '../src/create-server-fn-plugin/compiler' + +// Helper to create a compiler with all kinds enabled +function createFullCompiler(env: 'client' | 'server') { + const lookupKinds: Set = + env === 'client' + ? new Set([ + 'ServerFn', + 'Middleware', + 'IsomorphicFn', + 'ServerOnlyFn', + 'ClientOnlyFn', + ]) + : new Set(['ServerFn', 'IsomorphicFn', 'ServerOnlyFn', 'ClientOnlyFn']) + + const lookupConfigurations: Array = [ + { + libName: '@tanstack/react-start', + rootExport: 'createServerFn', + kind: 'Root', + }, + { + libName: '@tanstack/react-start', + rootExport: 'createMiddleware', + kind: 'Root', + }, + { + libName: '@tanstack/react-start', + rootExport: 'createIsomorphicFn', + kind: 'IsomorphicFn', + }, + { + libName: '@tanstack/react-start', + rootExport: 'createServerOnlyFn', + kind: 'ServerOnlyFn', + }, + { + libName: '@tanstack/react-start', + rootExport: 'createClientOnlyFn', + kind: 'ClientOnlyFn', + }, + ] + + return new ServerFnCompiler({ + env, + directive: 'use server', + lookupKinds, + lookupConfigurations, + loadModule: async () => {}, + resolveId: async (id) => id, + }) +} + +describe('detectKindsInCode', () => { + describe('detects individual kinds correctly', () => { + test('detects ServerFn via .handler(', () => { + const code = ` + import { createServerFn } from '@tanstack/react-start' + const fn = createServerFn().handler(() => 'hello') + ` + expect(detectKindsInCode(code, 'client')).toEqual(new Set(['ServerFn'])) + expect(detectKindsInCode(code, 'server')).toEqual(new Set(['ServerFn'])) + }) + + test('detects Middleware', () => { + const code = ` + import { createMiddleware } from '@tanstack/react-start' + const mw = createMiddleware().server(({ next }) => next()) + ` + // Middleware is only valid on client + expect(detectKindsInCode(code, 'client')).toEqual(new Set(['Middleware'])) + expect(detectKindsInCode(code, 'server')).toEqual(new Set()) + }) + + test('detects IsomorphicFn', () => { + const code = ` + import { createIsomorphicFn } from '@tanstack/react-start' + const fn = createIsomorphicFn().client(() => 'c').server(() => 's') + ` + expect(detectKindsInCode(code, 'client')).toEqual( + new Set(['IsomorphicFn']), + ) + expect(detectKindsInCode(code, 'server')).toEqual( + new Set(['IsomorphicFn']), + ) + }) + + test('detects ServerOnlyFn', () => { + const code = ` + import { createServerOnlyFn } from '@tanstack/react-start' + const fn = createServerOnlyFn(() => 'server only') + ` + expect(detectKindsInCode(code, 'client')).toEqual( + new Set(['ServerOnlyFn']), + ) + expect(detectKindsInCode(code, 'server')).toEqual( + new Set(['ServerOnlyFn']), + ) + }) + + test('detects ClientOnlyFn', () => { + const code = ` + import { createClientOnlyFn } from '@tanstack/react-start' + const fn = createClientOnlyFn(() => 'client only') + ` + expect(detectKindsInCode(code, 'client')).toEqual( + new Set(['ClientOnlyFn']), + ) + expect(detectKindsInCode(code, 'server')).toEqual( + new Set(['ClientOnlyFn']), + ) + }) + }) + + describe('detects multiple kinds in same file', () => { + test('detects ServerFn and IsomorphicFn', () => { + const code = ` + import { createServerFn, createIsomorphicFn } from '@tanstack/react-start' + const serverFn = createServerFn().handler(() => 'hello') + const isoFn = createIsomorphicFn().client(() => 'client') + ` + expect(detectKindsInCode(code, 'client')).toEqual( + new Set(['ServerFn', 'IsomorphicFn']), + ) + }) + + test('detects all kinds on client', () => { + const code = ` + import { createServerFn, createMiddleware, createIsomorphicFn, createServerOnlyFn, createClientOnlyFn } from '@tanstack/react-start' + const a = createServerFn().handler(() => {}) + const b = createMiddleware().server(({ next }) => next()) + const c = createIsomorphicFn() + const d = createServerOnlyFn(() => {}) + const e = createClientOnlyFn(() => {}) + ` + expect(detectKindsInCode(code, 'client')).toEqual( + new Set([ + 'ServerFn', + 'Middleware', + 'IsomorphicFn', + 'ServerOnlyFn', + 'ClientOnlyFn', + ]), + ) + }) + + test('detects all valid kinds on server (excludes Middleware)', () => { + const code = ` + import { createServerFn, createMiddleware, createIsomorphicFn, createServerOnlyFn, createClientOnlyFn } from '@tanstack/react-start' + const a = createServerFn().handler(() => {}) + const b = createMiddleware().server(({ next }) => next()) + const c = createIsomorphicFn() + const d = createServerOnlyFn(() => {}) + const e = createClientOnlyFn(() => {}) + ` + // Middleware should NOT be detected on server + expect(detectKindsInCode(code, 'server')).toEqual( + new Set(['ServerFn', 'IsomorphicFn', 'ServerOnlyFn', 'ClientOnlyFn']), + ) + }) + }) + + describe('handles edge cases', () => { + test('returns empty set for code with no matching patterns', () => { + const code = ` + const foo = 'bar' + function hello() { return 'world' } + ` + expect(detectKindsInCode(code, 'client')).toEqual(new Set()) + expect(detectKindsInCode(code, 'server')).toEqual(new Set()) + }) + + test('handles .handler with whitespace variations', () => { + const code1 = `fn.handler(() => {})` + const code2 = `fn.handler (() => {})` + const code3 = `fn.handler\n(() => {})` + const code4 = `fn.handler\t(() => {})` + + expect(detectKindsInCode(code1, 'client')).toEqual(new Set(['ServerFn'])) + expect(detectKindsInCode(code2, 'client')).toEqual(new Set(['ServerFn'])) + expect(detectKindsInCode(code3, 'client')).toEqual(new Set(['ServerFn'])) + expect(detectKindsInCode(code4, 'client')).toEqual(new Set(['ServerFn'])) + }) + + test('does not false positive on similar names', () => { + const code = ` + const myCreateServerFn = () => {} + const createServerFnLike = () => {} + // But this should match: + createServerFn().handler(() => {}) + ` + // Should only match because of .handler(, not because of variable names + expect(detectKindsInCode(code, 'client')).toEqual(new Set(['ServerFn'])) + }) + }) +}) + +describe('compiler handles multiple files with different kinds', () => { + test('single compiler instance correctly processes files with different kinds in succession', async () => { + const compiler = createFullCompiler('client') + + // File 1: ServerFn only + const result1 = await compiler.compile({ + code: ` + import { createServerFn } from '@tanstack/react-start' + export const fn = createServerFn().handler(() => 'hello') + `, + id: 'file1.ts', + isProviderFile: false, + detectedKinds: new Set(['ServerFn']), + }) + expect(result1).not.toBeNull() + expect(result1!.code).toContain('__executeServer') // Client should have RPC stub + expect(result1!.code).not.toContain('createMiddleware') + expect(result1!.code).not.toContain('createIsomorphicFn') + + // File 2: Middleware only + const result2 = await compiler.compile({ + code: ` + import { createMiddleware } from '@tanstack/react-start' + export const mw = createMiddleware().server(({ next }) => { + console.log('server only') + return next() + }) + `, + id: 'file2.ts', + isProviderFile: false, + detectedKinds: new Set(['Middleware']), + }) + expect(result2).not.toBeNull() + // .server() should be stripped on client + expect(result2!.code).not.toContain('console.log') + expect(result2!.code).not.toContain('createServerFn') + + // File 3: IsomorphicFn only + const result3 = await compiler.compile({ + code: ` + import { createIsomorphicFn } from '@tanstack/react-start' + export const fn = createIsomorphicFn() + .client(() => 'client-value') + .server(() => 'server-value') + `, + id: 'file3.ts', + isProviderFile: false, + detectedKinds: new Set(['IsomorphicFn']), + }) + expect(result3).not.toBeNull() + // Client should have client implementation + expect(result3!.code).toContain('client-value') + expect(result3!.code).not.toContain('server-value') + + // File 4: ServerOnlyFn only + const result4 = await compiler.compile({ + code: ` + import { createServerOnlyFn } from '@tanstack/react-start' + export const fn = createServerOnlyFn(() => 'server only value') + `, + id: 'file4.ts', + isProviderFile: false, + detectedKinds: new Set(['ServerOnlyFn']), + }) + expect(result4).not.toBeNull() + // Client should have error throw + expect(result4!.code).toContain('throw new Error') + expect(result4!.code).not.toContain('server only value') + + // File 5: Mix of ServerFn and IsomorphicFn + const result5 = await compiler.compile({ + code: ` + import { createServerFn, createIsomorphicFn } from '@tanstack/react-start' + export const serverFn = createServerFn().handler(() => 'hello') + export const isoFn = createIsomorphicFn().client(() => 'client-iso') + `, + id: 'file5.ts', + isProviderFile: false, + detectedKinds: new Set(['ServerFn', 'IsomorphicFn']), + }) + expect(result5).not.toBeNull() + expect(result5!.code).toContain('__executeServer') // ServerFn RPC + expect(result5!.code).toContain('client-iso') // IsomorphicFn client impl + }) + + test('compiler works correctly when processing same kind multiple times', async () => { + const compiler = createFullCompiler('client') + + // First file with IsomorphicFn + const result1 = await compiler.compile({ + code: ` + import { createIsomorphicFn } from '@tanstack/react-start' + export const fn1 = createIsomorphicFn().client(() => 'first') + `, + id: 'first.ts', + isProviderFile: false, + detectedKinds: new Set(['IsomorphicFn']), + }) + expect(result1!.code).toContain('first') + + // Second file with different IsomorphicFn + const result2 = await compiler.compile({ + code: ` + import { createIsomorphicFn } from '@tanstack/react-start' + export const fn2 = createIsomorphicFn().client(() => 'second') + `, + id: 'second.ts', + isProviderFile: false, + detectedKinds: new Set(['IsomorphicFn']), + }) + expect(result2!.code).toContain('second') + expect(result2!.code).not.toContain('first') // Should not leak from previous file + }) + + test('server environment excludes Middleware from detected kinds', async () => { + const compiler = createFullCompiler('server') + + // Even if Middleware is in detectedKinds, server env should ignore it + const result = await compiler.compile({ + code: ` + import { createMiddleware } from '@tanstack/react-start' + export const mw = createMiddleware().server(({ next }) => next()) + `, + id: 'middleware.ts', + isProviderFile: false, + // Intentionally including Middleware even though it's server env + detectedKinds: new Set(['Middleware']), + }) + // Should return null since Middleware is not valid on server + expect(result).toBeNull() + }) +}) + +describe('edge cases for detectedKinds', () => { + test('empty detectedKinds returns null (no candidates to process)', async () => { + const compiler = createFullCompiler('client') + + const result = await compiler.compile({ + code: ` + // This file somehow passed Vite filter but has no actual kinds + const foo = 'bar' + // Maybe a false positive match like "handler(" in a comment + // .handler( in string + `, + id: 'empty.ts', + isProviderFile: false, + detectedKinds: new Set(), // Empty set + }) + + expect(result).toBeNull() + }) + + test('detectedKinds not provided falls back to all valid kinds', async () => { + const compiler = createFullCompiler('client') + + // When detectedKinds is not provided, should check all valid kinds + const result = await compiler.compile({ + code: ` + import { createIsomorphicFn } from '@tanstack/react-start' + export const fn = createIsomorphicFn().client(() => 'works') + `, + id: 'no-detected.ts', + isProviderFile: false, + // No detectedKinds provided + }) + + expect(result).not.toBeNull() + expect(result!.code).toContain('works') + }) + + test('detectedKinds with invalid kinds for env are filtered out', async () => { + const compiler = createFullCompiler('server') + + // Passing Middleware (invalid for server) along with valid kind + const result = await compiler.compile({ + code: ` + import { createIsomorphicFn } from '@tanstack/react-start' + export const fn = createIsomorphicFn().server(() => 'server-impl') + `, + id: 'filtered.ts', + isProviderFile: false, + detectedKinds: new Set(['Middleware', 'IsomorphicFn']), // Middleware should be filtered + }) + + expect(result).not.toBeNull() + expect(result!.code).toContain('server-impl') + }) +}) diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnFactory.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnFactory.tsx new file mode 100644 index 00000000000..803bb6d8011 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnFactory.tsx @@ -0,0 +1,25 @@ +// Isomorphic function factory - returns a createIsomorphicFn with .client() and .server() calls +export function createPlatformFn(platform: string) { + return () => `client-${platform}`; +} + +// Arrow function factory with only server implementation +export const createServerImplFn = (name: string) => { + return () => {}; +}; + +// Arrow function factory with only client implementation +export const createClientImplFn = (name: string) => { + return () => { + console.log(`Client: ${name}`); + return `client-${name}`; + }; +}; + +// Factory returning no-implementation isomorphic fn +export function createNoImplFn() { + return () => {}; +} + +// Top-level isomorphic fn for comparison +export const topLevelIsomorphicFn = () => 'client'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnInline.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnInline.tsx new file mode 100644 index 00000000000..09666623a62 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/client/createIsomorphicFnInline.tsx @@ -0,0 +1,41 @@ +// Create and immediately invoke isomorphic fn inside a function +export function getPlatformValue() { + const fn = () => 'client-value'; + return fn(); +} + +// Arrow function that creates and invokes isomorphic fn +export const getEnvironment = () => { + const envFn = () => 'running on client'; + return envFn(); +}; + +// Create isomorphic fn inline without assigning to variable +export function getDirectValue() { + return (() => 'direct-client')(); +} + +// Multiple isomorphic fns created and used in same function +export function getMultipleValues() { + const first = () => 'first-client'; + const second = () => 'second-client'; + return { + first: first(), + second: second() + }; +} + +// Isomorphic fn with server-only implementation used inline +export function getServerOnlyValue() { + const fn = () => {}; + return fn(); +} + +// Isomorphic fn with client-only implementation used inline +export function getClientOnlyValue() { + const fn = () => { + console.log('client side effect'); + return 'client-only-value'; + }; + return fn(); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnFactory.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnFactory.tsx new file mode 100644 index 00000000000..ee949e0c382 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnFactory.tsx @@ -0,0 +1,25 @@ +// Isomorphic function factory - returns a createIsomorphicFn with .client() and .server() calls +export function createPlatformFn(platform: string) { + return () => `server-${platform}`; +} + +// Arrow function factory with only server implementation +export const createServerImplFn = (name: string) => { + return () => { + console.log(`Server: ${name}`); + return `server-${name}`; + }; +}; + +// Arrow function factory with only client implementation +export const createClientImplFn = (name: string) => { + return () => {}; +}; + +// Factory returning no-implementation isomorphic fn +export function createNoImplFn() { + return () => {}; +} + +// Top-level isomorphic fn for comparison +export const topLevelIsomorphicFn = () => 'server'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnInline.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnInline.tsx new file mode 100644 index 00000000000..9c058dfae86 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/snapshots/server/createIsomorphicFnInline.tsx @@ -0,0 +1,41 @@ +// Create and immediately invoke isomorphic fn inside a function +export function getPlatformValue() { + const fn = () => 'server-value'; + return fn(); +} + +// Arrow function that creates and invokes isomorphic fn +export const getEnvironment = () => { + const envFn = () => 'running on server'; + return envFn(); +}; + +// Create isomorphic fn inline without assigning to variable +export function getDirectValue() { + return (() => 'direct-server')(); +} + +// Multiple isomorphic fns created and used in same function +export function getMultipleValues() { + const first = () => 'first-server'; + const second = () => 'second-server'; + return { + first: first(), + second: second() + }; +} + +// Isomorphic fn with server-only implementation used inline +export function getServerOnlyValue() { + const fn = () => { + console.log('server side effect'); + return 'server-only-value'; + }; + return fn(); +} + +// Isomorphic fn with client-only implementation used inline +export function getClientOnlyValue() { + const fn = () => {}; + return fn(); +} \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnFactory.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnFactory.tsx new file mode 100644 index 00000000000..3974ce77f08 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnFactory.tsx @@ -0,0 +1,34 @@ +import { createIsomorphicFn } from '@tanstack/react-start' + +// Isomorphic function factory - returns a createIsomorphicFn with .client() and .server() calls +export function createPlatformFn(platform: string) { + return createIsomorphicFn() + .client(() => `client-${platform}`) + .server(() => `server-${platform}`) +} + +// Arrow function factory with only server implementation +export const createServerImplFn = (name: string) => { + return createIsomorphicFn().server(() => { + console.log(`Server: ${name}`) + return `server-${name}` + }) +} + +// Arrow function factory with only client implementation +export const createClientImplFn = (name: string) => { + return createIsomorphicFn().client(() => { + console.log(`Client: ${name}`) + return `client-${name}` + }) +} + +// Factory returning no-implementation isomorphic fn +export function createNoImplFn() { + return createIsomorphicFn() +} + +// Top-level isomorphic fn for comparison +export const topLevelIsomorphicFn = createIsomorphicFn() + .client(() => 'client') + .server(() => 'server') diff --git a/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnInline.tsx b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnInline.tsx new file mode 100644 index 00000000000..1b4145bf9c5 --- /dev/null +++ b/packages/start-plugin-core/tests/createIsomorphicFn/test-files/createIsomorphicFnInline.tsx @@ -0,0 +1,55 @@ +import { createIsomorphicFn } from '@tanstack/react-start' + +// Create and immediately invoke isomorphic fn inside a function +export function getPlatformValue() { + const fn = createIsomorphicFn() + .client(() => 'client-value') + .server(() => 'server-value') + return fn() +} + +// Arrow function that creates and invokes isomorphic fn +export const getEnvironment = () => { + const envFn = createIsomorphicFn() + .server(() => 'running on server') + .client(() => 'running on client') + return envFn() +} + +// Create isomorphic fn inline without assigning to variable +export function getDirectValue() { + return createIsomorphicFn() + .client(() => 'direct-client') + .server(() => 'direct-server')() +} + +// Multiple isomorphic fns created and used in same function +export function getMultipleValues() { + const first = createIsomorphicFn() + .client(() => 'first-client') + .server(() => 'first-server') + + const second = createIsomorphicFn() + .client(() => 'second-client') + .server(() => 'second-server') + + return { first: first(), second: second() } +} + +// Isomorphic fn with server-only implementation used inline +export function getServerOnlyValue() { + const fn = createIsomorphicFn().server(() => { + console.log('server side effect') + return 'server-only-value' + }) + return fn() +} + +// Isomorphic fn with client-only implementation used inline +export function getClientOnlyValue() { + const fn = createIsomorphicFn().client(() => { + console.log('client side effect') + return 'client-only-value' + }) + return fn() +} diff --git a/packages/start-plugin-core/tests/createMiddleware/snapshots/client/middleware-factory.ts b/packages/start-plugin-core/tests/createMiddleware/snapshots/client/middleware-factory.ts new file mode 100644 index 00000000000..6845bd0ef5d --- /dev/null +++ b/packages/start-plugin-core/tests/createMiddleware/snapshots/client/middleware-factory.ts @@ -0,0 +1,20 @@ +import { createMiddleware } from '@tanstack/react-start'; + +// Middleware factory function - returns a middleware with .server() call +export function createPublicRateLimitMiddleware(keySuffix) { + return createMiddleware({ + type: 'function' + }); +} + +// Arrow function factory +export const createAuthMiddleware = requiredRole => { + return createMiddleware({ + type: 'function' + }); +}; + +// Top-level middleware for comparison +export const topLevelMiddleware = createMiddleware({ + id: 'topLevel' +}); \ No newline at end of file diff --git a/packages/start-plugin-core/tests/createMiddleware/test-files/middleware-factory.ts b/packages/start-plugin-core/tests/createMiddleware/test-files/middleware-factory.ts new file mode 100644 index 00000000000..a306dbf3712 --- /dev/null +++ b/packages/start-plugin-core/tests/createMiddleware/test-files/middleware-factory.ts @@ -0,0 +1,41 @@ +import { createMiddleware } from '@tanstack/react-start' + +// Middleware factory function - returns a middleware with .server() call +export function createPublicRateLimitMiddleware(keySuffix) { + return createMiddleware({ type: 'function' }).server( + async ({ next, data }) => { + const key = keySuffix ? `ip:${keySuffix}` : 'ip:default' + const finalKey = typeof data === 'string' ? `ip:${data}` : key + + const { success } = await rateLimit({ key: finalKey }) + + if (!success) { + throw new Error('Too many requests') + } + + return next() + }, + ) +} + +// Arrow function factory +export const createAuthMiddleware = (requiredRole) => { + return createMiddleware({ type: 'function' }).server(async ({ next }) => { + const user = await getUser() + if (!user) { + throw new Error('Unauthorized') + } + if (requiredRole && user.role !== requiredRole) { + throw new Error('Forbidden') + } + return next() + }) +} + +// Top-level middleware for comparison +export const topLevelMiddleware = createMiddleware({ + id: 'topLevel', +}).server(async ({ next }) => { + console.log('top level') + return next() +}) diff --git a/packages/start-plugin-core/tests/envOnly/snapshots/client/envOnlyFactory.tsx b/packages/start-plugin-core/tests/envOnly/snapshots/client/envOnlyFactory.tsx new file mode 100644 index 00000000000..6a29fcbfe64 --- /dev/null +++ b/packages/start-plugin-core/tests/envOnly/snapshots/client/envOnlyFactory.tsx @@ -0,0 +1,32 @@ +// Server-only function factory +export function createServerFactory(name: string) { + return () => { + throw new Error("createServerOnlyFn() functions can only be called on the server!"); + }; +} + +// Client-only function factory +export function createClientFactory(name: string) { + return () => { + console.log(`Client only: ${name}`); + return `client-${name}`; + }; +} + +// Arrow function server factory +export const createServerArrowFactory = (prefix: string) => { + return () => { + throw new Error("createServerOnlyFn() functions can only be called on the server!"); + }; +}; + +// Arrow function client factory +export const createClientArrowFactory = (prefix: string) => { + return () => `${prefix}-client`; +}; + +// Top-level for comparison +export const topLevelServerFn = () => { + throw new Error("createServerOnlyFn() functions can only be called on the server!"); +}; +export const topLevelClientFn = () => 'client'; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/envOnly/snapshots/server/envOnlyFactory.tsx b/packages/start-plugin-core/tests/envOnly/snapshots/server/envOnlyFactory.tsx new file mode 100644 index 00000000000..58b2483a48d --- /dev/null +++ b/packages/start-plugin-core/tests/envOnly/snapshots/server/envOnlyFactory.tsx @@ -0,0 +1,32 @@ +// Server-only function factory +export function createServerFactory(name: string) { + return () => { + console.log(`Server only: ${name}`); + return `server-${name}`; + }; +} + +// Client-only function factory +export function createClientFactory(name: string) { + return () => { + throw new Error("createClientOnlyFn() functions can only be called on the client!"); + }; +} + +// Arrow function server factory +export const createServerArrowFactory = (prefix: string) => { + return () => `${prefix}-server`; +}; + +// Arrow function client factory +export const createClientArrowFactory = (prefix: string) => { + return () => { + throw new Error("createClientOnlyFn() functions can only be called on the client!"); + }; +}; + +// Top-level for comparison +export const topLevelServerFn = () => 'server'; +export const topLevelClientFn = () => { + throw new Error("createClientOnlyFn() functions can only be called on the client!"); +}; \ No newline at end of file diff --git a/packages/start-plugin-core/tests/envOnly/test-files/envOnlyFactory.tsx b/packages/start-plugin-core/tests/envOnly/test-files/envOnlyFactory.tsx new file mode 100644 index 00000000000..a1223a6aeec --- /dev/null +++ b/packages/start-plugin-core/tests/envOnly/test-files/envOnlyFactory.tsx @@ -0,0 +1,31 @@ +import { createServerOnlyFn, createClientOnlyFn } from '@tanstack/react-start' + +// Server-only function factory +export function createServerFactory(name: string) { + return createServerOnlyFn(() => { + console.log(`Server only: ${name}`) + return `server-${name}` + }) +} + +// Client-only function factory +export function createClientFactory(name: string) { + return createClientOnlyFn(() => { + console.log(`Client only: ${name}`) + return `client-${name}` + }) +} + +// Arrow function server factory +export const createServerArrowFactory = (prefix: string) => { + return createServerOnlyFn(() => `${prefix}-server`) +} + +// Arrow function client factory +export const createClientArrowFactory = (prefix: string) => { + return createClientOnlyFn(() => `${prefix}-client`) +} + +// Top-level for comparison +export const topLevelServerFn = createServerOnlyFn(() => 'server') +export const topLevelClientFn = createClientOnlyFn(() => 'client')