Skip to content

Commit a7755ef

Browse files
feat: Next 13 app Dir fetch support! (#315)
* init commit * linting * add request cache * cleanup, fix tests * add changeset * Update early-frogs-joke.md
1 parent 16501dd commit a7755ef

10 files changed

Lines changed: 212 additions & 16 deletions

File tree

.changeset/early-frogs-joke.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@ts-rest/core': minor
3+
'@ts-rest/next': minor
4+
---
5+
6+
Adds support for fetch cache and support for Nextjs App Dir fetch, see this PR for more info: https://github.com/ts-rest/ts-rest/pull/315

apps/example-next/next.config.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ const nextConfig = {
1010
// See: https://github.com/gregberge/svgr
1111
svgr: false,
1212
},
13+
expiremental: {
14+
serverActions: true
15+
}
1316
};
1417

1518
module.exports = withNx(nextConfig);

libs/ts-rest/core/src/lib/client.spec.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ type ClientGetPostsType = Expect<
156156
Equal<
157157
Parameters<typeof client.posts.getPosts>[0],
158158
| {
159+
cache?: RequestCache;
159160
query?: {
160161
take?: number;
161162
skip?: number;
@@ -182,6 +183,7 @@ type ClientGetPostType = Expect<
182183
Equal<
183184
Parameters<typeof client.posts.getPost>[0],
184185
{
186+
cache?: RequestCache;
185187
params: {
186188
id: string;
187189
};
@@ -338,6 +340,7 @@ describe('client', () => {
338340
published: true,
339341
filter: { title: 'test' },
340342
},
343+
341344
});
342345

343346
expect(result.body).toStrictEqual(value);
@@ -629,6 +632,7 @@ type CustomClientGetPostsType = Expect<
629632
Equal<
630633
Parameters<typeof customClient.posts.getPosts>[0],
631634
{
635+
cache?: RequestCache;
632636
query?: {
633637
take?: number;
634638
skip?: number;
@@ -655,6 +659,7 @@ type CustomClientGetPostType = Expect<
655659
Equal<
656660
Parameters<typeof customClient.posts.getPost>[0],
657661
{
662+
cache?: RequestCache;
658663
params: {
659664
id: string;
660665
};

libs/ts-rest/core/src/lib/client.ts

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,16 @@ import {
77
ClientInferRequest,
88
ClientInferResponses,
99
PartialClientInferRequest,
10+
NextClientArgs,
11+
Frameworks
1012
} from './infer-types';
1113

1214
type RecursiveProxyObj<T extends AppRouter, TClientArgs extends ClientArgs> = {
1315
[TKey in keyof T]: T[TKey] extends AppRoute
14-
? AppRouteFunction<T[TKey], TClientArgs>
15-
: T[TKey] extends AppRouter
16-
? RecursiveProxyObj<T[TKey], TClientArgs>
17-
: never;
16+
? AppRouteFunction<T[TKey], TClientArgs>
17+
: T[TKey] extends AppRouter
18+
? RecursiveProxyObj<T[TKey], TClientArgs>
19+
: never;
1820
};
1921

2022
/**
@@ -59,6 +61,12 @@ export type ApiFetcherArgs = {
5961
contentType: AppRouteMutation['contentType'];
6062
credentials?: RequestCredentials;
6163
signal?: AbortSignal;
64+
cache?: RequestCache;
65+
/**
66+
* Only to be used by `@ts-rest/next`.
67+
* You can obtain a Nextjs Client by calling `initNextClient`
68+
*/
69+
next?: NextClientArgs['next'] | undefined;
6270
};
6371

6472
export type ApiFetcher = (args: ApiFetcherArgs) => Promise<{
@@ -81,14 +89,20 @@ export const tsRestFetchApi: ApiFetcher = async ({
8189
body,
8290
credentials,
8391
signal,
92+
cache,
93+
next
8494
}) => {
8595
const result = await fetch(path, {
8696
method,
8797
headers,
8898
body,
8999
credentials,
90100
signal,
91-
});
101+
cache,
102+
next
103+
// we must type cast here because the typescript types for RequestInit
104+
// do not include properties like "next" for Frameworks (like Nextjs)
105+
} as RequestInit);
92106
const contentType = result.headers.get('content-type');
93107

94108
if (contentType?.includes("application/") && contentType?.includes('json')) {
@@ -143,6 +157,7 @@ export const fetchApi = ({
143157
extraInputArgs,
144158
headers,
145159
signal,
160+
next
146161
}: {
147162
path: string;
148163
clientArgs: ClientArgs;
@@ -152,6 +167,12 @@ export const fetchApi = ({
152167
extraInputArgs: Record<string, unknown>;
153168
headers: Record<string, string | undefined>;
154169
signal?: AbortSignal;
170+
// ---- Framework specific ----
171+
/**
172+
* only to be used by @ts-rest/next
173+
* You can obtain a Nextjs Client by calling `initNextClient`
174+
*/
175+
next?: NextClientArgs['next'] | undefined;
155176
}) => {
156177
const apiFetcher = clientArgs.api || tsRestFetchApi;
157178

@@ -179,6 +200,7 @@ export const fetchApi = ({
179200
rawQuery: query,
180201
contentType: 'multipart/form-data',
181202
signal,
203+
next,
182204
...extraInputArgs,
183205
});
184206
}
@@ -198,6 +220,7 @@ export const fetchApi = ({
198220
rawQuery: query,
199221
contentType: route.method !== 'GET' ? 'application/json' : undefined,
200222
signal,
223+
next,
201224
...extraInputArgs,
202225
});
203226
};
@@ -220,16 +243,21 @@ export const getCompleteUrl = (
220243
return `${baseUrl}${path}${queryComponent}`;
221244
};
222245

223-
export const getRouteQuery = <TAppRoute extends AppRoute>(
246+
export const getRouteQuery = <TAppRoute extends AppRoute, Framework extends Frameworks = 'none'>(
224247
route: TAppRoute,
225-
clientArgs: InitClientArgs
248+
clientArgs: InitClientArgs,
226249
) => {
227250
const knownResponseStatuses = Object.keys(route.responses);
228251
return async (
229-
inputArgs?: ClientInferRequest<AppRouteMutation, ClientArgs>
252+
inputArgs?: Framework extends 'nextjs' ?
253+
ClientInferRequest<AppRouteMutation, ClientArgs, 'nextjs'>
254+
: ClientInferRequest<AppRouteMutation, ClientArgs>
230255
) => {
231-
const { query, params, body, headers, extraHeaders, ...extraInputArgs } =
232-
inputArgs || {};
256+
const { query, params, body, headers, extraHeaders, next, ...extraInputArgs } =
257+
// ---- Merge all framework Request infer types ----
258+
inputArgs as ClientInferRequest<AppRouteMutation, ClientArgs, 'nextjs'> &
259+
ClientInferRequest<AppRouteMutation, ClientArgs>
260+
|| {};
233261

234262
const completeUrl = getCompleteUrl(
235263
query,
@@ -246,6 +274,7 @@ export const getRouteQuery = <TAppRoute extends AppRoute>(
246274
body,
247275
query,
248276
extraInputArgs,
277+
next,
249278
headers: {
250279
...extraHeaders,
251280
...headers,
@@ -287,7 +316,7 @@ export const initClient = <
287316
return Object.fromEntries(
288317
Object.entries(router).map(([key, subRouter]) => {
289318
if (isAppRoute(subRouter)) {
290-
return [key, getRouteQuery(subRouter, args)];
319+
return [key, getRouteQuery<typeof subRouter>(subRouter, args)];
291320
} else {
292321
return [key, initClient(subRouter, args)];
293322
}

libs/ts-rest/core/src/lib/infer-types.spec.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -497,6 +497,7 @@ it('type inference helpers', () => {
497497
ClientInferRequest<typeof contract>,
498498
{
499499
getPost: {
500+
cache?: RequestCache;
500501
query: { includeComments?: boolean | undefined };
501502
params: { id: string };
502503
headers: { authorization: string; age?: number };
@@ -506,6 +507,7 @@ it('type inference helpers', () => {
506507
} & Record<string, string | undefined>;
507508
};
508509
createPost: {
510+
cache?: RequestCache;
509511
body: { title: string; content: string };
510512
headers: { authorization: string; age?: number };
511513
extraHeaders?: {
@@ -514,6 +516,7 @@ it('type inference helpers', () => {
514516
} & Record<string, string | undefined>;
515517
};
516518
uploadImage: {
519+
cache?: RequestCache;
517520
body:
518521
| {
519522
image: File;
@@ -527,6 +530,7 @@ it('type inference helpers', () => {
527530
};
528531
nested: {
529532
getComments: {
533+
cache?: RequestCache;
530534
params: { id: string };
531535
headers: {
532536
authorization: string;

libs/ts-rest/core/src/lib/infer-types.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import {
2424
import { ApiFetcher, ClientArgs } from './client';
2525
import { ParamsFromUrl } from './paths';
2626

27+
export type Frameworks = 'nextjs' | 'none';
28+
29+
export type NextClientArgs = {
30+
next?: { revalidate?: number | false, tags?: string[]} | undefined
31+
}
32+
2733
type ExtractExtraParametersFromClientArgs<
2834
TClientArgs extends Pick<ClientArgs, 'api'>
2935
> = TClientArgs['api'] extends ApiFetcher
@@ -176,6 +182,7 @@ export type ServerInferRequest<
176182
: never;
177183

178184
type ClientInferRequestBase<
185+
Framework extends Frameworks,
179186
T extends AppRoute,
180187
TClientArgs extends Omit<ClientArgs, 'baseUrl'> = {
181188
baseHeaders: {};
@@ -187,7 +194,7 @@ type ClientInferRequestBase<
187194
keyof LowercaseKeys<TClientArgs['baseHeaders']>
188195
>
189196
>
190-
: never
197+
: never,
191198
> = Prettify<
192199
Without<
193200
{
@@ -212,6 +219,10 @@ type ClientInferRequestBase<
212219
extraHeaders?: {
213220
[K in NonNullable<keyof THeaders>]?: never;
214221
} & Record<string, string | undefined>;
222+
cache?: RequestCache;
223+
next?: Framework extends 'nextjs'
224+
? NextClientArgs['next']
225+
: never;
215226
} & ExtractExtraParametersFromClientArgs<TClientArgs>,
216227
never
217228
>
@@ -221,9 +232,10 @@ export type ClientInferRequest<
221232
T extends AppRoute | AppRouter,
222233
TClientArgs extends Omit<ClientArgs, 'baseUrl'> = {
223234
baseHeaders: {};
224-
}
235+
},
236+
Framework extends Frameworks = 'none'
225237
> = T extends AppRoute
226-
? ClientInferRequestBase<T, TClientArgs>
238+
? ClientInferRequestBase<Framework, T, TClientArgs>
227239
: T extends AppRouter
228240
? { [TKey in keyof T]: ClientInferRequest<T[TKey]> }
229241
: never;
@@ -232,5 +244,6 @@ export type PartialClientInferRequest<
232244
TRoute extends AppRoute,
233245
TClientArgs extends Omit<ClientArgs, 'baseUrl'> = {
234246
baseHeaders: {};
235-
}
236-
> = OptionalIfAllOptional<ClientInferRequest<TRoute, TClientArgs>>;
247+
},
248+
Framework extends Frameworks = 'none'
249+
> = OptionalIfAllOptional<ClientInferRequest<TRoute, TClientArgs, Framework>>;

libs/ts-rest/next/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
11
export * from './lib/ts-rest-next';
2+
export * from './lib/next-client';
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { initContract } from '@ts-rest/core';
2+
import { z } from 'zod';
3+
import { initNextClient } from './next-client';
4+
import type { Equal, Expect } from './test-helpers';
5+
6+
const UserSchema = z.object({
7+
id: z.string(),
8+
name: z.string(),
9+
email: z.string(),
10+
});
11+
12+
const c = initContract();
13+
const contract = c.router({
14+
getUser: {
15+
method: 'GET',
16+
path: '/users/:id',
17+
responses: {
18+
200: UserSchema,
19+
},
20+
},
21+
});
22+
23+
describe('next-client', () => {
24+
it('Client Args should include "next" property if client is initNextClient', () => {
25+
const usersClient = initNextClient(contract, {
26+
baseHeaders: {},
27+
baseUrl: 'http://localhost:5002',
28+
});
29+
type UserClient = typeof usersClient;
30+
type Test = Parameters<UserClient['getUser']>[0]
31+
type ExpectedClientArgs = {
32+
params: {
33+
id: string;
34+
};
35+
next?: {
36+
revalidate?: number | false | undefined;
37+
tags?: string[] | undefined;
38+
} | undefined;
39+
extraHeaders?: Test['extraHeaders'];
40+
cache?: RequestCache
41+
}
42+
type NextClientTypeTest = Expect<Equal<Test, ExpectedClientArgs>>;
43+
44+
});
45+
it('Should include "next" property in the fetch request', async () => {
46+
const usersClient = initNextClient(contract, {
47+
baseHeaders: {},
48+
baseUrl: 'http://localhost:5002',
49+
});
50+
global.fetch = jest.fn(() => Promise.resolve({
51+
json: () => Promise.resolve({
52+
id: '1',
53+
name: 'John',
54+
email: 'some@email'
55+
}),
56+
headers: new Headers({
57+
'content-type': 'application/json'
58+
})
59+
} as Response));
60+
await usersClient.getUser({ params: { id: '1' }, next: { revalidate: 1, tags: ['user1'] } });
61+
expect(global.fetch).toHaveBeenCalledWith('http://localhost:5002/users/1', {
62+
body: undefined,
63+
credentials: undefined,
64+
headers: {
65+
'content-type': 'application/json'
66+
},
67+
method: 'GET',
68+
signal: undefined,
69+
next: { revalidate: 1, tags: ['user1'] }
70+
});
71+
(global.fetch as jest.Mock).mockClear();
72+
})
73+
}
74+
)

0 commit comments

Comments
 (0)