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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions packages/start-client-core/src/buildServerFnUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { buildServerFnUrlFromBase } from './client-rpc/serverFnUrl'
import type { ServerFnFetcherTypes } from './createServerFn'
import type { IntersectAllValidatorInputs } from './createMiddleware'

type BuildServerFnUrlData<TServerFn> =
TServerFn extends ServerFnFetcherTypes<
'GET',
infer TMiddlewares,
infer TInputValidator
>
? IntersectAllValidatorInputs<TMiddlewares, TInputValidator>
: never

type GetServerFn = {
url: string
} & ServerFnFetcherTypes<'GET', any, any>

export function buildServerFnUrl<TServerFn extends GetServerFn>(
serverFn: TServerFn,
...args: undefined extends BuildServerFnUrlData<TServerFn>
? [data?: BuildServerFnUrlData<TServerFn>]
: [data: BuildServerFnUrlData<TServerFn>]
): Promise<string> {
return buildServerFnUrlFromBase(
serverFn.url,
args.length ? { data: args[0] } : undefined,
)
}
83 changes: 14 additions & 69 deletions packages/start-client-core/src/client-rpc/serverFnFetcher.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,26 @@
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,
X_TSS_RAW_RESPONSE,
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<SerovalPlugin<any, any>> | null = null

/**
* Current async post-processing context for deserialization.
Expand Down Expand Up @@ -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 =>
Expand All @@ -112,9 +101,6 @@ export async function serverFnFetcher(
args: Array<any>,
handler: (url: string, requestInit: RequestInit) => Promise<Response>,
) {
if (!serovalPlugins) {
serovalPlugins = getDefaultSerovalPlugins()
}
const _first = args[0]

const first = _first as FunctionMiddlewareClientFnOptions<any, any, any> & {
Expand All @@ -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
Expand All @@ -174,49 +147,21 @@ export async function serverFnFetcher(
)
}

async function serializePayload(
opts: FunctionMiddlewareClientFnOptions<any, any, any>,
): Promise<string | undefined> {
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<any, any, any>,
): 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' }
}
Expand Down Expand Up @@ -281,7 +226,7 @@ async function getResponse(fn: () => Promise<Response>) {
// 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({
Expand All @@ -299,7 +244,7 @@ async function getResponse(fn: () => Promise<Response>) {
const postProcessPromises: Array<Promise<unknown>> = []
setPostProcessContext(postProcessPromises)
try {
result = fromCrossJSON(jsonPayload, { plugins: serovalPlugins! })
result = fromCrossJSON(jsonPayload, { plugins: getSerovalPlugins() })
} finally {
setPostProcessContext(null)
}
Expand Down
60 changes: 60 additions & 0 deletions packages/start-client-core/src/client-rpc/serverFnUrl.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string | undefined> {
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() }),
),
)
}
54 changes: 38 additions & 16 deletions packages/start-client-core/src/createServerFn.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export type ServerFnStrictOutput<TStrict extends ServerFnStrict> =
: true

export type CreateServerFn<TRegister> = <
TMethod extends Method,
TMethod extends Method = 'GET',
TStrict extends ServerFnStrict = true,
TResponse = unknown,
TMiddlewares = undefined,
Expand Down Expand Up @@ -368,10 +368,15 @@ export type CompiledFetcherFnOptions = {
context?: any
}

export type Fetcher<TMiddlewares, TInputValidator, TResponse> =
export type Fetcher<
TMiddlewares,
TInputValidator,
TResponse,
TMethod extends Method = Method,
> =
undefined extends IntersectAllValidatorInputs<TMiddlewares, TInputValidator>
? OptionalFetcher<TMiddlewares, TInputValidator, TResponse>
: RequiredFetcher<TMiddlewares, TInputValidator, TResponse>
? OptionalFetcher<TMiddlewares, TInputValidator, TResponse, TMethod>
: RequiredFetcher<TMiddlewares, TInputValidator, TResponse, TMethod>

export interface FetcherBase {
[TSS_SERVER_FUNCTION]: true
Expand All @@ -385,24 +390,41 @@ export interface FetcherBase {
}) => Promise<unknown>
}

export interface OptionalFetcher<
export type OptionalFetcher<
TMiddlewares,
TInputValidator,
TResponse,
> extends FetcherBase {
(
options?: OptionalFetcherDataOptions<TMiddlewares, TInputValidator>,
): Promise<Awaited<TResponse>>
}
TMethod extends Method = Method,
> = FetcherBase &
ServerFnFetcherTypes<TMethod, TMiddlewares, TInputValidator> & {
(
options?: OptionalFetcherDataOptions<TMiddlewares, TInputValidator>,
): Promise<Awaited<TResponse>>
}

export interface RequiredFetcher<
export type RequiredFetcher<
TMiddlewares,
TInputValidator,
TResponse,
> extends FetcherBase {
(
opts: RequiredFetcherDataOptions<TMiddlewares, TInputValidator>,
): Promise<Awaited<TResponse>>
TMethod extends Method = Method,
> = FetcherBase &
ServerFnFetcherTypes<TMethod, TMiddlewares, TInputValidator> & {
(
opts: RequiredFetcherDataOptions<TMiddlewares, TInputValidator>,
): Promise<Awaited<TResponse>>
}

export interface ServerFnFetcherTypes<
in out TMethod extends Method,
in out TMiddlewares,
in out TInputValidator,
> {
'~serverFnTypes': {
method: TMethod
middlewares: TMiddlewares
inputValidator: TInputValidator
allInput: IntersectAllValidatorInputs<TMiddlewares, TInputValidator>
}
}

// 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.
Expand Down Expand Up @@ -735,7 +757,7 @@ export interface ServerFnHandler<
TNewResponse,
TStrict
>,
) => Fetcher<TMiddlewares, TInputValidator, TNewResponse>
) => Fetcher<TMiddlewares, TInputValidator, TNewResponse, TMethod>
}

export interface ServerFnBuilder<
Expand Down
2 changes: 2 additions & 0 deletions packages/start-client-core/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export {
type IsomorphicFnBase,
} from '@tanstack/start-fn-stubs'
export { createServerFn } from './createServerFn'
export { buildServerFnUrl } from './buildServerFnUrl'
export {
createMiddleware,
type IntersectAllValidatorInputs,
Expand Down Expand Up @@ -65,6 +66,7 @@ export type {
FetcherBaseOptions,
ServerFn,
ServerFnCtx,
ServerFnFetcherTypes,
ServerFnOptions,
ServerFnStrict,
ServerFnStrictInput,
Expand Down
Loading
Loading