@trpc/next 10.26.0 → 10.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,218 @@
1
+ import {
2
+ CreateTRPCClientOptions,
3
+ TRPCClientError,
4
+ TRPCLink,
5
+ TRPCRequestOptions,
6
+ createTRPCUntypedClient,
7
+ } from '@trpc/client';
8
+ import { transformResult } from '@trpc/client/shared';
9
+ import {
10
+ AnyProcedure,
11
+ AnyRouter,
12
+ MaybePromise,
13
+ ProcedureOptions,
14
+ Simplify,
15
+ inferHandlerInput,
16
+ } from '@trpc/server';
17
+ import { observable } from '@trpc/server/observable';
18
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
19
+ import { TRPCActionHandler } from './server';
20
+ import { ActionHandlerDef, isFormData } from './shared';
21
+
22
+ interface Def {
23
+ input?: any;
24
+ output?: any;
25
+ errorShape: any;
26
+ }
27
+
28
+ type MutationArgs<TDef extends Def> = TDef['input'] extends void
29
+ ? [input?: undefined | void, opts?: ProcedureOptions]
30
+ : [input: TDef['input'] | FormData, opts?: ProcedureOptions];
31
+
32
+ interface UseTRPCActionBaseResult<TDef extends Def> {
33
+ mutate: (...args: MutationArgs<TDef>) => void;
34
+ mutateAsync: (...args: MutationArgs<TDef>) => Promise<Def['output']>;
35
+ }
36
+
37
+ interface UseTRPCActionSuccessResult<TDef extends Def>
38
+ extends UseTRPCActionBaseResult<TDef> {
39
+ data: TDef['output'];
40
+ error?: never;
41
+ status: 'success';
42
+ }
43
+
44
+ interface UseTRPCActionErrorResult<TDef extends Def>
45
+ extends UseTRPCActionBaseResult<TDef> {
46
+ data?: never;
47
+ error: TRPCClientError<TDef['errorShape']>;
48
+ status: 'error';
49
+ }
50
+
51
+ interface UseTRPCActionIdleResult<TDef extends Def>
52
+ extends UseTRPCActionBaseResult<TDef> {
53
+ data?: never;
54
+ error?: never;
55
+ status: 'idle';
56
+ }
57
+
58
+ interface UseTRPCActionLoadingResult<TDef extends Def>
59
+ extends UseTRPCActionBaseResult<TDef> {
60
+ data?: never;
61
+ error?: never;
62
+ status: 'loading';
63
+ }
64
+
65
+ // ts-prune-ignore-next
66
+ export type UseTRPCActionResult<TDef extends Def> =
67
+ | UseTRPCActionSuccessResult<TDef>
68
+ | UseTRPCActionErrorResult<TDef>
69
+ | UseTRPCActionIdleResult<TDef>
70
+ | UseTRPCActionLoadingResult<TDef>;
71
+
72
+ type ActionContext = {
73
+ _action: (...args: any[]) => Promise<any>;
74
+ };
75
+
76
+ // ts-prune-ignore-next
77
+ export function experimental_serverActionLink<
78
+ TRouter extends AnyRouter = AnyRouter,
79
+ >(): TRPCLink<TRouter> {
80
+ return (runtime) =>
81
+ ({ op }) =>
82
+ observable((observer) => {
83
+ const context = op.context as ActionContext;
84
+
85
+ context
86
+ ._action(
87
+ isFormData(op.input)
88
+ ? op.input
89
+ : runtime.transformer.serialize(op.input),
90
+ )
91
+ .then((data) => {
92
+ const transformed = transformResult(data, runtime);
93
+
94
+ if (!transformed.ok) {
95
+ observer.error(TRPCClientError.from(transformed.error, {}));
96
+ return;
97
+ }
98
+ observer.next({
99
+ context: op.context,
100
+ result: transformed.result,
101
+ });
102
+ observer.complete();
103
+ })
104
+ .catch((cause) => observer.error(TRPCClientError.from(cause)));
105
+ });
106
+ }
107
+
108
+ // ts-prune-ignore-next
109
+ /**
110
+ * @internal
111
+ */
112
+ export type inferActionResultProps<TProc extends AnyProcedure> = {
113
+ input: inferHandlerInput<TProc>[0];
114
+ output: TProc['_def']['_output_out'];
115
+ errorShape: TProc['_def']['_config']['$types']['errorShape'];
116
+ };
117
+
118
+ interface UseTRPCActionOptions<TDef extends Def> {
119
+ onSuccess?: (result: TDef['output']) => void | MaybePromise<void>;
120
+ onError?: (result: TRPCClientError<TDef['errorShape']>) => MaybePromise<void>;
121
+ }
122
+
123
+ // ts-prune-ignore-next
124
+ export function experimental_createActionHook<TRouter extends AnyRouter>(
125
+ opts: CreateTRPCClientOptions<TRouter>,
126
+ ) {
127
+ type ActionContext = {
128
+ _action: (...args: any[]) => Promise<any>;
129
+ };
130
+ const client = createTRPCUntypedClient(opts);
131
+ return function useAction<TDef extends ActionHandlerDef>(
132
+ handler: TRPCActionHandler<TDef>,
133
+ useActionOpts?: UseTRPCActionOptions<Simplify<TDef>>,
134
+ ) {
135
+ const count = useRef(0);
136
+
137
+ type Result = UseTRPCActionResult<TDef>;
138
+ type State = Omit<Result, 'mutate' | 'mutateAsync'>;
139
+ const [state, setState] = useState<State>({
140
+ status: 'idle',
141
+ });
142
+
143
+ const actionOptsRef = useRef(useActionOpts);
144
+ actionOptsRef.current = useActionOpts;
145
+
146
+ useEffect(() => {
147
+ return () => {
148
+ // cleanup after unmount to prevent calling hook opts after unmount
149
+ count.current = -1;
150
+ actionOptsRef.current = undefined;
151
+ };
152
+ }, []);
153
+
154
+ const mutateAsync = useCallback(
155
+ (input: any, requestOptions?: TRPCRequestOptions) => {
156
+ const idx = ++count.current;
157
+ const context = {
158
+ ...requestOptions?.context,
159
+ _action(innerInput) {
160
+ return handler(innerInput);
161
+ },
162
+ } as ActionContext;
163
+
164
+ setState({
165
+ status: 'loading',
166
+ });
167
+ return client
168
+ .mutation('serverAction', input, {
169
+ ...requestOptions,
170
+ context,
171
+ })
172
+ .then(async (data) => {
173
+ await actionOptsRef.current?.onSuccess?.(data as any);
174
+ if (idx !== count.current) {
175
+ return;
176
+ }
177
+ setState({
178
+ status: 'success',
179
+ data: data as any,
180
+ });
181
+ })
182
+ .catch(async (error) => {
183
+ await actionOptsRef.current?.onError?.(error);
184
+ throw error;
185
+ })
186
+ .catch((error) => {
187
+ if (idx !== count.current) {
188
+ return;
189
+ }
190
+ setState({
191
+ status: 'error',
192
+ error: TRPCClientError.from(error, {}),
193
+ });
194
+ throw error;
195
+ });
196
+ },
197
+ [handler],
198
+ ) as Result['mutateAsync'];
199
+
200
+ const mutate: Result['mutate'] = useCallback(
201
+ (...args: any[]) => {
202
+ void (mutateAsync as any)(...args).catch(() => {
203
+ // ignored
204
+ });
205
+ },
206
+ [mutateAsync],
207
+ );
208
+
209
+ return useMemo(
210
+ () => ({
211
+ ...state,
212
+ mutate,
213
+ mutateAsync,
214
+ }),
215
+ [mutate, mutateAsync, state],
216
+ ) as Result;
217
+ };
218
+ }
@@ -0,0 +1,58 @@
1
+ import { formDataToObject } from './formDataToObject';
2
+
3
+ test('basic', () => {
4
+ const formData = new FormData();
5
+
6
+ formData.append('foo', 'bar');
7
+
8
+ expect(formDataToObject(formData)).toEqual({
9
+ foo: 'bar',
10
+ });
11
+ });
12
+
13
+ test('multiple values on the same key', () => {
14
+ const formData = new FormData();
15
+
16
+ formData.append('foo', 'bar');
17
+ formData.append('foo', 'baz');
18
+
19
+ expect(formDataToObject(formData)).toEqual({
20
+ foo: ['bar', 'baz'],
21
+ });
22
+ });
23
+
24
+ test('deep key', () => {
25
+ const formData = new FormData();
26
+
27
+ formData.append('foo.bar.baz', 'qux');
28
+
29
+ expect(formDataToObject(formData)).toEqual({
30
+ foo: {
31
+ bar: {
32
+ baz: 'qux',
33
+ },
34
+ },
35
+ });
36
+ });
37
+
38
+ test('array', () => {
39
+ const formData = new FormData();
40
+
41
+ formData.append('foo[0]', 'bar');
42
+ formData.append('foo[1]', 'baz');
43
+
44
+ expect(formDataToObject(formData)).toEqual({
45
+ foo: ['bar', 'baz'],
46
+ });
47
+ });
48
+
49
+ test('array with dot notation', () => {
50
+ const formData = new FormData();
51
+
52
+ formData.append('foo.0', 'bar');
53
+ formData.append('foo.1', 'baz');
54
+
55
+ expect(formDataToObject(formData)).toEqual({
56
+ foo: ['bar', 'baz'],
57
+ });
58
+ });
@@ -0,0 +1,36 @@
1
+ /* eslint-disable @typescript-eslint/no-non-null-assertion */
2
+ function set(
3
+ obj: Record<string, any>,
4
+ path: string | string[],
5
+ value: unknown,
6
+ ): void {
7
+ if (typeof path === 'string') {
8
+ path = path.split(/[\.\[\]]/).filter(Boolean);
9
+ }
10
+
11
+ if (path.length > 1) {
12
+ const p = path.shift()!;
13
+ const isArrayIndex = /^\d+$/.test(path[0]!);
14
+ obj[p] = obj[p] || (isArrayIndex ? [] : {});
15
+ set(obj[p], path, value);
16
+ return;
17
+ }
18
+ const p = path[0]!;
19
+ if (obj[p] === undefined) {
20
+ obj[p] = value;
21
+ } else if (Array.isArray(obj[p])) {
22
+ obj[p].push(value);
23
+ } else {
24
+ obj[p] = [obj[p], value];
25
+ }
26
+ }
27
+
28
+ export function formDataToObject(formData: FormData) {
29
+ const obj: Record<string, unknown> = {};
30
+
31
+ for (const [key, value] of formData.entries()) {
32
+ set(obj, key, value);
33
+ }
34
+
35
+ return obj;
36
+ }
@@ -1,14 +1,34 @@
1
1
  /// <reference types="next" />
2
-
3
2
  import {
4
3
  CreateTRPCProxyClient,
5
4
  clientCallTypeToProcedureType,
6
5
  createTRPCUntypedClient,
7
6
  } from '@trpc/client';
8
- import { AnyRouter } from '@trpc/server';
9
- import { createRecursiveProxy } from '@trpc/server/shared';
7
+ import {
8
+ AnyProcedure,
9
+ AnyRootConfig,
10
+ AnyRouter,
11
+ CombinedDataTransformer,
12
+ MaybePromise,
13
+ Simplify,
14
+ TRPCError,
15
+ getTRPCErrorFromUnknown,
16
+ inferProcedureInput,
17
+ } from '@trpc/server';
18
+ import { TRPCResponse } from '@trpc/server/rpc';
19
+ import {
20
+ createRecursiveProxy,
21
+ getErrorShape,
22
+ transformTRPCResponse,
23
+ } from '@trpc/server/shared';
10
24
  import { cache } from 'react';
11
- import { CreateTRPCNextAppRouterOptions } from './shared';
25
+ import { formDataToObject } from './formDataToObject';
26
+ import {
27
+ ActionHandlerDef,
28
+ CreateTRPCNextAppRouterOptions,
29
+ inferActionDef,
30
+ isFormData,
31
+ } from './shared';
12
32
 
13
33
  // ts-prune-ignore-next
14
34
  export function experimental_createTRPCNextAppDirServer<
@@ -32,3 +52,89 @@ export function experimental_createTRPCNextAppDirServer<
32
52
  return (client[procedureType] as any)(fullPath, ...callOpts.args);
33
53
  }) as CreateTRPCProxyClient<TRouter>;
34
54
  }
55
+
56
+ /**
57
+ * @internal
58
+ */
59
+ export type TRPCActionHandler<TDef extends ActionHandlerDef> = (
60
+ input: TDef['input'] | FormData,
61
+ ) => Promise<TRPCResponse<TDef['output'], TDef['errorShape']>>;
62
+
63
+ export function experimental_createServerActionHandler<
64
+ TInstance extends {
65
+ _config: AnyRootConfig;
66
+ },
67
+ >(
68
+ t: TInstance,
69
+ opts: {
70
+ createContext: () => MaybePromise<TInstance['_config']['$types']['ctx']>;
71
+ /**
72
+ * Transform form data to a `Record` before passing it to the procedure
73
+ * @default true
74
+ */
75
+ normalizeFormData?: boolean;
76
+ },
77
+ ) {
78
+ const config = t._config;
79
+ const { normalizeFormData = true, createContext } = opts;
80
+
81
+ const transformer = config.transformer as CombinedDataTransformer;
82
+
83
+ // TODO allow this to take a `TRouter` in addition to a `AnyProcedure`
84
+ return function createServerAction<TProc extends AnyProcedure>(
85
+ proc: TProc,
86
+ ): TRPCActionHandler<Simplify<inferActionDef<TProc>>> {
87
+ return async function actionHandler(
88
+ rawInput: inferProcedureInput<TProc> | FormData,
89
+ ) {
90
+ const ctx: undefined | TInstance['_config']['$types']['ctx'] = undefined;
91
+ try {
92
+ const ctx = await createContext();
93
+ if (normalizeFormData && isFormData(rawInput)) {
94
+ // Normalizes formdata so we can use `z.object({})` etc on the server
95
+ try {
96
+ rawInput = formDataToObject(rawInput);
97
+ } catch {
98
+ throw new TRPCError({
99
+ code: 'INTERNAL_SERVER_ERROR',
100
+ message: 'Failed to convert FormData to an object',
101
+ });
102
+ }
103
+ } else if (rawInput && !isFormData(rawInput)) {
104
+ rawInput = transformer.input.deserialize(rawInput);
105
+ }
106
+
107
+ const data = await proc({
108
+ input: undefined,
109
+ ctx,
110
+ path: 'serverAction',
111
+ rawInput,
112
+ type: proc._type,
113
+ });
114
+
115
+ const transformedJSON = transformTRPCResponse(config, {
116
+ result: {
117
+ data,
118
+ },
119
+ });
120
+ return transformedJSON;
121
+ } catch (cause) {
122
+ const error = getTRPCErrorFromUnknown(cause);
123
+ const shape = getErrorShape({
124
+ config,
125
+ ctx,
126
+ error,
127
+ input: rawInput,
128
+ path: 'serverAction',
129
+ type: proc._type,
130
+ });
131
+
132
+ // TODO: send the right HTTP header?!
133
+
134
+ return transformTRPCResponse(t._config, {
135
+ error: shape,
136
+ });
137
+ }
138
+ } as TRPCActionHandler<inferActionDef<TProc>>;
139
+ };
140
+ }
@@ -4,11 +4,13 @@ import {
4
4
  TRPCUntypedClient,
5
5
  } from '@trpc/client';
6
6
  import {
7
+ AnyProcedure,
7
8
  AnyQueryProcedure,
8
9
  AnyRouter,
9
10
  Filter,
10
11
  ProtectedIntersection,
11
12
  ThenArg,
13
+ inferHandlerInput,
12
14
  } from '@trpc/server';
13
15
  import { createRecursiveProxy } from '@trpc/server/shared';
14
16
 
@@ -59,3 +61,33 @@ export type CreateTRPCNextAppRouter<TRouter extends AnyRouter> =
59
61
  export interface CreateTRPCNextAppRouterOptions<TRouter extends AnyRouter> {
60
62
  config: () => CreateTRPCClientOptions<TRouter>;
61
63
  }
64
+
65
+ /**
66
+ * @internal
67
+ */
68
+ export function isFormData(value: unknown): value is FormData {
69
+ if (typeof FormData === 'undefined') {
70
+ // FormData is not supported
71
+ return false;
72
+ }
73
+ return value instanceof FormData;
74
+ }
75
+
76
+ /**
77
+ * @internal
78
+ */
79
+ export interface ActionHandlerDef {
80
+ input?: any;
81
+ output?: any;
82
+ errorShape: any;
83
+ }
84
+
85
+ // ts-prune-ignore-next
86
+ /**
87
+ * @internal
88
+ */
89
+ export type inferActionDef<TProc extends AnyProcedure> = {
90
+ input: inferHandlerInput<TProc>[0];
91
+ output: TProc['_def']['_output_out'];
92
+ errorShape: TProc['_def']['_config']['$types']['errorShape'];
93
+ };