diff --git a/.changeset/strong-trains-act.md b/.changeset/strong-trains-act.md index d0394c8efa..9f9b454196 100644 --- a/.changeset/strong-trains-act.md +++ b/.changeset/strong-trains-act.md @@ -1,5 +1,4 @@ --- -'@tanstack/start-client-core': minor '@tanstack/start-plugin-core': patch '@tanstack/start-server-core': patch '@tanstack/start-fn-stubs': patch diff --git a/packages/start-client-core/src/createCsrfMiddleware.ts b/packages/start-client-core/src/createCsrfMiddleware.ts index b7152d44a9..0715dd7e63 100644 --- a/packages/start-client-core/src/createCsrfMiddleware.ts +++ b/packages/start-client-core/src/createCsrfMiddleware.ts @@ -79,7 +79,7 @@ type CreateCsrfMiddleware = ( const innerCreateCsrfMiddleware: CreateCsrfMiddleware = (opts = {}) => { const middleware = createMiddleware().server(async (ctx) => { - const csrfCtx = ctx as RequestServerOptions & typeof ctx + const csrfCtx = ctx as RequestServerOptions if (opts.filter && !(await opts.filter(csrfCtx))) { return ctx.next() diff --git a/packages/start-plugin-core/src/start-compiler/compiler.ts b/packages/start-plugin-core/src/start-compiler/compiler.ts index 9a950c65e2..e742a75d41 100644 --- a/packages/start-plugin-core/src/start-compiler/compiler.ts +++ b/packages/start-plugin-core/src/start-compiler/compiler.ts @@ -40,6 +40,8 @@ type Binding = resolvedKind?: Kind } +type ImportBinding = Extract + type Kind = 'None' | `Root` | `Builder` | LookupKind export type BuiltInLookupKind = @@ -270,6 +272,12 @@ export type LookupConfig = { kind: LookupKind | 'Root' // 'Root' for builder pattern, LookupKind for direct call } +interface ExportResolution { + moduleInfo: ModuleInfo + localName: string + binding: Binding +} + interface ModuleInfo { id: string bindings: Map @@ -463,7 +471,7 @@ export class StartCompiler { private resolveIdCache = new Map() private exportResolutionCache = new Map< string, - Map + Map >() // Fast lookup for direct imports from known libraries (e.g., '@tanstack/react-start') // Maps: libName → (exportName → Kind) @@ -702,9 +710,12 @@ export class StartCompiler { this.knownRootImports.set( '@tanstack/start-client-core', new Map([ + ['createServerFn', 'Root'], ['createIsomorphicFn', 'IsomorphicFn'], ['createServerOnlyFn', 'ServerOnlyFn'], ['createClientOnlyFn', 'ClientOnlyFn'], + ['createMiddleware', 'Middleware'], + ['createStart', 'Root'], ]), ) @@ -1455,7 +1466,7 @@ export class StartCompiler { // TODO improve cycle detection? should we throw here instead of returning 'None'? // prevent cycles - const vKey = `${id}:${ident}` + const vKey = `${cleanId(id)}:${ident}` if (visited.has(vKey)) { return 'None' } @@ -1474,7 +1485,7 @@ export class StartCompiler { moduleInfo: ModuleInfo, exportName: string, visitedModules = new Set(), - ): Promise<{ moduleInfo: ModuleInfo; binding: Binding } | undefined> { + ): Promise { const isBuildMode = this.mode === 'build' // Check cache first (only for top-level calls in build mode) @@ -1499,7 +1510,7 @@ export class StartCompiler { if (localBindingName) { const binding = moduleInfo.bindings.get(localBindingName) if (binding) { - const result = { moduleInfo, binding } + const result = { moduleInfo, localName: localBindingName, binding } // Cache the result (build mode only) if (isBuildMode) { this.getExportResolutionCache(moduleInfo.id).set(exportName, result) @@ -1548,58 +1559,165 @@ export class StartCompiler { return undefined } - private async resolveBindingKind( - binding: Binding, - fileId: string, + private async resolveBindingTarget( + resolution: ExportResolution, visited = new Set(), + ): Promise { + const key = `${cleanId(resolution.moduleInfo.id)}:${resolution.localName}` + if (visited.has(key)) { + return undefined + } + visited.add(key) + + if (resolution.binding.type !== 'import') { + return resolution + } + + const target = await this.resolveIdCached( + resolution.binding.source, + resolution.moduleInfo.id, + ) + if (!target) { + return undefined + } + + const importedModule = await this.getModuleInfo(target) + const found = await this.findExportInModule( + importedModule, + resolution.binding.importedName, + ) + if (!found) { + return undefined + } + + return this.resolveBindingTarget(found, visited) + } + + private async resolveKnownImportKind( + binding: ImportBinding, + resolved?: ExportResolution, ): Promise { - if (binding.resolvedKind) { - return binding.resolvedKind + const directKind = + this.knownRootImports.get(binding.source)?.get(binding.importedName) ?? + 'None' + if (directKind !== 'None') { + return directKind } - if (binding.type === 'import') { - // Fast path: check if this is a direct import from a known library - // (e.g., import { createServerFn } from '@tanstack/react-start') - // This avoids async resolveId calls for the common case - const knownExports = this.knownRootImports.get(binding.source) - if (knownExports) { - const kind = knownExports.get(binding.importedName) - if (kind) { - binding.resolvedKind = kind - return kind - } + + if (!resolved) { + return 'None' + } + + for (const [source, exports] of this.knownRootImports) { + const kind = exports.get(binding.importedName) + if (!kind) { + continue } - // Slow path: resolve through the module graph - const target = await this.resolveIdCached(binding.source, fileId) - if (!target) { - return 'None' + let targetId: string | null + try { + targetId = await this.resolveIdCached(source, resolved.moduleInfo.id) + } catch { + continue } - const importedModule = await this.getModuleInfo(target) + if (!targetId) { + continue + } - // Find the export, recursively searching through export * from chains - const found = await this.findExportInModule( - importedModule, - binding.importedName, - ) + try { + const rootModule = await this.getModuleInfo(targetId) + const found = await this.findExportInModule( + rootModule, + binding.importedName, + ) + const target = found + ? ((await this.resolveBindingTarget(found)) ?? found) + : undefined - if (!found) { - return 'None' + // Match by resolved binding identity, not by export name alone. + if ( + target && + cleanId(resolved.moduleInfo.id) === cleanId(target.moduleInfo.id) && + resolved.localName === target.localName + ) { + return kind + } + } catch { + continue } + } - const { moduleInfo: foundModule, binding: foundBinding } = found + return 'None' + } - if (foundBinding.resolvedKind) { - return foundBinding.resolvedKind - } + private async resolveImportKind( + binding: ImportBinding, + fileId: string, + visited: Set, + ): Promise { + const directKnownKind = await this.resolveKnownImportKind(binding) + if (directKnownKind !== 'None') { + binding.resolvedKind = directKnownKind + return directKnownKind + } - const resolvedKind = await this.resolveBindingKind( - foundBinding, - foundModule.id, - visited, - ) - foundBinding.resolvedKind = resolvedKind - return resolvedKind + if (binding.importedName === '*') { + return 'None' + } + + const target = await this.resolveIdCached(binding.source, fileId) + if (!target) { + return 'None' + } + + const importedModule = await this.getModuleInfo(target) + const found = await this.findExportInModule( + importedModule, + binding.importedName, + ) + if (!found) { + return 'None' + } + + const knownKind = await this.resolveKnownImportKind(binding, found) + if (knownKind !== 'None') { + found.binding.resolvedKind = knownKind + binding.resolvedKind = knownKind + return knownKind + } + + if (found.binding.resolvedKind) { + return found.binding.resolvedKind + } + + // Import aliases can form cycles, e.g. A re-exports from B while B + // re-exports from A. Track the exported binding before following it. + const vKey = `${cleanId(found.moduleInfo.id)}:${found.localName}` + if (visited.has(vKey)) { + return 'None' + } + visited.add(vKey) + + const resolvedKind = await this.resolveBindingKind( + found.binding, + found.moduleInfo.id, + visited, + ) + found.binding.resolvedKind = resolvedKind + return resolvedKind + } + + private async resolveBindingKind( + binding: Binding, + fileId: string, + visited = new Set(), + ): Promise { + if (binding.resolvedKind) { + return binding.resolvedKind + } + if (binding.type === 'import') { + return this.resolveImportKind(binding, fileId, visited) } const resolvedKind = await this.resolveExprKind( @@ -1743,36 +1861,15 @@ export class StartCompiler { binding.type === 'import' && binding.importedName === '*' ) { - const knownExports = this.knownRootImports.get(binding.source) - const knownKind = knownExports?.get(callee.property.name) - if (knownKind) { - return knownKind - } - - // resolve the property from the target module - const targetModuleId = await this.resolveIdCached( - binding.source, + return this.resolveImportKind( + { + type: 'import', + source: binding.source, + importedName: callee.property.name, + }, fileId, + visited, ) - if (targetModuleId) { - const targetModule = await this.getModuleInfo(targetModuleId) - const localBindingName = targetModule.exports.get( - callee.property.name, - ) - if (localBindingName) { - const exportedBinding = - targetModule.bindings.get(localBindingName) - if (exportedBinding) { - return await this.resolveBindingKind( - exportedBinding, - targetModule.id, - visited, - ) - } - } - } else { - return 'None' - } } } return this.resolveExprKind(callee.object, fileId, visited) diff --git a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts index d423798547..3130ca47c1 100644 --- a/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts +++ b/packages/start-plugin-core/src/vite/start-compiler-plugin/plugin.ts @@ -1,4 +1,3 @@ -import { AsyncLocalStorage } from 'node:async_hooks' import { VIRTUAL_MODULES } from '@tanstack/start-server-core' import { resolve as resolvePath } from 'pathe' import { @@ -47,20 +46,6 @@ type ModuleInvalidationEnvironment = { } } -type StartCompilerPluginContext = { - environment: { - name: string - mode: string - transformRequest: (url: string) => Promise - } - load: (options: { id: string }) => Promise<{ code?: string | null }> - resolve: ( - source: string, - importer?: string, - ) => Promise<{ id: string; external?: boolean | string } | null> - error: (message: string) => never -} - function invalidateMatchingFileModules( environment: ModuleInvalidationEnvironment, ids: Iterable, @@ -196,17 +181,6 @@ export function startCompilerPlugin( opts: StartCompilerPluginOptions, ): PluginOption { const compilers = new Map>() - const compilerContextStorage = - new AsyncLocalStorage() - - const getCompilerContext = () => { - const context = compilerContextStorage.getStore() - if (!context) { - throw new Error('Start compiler Vite context is unavailable.') - } - - return context - } // Shared registry of server functions across all environments const serverFnsById: Record = {} @@ -288,30 +262,27 @@ export function startCompilerPlugin( ? createViteDevServerFnModuleSpecifierEncoder(root) : undefined, loadModule: async (id: string) => { - const compilerContext = getCompilerContext() - if (mode === 'build') { - const loaded = await compilerContext.load({ id }) + const loaded = await this.load({ id }) const code = loaded.code ?? '' compiler!.ingestModule({ code, id }) return } - if (compilerContext.environment.mode !== 'dev') { - compilerContext.error( - `could not load module ${id}: unknown environment mode ${compilerContext.environment.mode}`, + if (this.environment.mode !== 'dev') { + this.error( + `could not load module ${id}: unknown environment mode ${this.environment.mode}`, ) } - await compilerContext.environment.transformRequest( + await this.environment.transformRequest( `${id}?${SERVER_FN_LOOKUP}`, ) }, resolveId: async (source: string, importer?: string) => { - const compilerContext = getCompilerContext() - const r = await compilerContext.resolve(source, importer) + const r = await this.resolve(source, importer) if (r) { if (!r.external) { @@ -331,15 +302,11 @@ export function startCompilerPlugin( compilerTransforms, }) - const result = await compilerContextStorage.run( - this as unknown as StartCompilerPluginContext, - () => - compiler.compile({ - id, - code, - detectedKinds, - }), - ) + const result = await compiler.compile({ + id, + code, + detectedKinds, + }) return result }, }, diff --git a/packages/start-plugin-core/tests/compiler.test.ts b/packages/start-plugin-core/tests/compiler.test.ts index 58a91e3e1d..e5c99fd257 100644 --- a/packages/start-plugin-core/tests/compiler.test.ts +++ b/packages/start-plugin-core/tests/compiler.test.ts @@ -886,6 +886,75 @@ describe('re-export chain resolution', () => { expect(result!.code).not.toContain('deep-server') }) + test.each([ + { + name: 'named re-export cycle', + virtualModules: { + './factory-a': ` + export { createServerOnlyFn } from './factory-b' + `, + './factory-b': ` + export { createServerOnlyFn } from './factory-a' + `, + }, + }, + { + name: 'import alias cycle', + virtualModules: { + './factory-a': ` + import { createServerOnlyFn } from './factory-b' + export { createServerOnlyFn } + `, + './factory-b': ` + import { createServerOnlyFn } from './factory-a' + export { createServerOnlyFn } + `, + }, + }, + { + name: 'export-star cycle', + virtualModules: { + './factory-a': ` + export * from './factory-b' + `, + './factory-b': ` + export * from './factory-a' + `, + }, + }, + ])('handles circular import chain: $name', async ({ virtualModules }) => { + const compiler: StartCompiler = new StartCompiler({ + env: 'server', + envName: 'ssr', + root: '/test', + framework: 'react' as const, + providerEnvName: 'ssr', + lookupKinds: new Set(['ServerOnlyFn']), + lookupConfigurations: [], + getKnownServerFns: () => ({}), + loadModule: async (id) => { + const code = virtualModules[id as keyof typeof virtualModules] + if (code) { + compiler.ingestModule({ code, id }) + } + }, + resolveId: async (id) => { + return id in virtualModules ? id : null + }, + mode: 'build', + }) + + const result = await compiler.compile({ + id: 'circular-import-test.ts', + code: ` + import { createServerOnlyFn } from './factory-a' + const myFn = createServerOnlyFn(() => 'server-only-value') + `, + }) + + expect(result).toBeNull() + }) + test('ingestModule populates module metadata for later resolution', async () => { const compiler: StartCompiler = new StartCompiler({ env: 'server', diff --git a/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts b/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts index 8844dfbb80..e300baf6a9 100644 --- a/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts +++ b/packages/start-plugin-core/tests/createMiddleware/createMiddleware.test.ts @@ -158,4 +158,114 @@ describe('createMiddleware compiles correctly', async () => { expect(resolveIdMock).toHaveBeenCalledTimes(1) expect(resolveIdMock).toHaveBeenNthCalledWith(1, './factory', 'test.ts') }) + + test('should resolve createMiddleware from start-client-core implementation file', async () => { + const virtualModules: Record = { + '@tanstack/start-client-core': ` + export { createMiddleware } from './createMiddleware' + export { createIsomorphicFn } from '@tanstack/start-fn-stubs' + `, + '/virtual/compiler-known/middleware-factory.ts': ` + export const createMiddleware = () => ({ + server: () => createMiddleware(), + }) + `, + } + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + loadModule: async (id) => { + const code = virtualModules[id] + if (code) { + compiler.ingestModule({ code, id }) + } + }, + lookupKinds: new Set(['Middleware', 'IsomorphicFn']), + lookupConfigurations: [], + getKnownServerFns: () => ({}), + resolveId: async (source) => { + if (source === '@tanstack/start-client-core') { + return '@tanstack/start-client-core' + } + + if (source === './createMiddleware') { + return '/virtual/compiler-known/middleware-factory.ts' + } + + return null + }, + }) + + const result = await compiler.compile({ + id: '/repo/packages/start-client-core/src/createCsrfMiddleware.ts', + code: ` + import { createIsomorphicFn } from '@tanstack/start-fn-stubs' + import { createMiddleware } from './createMiddleware' + + const innerCreateCsrfMiddleware = () => { + return createMiddleware().server(() => 'server-only-middleware') + } + + export const createCsrfMiddleware = createIsomorphicFn() + .server(innerCreateCsrfMiddleware) + `, + }) + + expect(result).not.toBeNull() + expect(result!.code).not.toContain('server-only-middleware') + expect(result!.code).not.toContain('createIsomorphicFn') + }) + + test('should resolve namespace createMiddleware from start-client-core implementation file', async () => { + const virtualModules: Record = { + '@tanstack/start-client-core': ` + export { createMiddleware } from './createMiddleware' + `, + '/virtual/compiler-known/middleware-factory.ts': ` + export const createMiddleware = () => ({ + server: () => createMiddleware(), + }) + `, + } + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + loadModule: async (id) => { + const code = virtualModules[id] + if (code) { + compiler.ingestModule({ code, id }) + } + }, + lookupKinds: new Set(['Middleware']), + lookupConfigurations: [], + getKnownServerFns: () => ({}), + resolveId: async (source) => { + if (source === '@tanstack/start-client-core') { + return '@tanstack/start-client-core' + } + + if (source === './createMiddleware') { + return '/virtual/compiler-known/middleware-factory.ts' + } + + return null + }, + }) + + const result = await compiler.compile({ + id: '/repo/packages/start-client-core/src/internal.ts', + code: ` + import * as middlewareModule from './createMiddleware' + + export const middleware = middlewareModule.createMiddleware().server(() => { + return 'server-only-middleware' + }) + `, + }) + + expect(result).not.toBeNull() + expect(result!.code).not.toContain('server-only-middleware') + }) }) diff --git a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts index ce46b064b4..8b2716db77 100644 --- a/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts +++ b/packages/start-plugin-core/tests/createServerFn/createServerFn.test.ts @@ -520,4 +520,59 @@ describe('createServerFn compiles correctly', async () => { expect(firstResult!.code).toContain('createSsrRpc("constant_id"') expect(secondResult!.code).toContain('createSsrRpc("constant_id_1"') }) + + test('should resolve createServerFn from the same binding as a known root export', async () => { + const virtualModules: Record = { + '@tanstack/start-client-core': ` + export { createServerFn } from './createServerFn' + `, + '/virtual/compiler-known/server-fn-factory.ts': ` + export const createServerFn = () => ({ + handler: () => createServerFn(), + }) + `, + } + + const compiler = new StartCompiler({ + env: 'client', + ...getDefaultTestOptions('client'), + root: '/test', + mode: 'build', + loadModule: async (id) => { + const code = virtualModules[id] + if (code) { + compiler.ingestModule({ code, id }) + } + }, + lookupKinds: new Set(['ServerFn']), + lookupConfigurations: [], + getKnownServerFns: () => ({}), + resolveId: async (source) => { + if (source === '@tanstack/start-client-core') { + return '@tanstack/start-client-core' + } + + if (source === './createServerFn') { + return '/virtual/compiler-known/server-fn-factory.ts' + } + + return null + }, + }) + + const result = await compiler.compile({ + id: '/test/src/internal-server-fn.ts', + code: ` + import { createServerFn } from './createServerFn' + + export const getMessage = createServerFn().handler(() => { + return 'server-only-value' + }) + `, + }) + + expect(result).not.toBeNull() + expect(result!.code).toContain('createClientRpc') + expect(result!.code).not.toContain('server-only-value') + }) }) diff --git a/packages/start-client-core/src/tests/createCsrfMiddleware.test.ts b/packages/start-server-core/tests/createCsrfMiddleware.test.ts similarity index 98% rename from packages/start-client-core/src/tests/createCsrfMiddleware.test.ts rename to packages/start-server-core/tests/createCsrfMiddleware.test.ts index f152e82144..a46aa444d2 100644 --- a/packages/start-client-core/src/tests/createCsrfMiddleware.test.ts +++ b/packages/start-server-core/tests/createCsrfMiddleware.test.ts @@ -4,8 +4,8 @@ import { csrfSymbol, getCsrfRequestValidationResult, isCsrfRequestAllowed, -} from '../createCsrfMiddleware' -import type { RequestServerOptions } from '../createMiddleware' +} from '@tanstack/start-client-core' +import type { RequestServerOptions } from '@tanstack/start-client-core' import type { Register } from '@tanstack/router-core' const requestOrigin = 'https://app.example.com'