diff --git a/packages/start-client-core/src/buildServerFnUrl.ts b/packages/start-client-core/src/buildServerFnUrl.ts new file mode 100644 index 00000000000..021816d3eb8 --- /dev/null +++ b/packages/start-client-core/src/buildServerFnUrl.ts @@ -0,0 +1,28 @@ +import { buildServerFnUrlFromBase } from './client-rpc/serverFnUrl' +import type { ServerFnFetcherTypes } from './createServerFn' +import type { IntersectAllValidatorInputs } from './createMiddleware' + +type BuildServerFnUrlData = + TServerFn extends ServerFnFetcherTypes< + 'GET', + infer TMiddlewares, + infer TInputValidator + > + ? IntersectAllValidatorInputs + : never + +type GetServerFn = { + url: string +} & ServerFnFetcherTypes<'GET', any, any> + +export function buildServerFnUrl( + serverFn: TServerFn, + ...args: undefined extends BuildServerFnUrlData + ? [data?: BuildServerFnUrlData] + : [data: BuildServerFnUrlData] +): Promise { + return buildServerFnUrlFromBase( + serverFn.url, + args.length ? { data: args[0] } : undefined, + ) +} diff --git a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts index 2e993205bcf..333c971180d 100644 --- a/packages/start-client-core/src/client-rpc/serverFnFetcher.ts +++ b/packages/start-client-core/src/client-rpc/serverFnFetcher.ts @@ -1,12 +1,12 @@ import { createRawStreamDeserializePlugin, - encode, + hasKeys, invariant, isNotFound, parseRedirect, } from '@tanstack/router-core' -import { fromCrossJSON, toJSONAsync } from 'seroval' -import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins' +import { fromCrossJSON } from 'seroval' +import { getDefaultSerovalPlugins as getSerovalPlugins } from '../getDefaultSerovalPlugins' import { TSS_CONTENT_TYPE_FRAMED, TSS_FORMDATA_CONTEXT, @@ -14,11 +14,13 @@ import { X_TSS_SERIALIZED, validateFramedProtocolVersion, } from '../constants' +import { + buildServerFnUrlFromBase, + serializeServerFnPayload, + serializeServerFnPayloadValue, +} from './serverFnUrl' import { createFrameDecoder } from './frame-decoder' import type { FunctionMiddlewareClientFnOptions } from '../createMiddleware' -import type { Plugin as SerovalPlugin } from 'seroval' - -let serovalPlugins: Array> | null = null /** * Current async post-processing context for deserialization. @@ -84,19 +86,6 @@ async function awaitPostProcessPromises( } } -/** - * Checks if an object has at least one own enumerable property. - * More efficient than Object.keys(obj).length > 0 as it short-circuits on first property. - */ -const hop = Object.prototype.hasOwnProperty -function hasOwnProperties(obj: object): boolean { - for (const _ in obj) { - if (hop.call(obj, _)) { - return true - } - } - return false -} // caller => // serverFnFetcher => // client => @@ -112,9 +101,6 @@ export async function serverFnFetcher( args: Array, handler: (url: string, requestInit: RequestInit) => Promise, ) { - if (!serovalPlugins) { - serovalPlugins = getDefaultSerovalPlugins() - } const _first = args[0] const first = _first as FunctionMiddlewareClientFnOptions & { @@ -139,20 +125,7 @@ export async function serverFnFetcher( // If the method is GET, we need to move the payload to the query string if (first.method === 'GET') { - if (type === 'formData') { - throw new Error('FormData is not supported with GET requests') - } - const serializedPayload = await serializePayload(first) - if (serializedPayload !== undefined) { - const encodedPayload = encode({ - payload: serializedPayload, - }) - if (url.includes('?')) { - url += `&${encodedPayload}` - } else { - url += `?${encodedPayload}` - } - } + url = await buildServerFnUrlFromBase(url, first) } let body = undefined @@ -174,49 +147,21 @@ export async function serverFnFetcher( ) } -async function serializePayload( - opts: FunctionMiddlewareClientFnOptions, -): Promise { - let payloadAvailable = false - const payloadToSerialize: any = {} - if (opts.data !== undefined) { - payloadAvailable = true - payloadToSerialize['data'] = opts.data - } - - // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (opts.context && hasOwnProperties(opts.context)) { - payloadAvailable = true - payloadToSerialize['context'] = opts.context - } - - if (payloadAvailable) { - return serialize(payloadToSerialize) - } - return undefined -} - -async function serialize(data: any) { - return JSON.stringify( - await Promise.resolve(toJSONAsync(data, { plugins: serovalPlugins! })), - ) -} - async function getFetchBody( opts: FunctionMiddlewareClientFnOptions, ): Promise<{ body: FormData | string; contentType?: string } | undefined> { if (opts.data instanceof FormData) { let serializedContext = undefined // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (opts.context && hasOwnProperties(opts.context)) { - serializedContext = await serialize(opts.context) + if (opts.context && hasKeys(opts.context)) { + serializedContext = await serializeServerFnPayloadValue(opts.context) } if (serializedContext !== undefined) { opts.data.set(TSS_FORMDATA_CONTEXT, serializedContext) } return { body: opts.data } } - const serializedBody = await serializePayload(opts) + const serializedBody = await serializeServerFnPayload(opts) if (serializedBody) { return { body: serializedBody, contentType: 'application/json' } } @@ -281,7 +226,7 @@ async function getResponse(fn: () => Promise) { // Create deserialize plugin that wires up the raw streams const rawStreamPlugin = createRawStreamDeserializePlugin(getOrCreateStream) - const plugins = [rawStreamPlugin, ...(serovalPlugins || [])] + const plugins = [rawStreamPlugin, ...getSerovalPlugins()] const refs = new Map() result = await processFramedResponse({ @@ -299,7 +244,7 @@ async function getResponse(fn: () => Promise) { const postProcessPromises: Array> = [] setPostProcessContext(postProcessPromises) try { - result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! }) + result = fromCrossJSON(jsonPayload, { plugins: getSerovalPlugins() }) } finally { setPostProcessContext(null) } diff --git a/packages/start-client-core/src/client-rpc/serverFnUrl.ts b/packages/start-client-core/src/client-rpc/serverFnUrl.ts new file mode 100644 index 00000000000..65f61169e82 --- /dev/null +++ b/packages/start-client-core/src/client-rpc/serverFnUrl.ts @@ -0,0 +1,60 @@ +import { encode, hasKeys } from '@tanstack/router-core' +import { toJSONAsync } from 'seroval' +import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins' + +type ServerFnUrlPayloadOptions = { + data?: any + context?: any +} + +export async function buildServerFnUrlFromBase( + url: string, + opts?: ServerFnUrlPayloadOptions, +): Promise { + if (typeof FormData !== 'undefined' && opts?.data instanceof FormData) { + throw new Error('FormData is not supported with GET requests') + } + + const serializedPayload = await serializeServerFnPayload(opts) + if (serializedPayload === undefined) { + return url + } + + const encodedPayload = encode({ + payload: serializedPayload, + }) + + return url.includes('?') + ? `${url}&${encodedPayload}` + : `${url}?${encodedPayload}` +} + +export async function serializeServerFnPayload( + opts?: ServerFnUrlPayloadOptions, +): Promise { + let payloadAvailable = false + const payloadToSerialize: any = {} + if (opts?.data !== undefined) { + payloadAvailable = true + payloadToSerialize['data'] = opts.data + } + + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition + if (opts?.context && hasKeys(opts.context)) { + payloadAvailable = true + payloadToSerialize['context'] = opts.context + } + + if (payloadAvailable) { + return serializeServerFnPayloadValue(payloadToSerialize) + } + return undefined +} + +export async function serializeServerFnPayloadValue(data: any) { + return JSON.stringify( + await Promise.resolve( + toJSONAsync(data, { plugins: getDefaultSerovalPlugins() }), + ), + ) +} diff --git a/packages/start-client-core/src/createServerFn.ts b/packages/start-client-core/src/createServerFn.ts index 37d4ead1226..34e3a877c4a 100644 --- a/packages/start-client-core/src/createServerFn.ts +++ b/packages/start-client-core/src/createServerFn.ts @@ -57,7 +57,7 @@ export type ServerFnStrictOutput = : true export type CreateServerFn = < - TMethod extends Method, + TMethod extends Method = 'GET', TStrict extends ServerFnStrict = true, TResponse = unknown, TMiddlewares = undefined, @@ -368,10 +368,15 @@ export type CompiledFetcherFnOptions = { context?: any } -export type Fetcher = +export type Fetcher< + TMiddlewares, + TInputValidator, + TResponse, + TMethod extends Method = Method, +> = undefined extends IntersectAllValidatorInputs - ? OptionalFetcher - : RequiredFetcher + ? OptionalFetcher + : RequiredFetcher export interface FetcherBase { [TSS_SERVER_FUNCTION]: true @@ -385,24 +390,41 @@ export interface FetcherBase { }) => Promise } -export interface OptionalFetcher< +export type OptionalFetcher< TMiddlewares, TInputValidator, TResponse, -> extends FetcherBase { - ( - options?: OptionalFetcherDataOptions, - ): Promise> -} + TMethod extends Method = Method, +> = FetcherBase & + ServerFnFetcherTypes & { + ( + options?: OptionalFetcherDataOptions, + ): Promise> + } -export interface RequiredFetcher< +export type RequiredFetcher< TMiddlewares, TInputValidator, TResponse, -> extends FetcherBase { - ( - opts: RequiredFetcherDataOptions, - ): Promise> + TMethod extends Method = Method, +> = FetcherBase & + ServerFnFetcherTypes & { + ( + opts: RequiredFetcherDataOptions, + ): Promise> + } + +export interface ServerFnFetcherTypes< + in out TMethod extends Method, + in out TMiddlewares, + in out TInputValidator, +> { + '~serverFnTypes': { + method: TMethod + middlewares: TMiddlewares + inputValidator: TInputValidator + allInput: IntersectAllValidatorInputs + } } // Ideally, this type should just be `export type CustomFetch = typeof globalThis.fetch`, but that conflicts with the type overrides the `bun-types` package - a dependency of unplugin. @@ -735,7 +757,7 @@ export interface ServerFnHandler< TNewResponse, TStrict >, - ) => Fetcher + ) => Fetcher } export interface ServerFnBuilder< diff --git a/packages/start-client-core/src/index.tsx b/packages/start-client-core/src/index.tsx index f7835c98592..476e20a1ebb 100644 --- a/packages/start-client-core/src/index.tsx +++ b/packages/start-client-core/src/index.tsx @@ -15,6 +15,7 @@ export { type IsomorphicFnBase, } from '@tanstack/start-fn-stubs' export { createServerFn } from './createServerFn' +export { buildServerFnUrl } from './buildServerFnUrl' export { createMiddleware, type IntersectAllValidatorInputs, @@ -65,6 +66,7 @@ export type { FetcherBaseOptions, ServerFn, ServerFnCtx, + ServerFnFetcherTypes, ServerFnOptions, ServerFnStrict, ServerFnStrictInput, diff --git a/packages/start-client-core/src/tests/buildServerFnUrl.test.ts b/packages/start-client-core/src/tests/buildServerFnUrl.test.ts new file mode 100644 index 00000000000..e2d58151b77 --- /dev/null +++ b/packages/start-client-core/src/tests/buildServerFnUrl.test.ts @@ -0,0 +1,52 @@ +import { decode } from '@tanstack/router-core' +import { fromJSON } from 'seroval' +import { describe, expect, test } from 'vitest' +import { buildServerFnUrlFromBase } from '../client-rpc/serverFnUrl' +import { getDefaultSerovalPlugins } from '../getDefaultSerovalPlugins' + +function getPayload(url: string) { + const search = url.split('?')[1] ?? '' + const payload = decode(search).payload + return fromJSON(JSON.parse(payload), { + plugins: getDefaultSerovalPlugins(), + }) +} + +describe('buildServerFnUrlFromBase', () => { + test('returns base url without payload', async () => { + await expect(buildServerFnUrlFromBase('/_serverFn/test')).resolves.toBe( + '/_serverFn/test', + ) + }) + + test('serializes data into payload query param', async () => { + const url = await buildServerFnUrlFromBase('/_serverFn/test', { + data: { page: 1, filter: 'new' }, + }) + + expect(url).toContain('/_serverFn/test?payload=') + expect(getPayload(url)).toEqual({ + data: { page: 1, filter: 'new' }, + }) + }) + + test('appends payload to existing query params', async () => { + const url = await buildServerFnUrlFromBase( + '/_serverFn/test?existing=true', + { + data: { page: 1 }, + }, + ) + + expect(url).toContain('/_serverFn/test?existing=true&payload=') + expect(getPayload(url)).toEqual({ + data: { page: 1 }, + }) + }) + + test('rejects FormData', async () => { + await expect( + buildServerFnUrlFromBase('/_serverFn/test', { data: new FormData() }), + ).rejects.toThrow('FormData is not supported with GET requests') + }) +}) diff --git a/packages/start-client-core/src/tests/createServerFn.test-d.ts b/packages/start-client-core/src/tests/createServerFn.test-d.ts index eabaca198b5..62d7134f026 100644 --- a/packages/start-client-core/src/tests/createServerFn.test-d.ts +++ b/packages/start-client-core/src/tests/createServerFn.test-d.ts @@ -1,4 +1,5 @@ import { describe, expectTypeOf, test } from 'vitest' +import { buildServerFnUrl } from '../buildServerFnUrl' import { createMiddleware } from '../createMiddleware' import { createServerFn } from '../createServerFn' import { TSS_SERVER_FUNCTION } from '../constants' @@ -32,6 +33,55 @@ test('createServerFn without middleware', () => { }) }) +test('buildServerFnUrl is typed for default GET server functions', () => { + const fn = createServerFn() + .inputValidator((input: { page: number }) => input) + .handler(({ data }) => data.page) + + expectTypeOf(fn.url).toEqualTypeOf() + expectTypeOf(buildServerFnUrl(fn, { page: 1 })).toEqualTypeOf< + Promise + >() + + // @ts-expect-error input is required + buildServerFnUrl(fn) + // @ts-expect-error page must be a number + buildServerFnUrl(fn, { page: '1' }) +}) + +test('buildServerFnUrl is typed for explicit GET server functions', () => { + const fn = createServerFn({ method: 'GET' }) + .inputValidator((input: { search: string }) => input) + .handler(({ data }) => data.search) + + expectTypeOf(fn.url).toEqualTypeOf() + expectTypeOf(buildServerFnUrl(fn, { search: 'tanstack' })).toEqualTypeOf< + Promise + >() +}) + +test('buildServerFnUrl allows optional GET input', () => { + const fn = createServerFn() + .inputValidator((input: { search: string } | undefined) => input) + .handler(({ data }) => data?.search) + + expectTypeOf(fn.url).toEqualTypeOf() + expectTypeOf(buildServerFnUrl(fn)).toEqualTypeOf>() + expectTypeOf(buildServerFnUrl(fn, { search: 'tanstack' })).toEqualTypeOf< + Promise + >() +}) + +test('buildServerFnUrl rejects POST server functions', () => { + const fn = createServerFn({ method: 'POST' }) + .inputValidator((input: { page: number }) => input) + .handler(({ data }) => data.page) + + expectTypeOf(fn.url).toEqualTypeOf() + // @ts-expect-error POST server functions cannot build GET input URLs + buildServerFnUrl(fn, { page: 1 }) +}) + test('createServerFn with validator function', () => { const fnAfterValidator = createServerFn({ method: 'GET',