Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(next/app-dir): contextCache to control context values to add to cacheTag #5537

Open
wants to merge 48 commits into
base: next
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
48 commits
Select commit Hold shift + click to select a range
6f32a91
feat(next/app-dir): `cacheTag` generation to consider context
dalechyn Feb 7, 2024
7ee54e8
nit: lint
dalechyn Feb 7, 2024
6cfe7f4
Merge remote-tracking branch 'upstream/next' into next
dalechyn Feb 7, 2024
cf3c99f
refactor(next/app-dir): `@noble/hashes` -> `crypto` for input hashing
dalechyn Feb 7, 2024
6989a2e
refactor(next/app-dir): use `crypto.subtle` instead
dalechyn Feb 8, 2024
b29441a
feat(next/app-dir): drop http link, add `createContext` param to serv…
dalechyn Feb 8, 2024
655888e
nit: lint
dalechyn Feb 8, 2024
dfb02d1
Merge remote-tracking branch 'upstream/next' into next
dalechyn Feb 8, 2024
a8e88f1
chore: apply lint and formatting fixes
autofix-ci[bot] Feb 8, 2024
336f480
feat: hoist createContext one level up
dalechyn Feb 11, 2024
d133445
Merge remote-tracking branch 'upstream/next' into next
dalechyn Feb 11, 2024
8e35dda
chore: apply lint and formatting fixes
autofix-ci[bot] Feb 11, 2024
726931d
refactor: don't change `AnyClientTypes`
dalechyn Mar 1, 2024
2f5fdff
Merge remote-tracking branch 'upstream/next' into next
dalechyn Mar 1, 2024
2f1baad
hoist
juliusmarminge Mar 4, 2024
f305da3
feat: delete `server-http`
dalechyn Mar 4, 2024
129d8f4
test: add regression test for `cacheContext`
dalechyn Mar 4, 2024
75af515
chore: apply lint and formatting fixes
autofix-ci[bot] Mar 4, 2024
0866a5a
fix(server): use the recommended connection handler from @fastify/web…
mat-sz Mar 2, 2024
e66cd06
fix(client / server): chained `.pipe()`s in observables (#5533)
KATT Mar 3, 2024
c054046
chore: use vitest workspaces (#5461)
juliusmarminge Mar 4, 2024
60976e9
Merge remote-tracking branch 'upstream/next' into cache-context
dalechyn Mar 4, 2024
1dfd889
feat: bring the http back
dalechyn Mar 4, 2024
2d4e0af
chore: apply lint and formatting fixes
autofix-ci[bot] Mar 4, 2024
7348eec
fix: nextHttp tests
dalechyn Mar 4, 2024
b80e148
revert unrelated
juliusmarminge Mar 4, 2024
174cd91
rev more
juliusmarminge Mar 4, 2024
64faf05
nice
juliusmarminge Mar 4, 2024
aaa7983
chore: apply lint and formatting fixes
autofix-ci[bot] Mar 4, 2024
66ae725
revert some stuff
juliusmarminge Mar 4, 2024
f568388
revert more unused
juliusmarminge Mar 4, 2024
df05c4e
wtf autolinter
juliusmarminge Mar 4, 2024
6662379
chore: apply lint and formatting fixes
autofix-ci[bot] Mar 4, 2024
0b24086
Merge branch 'next' into cache-context
juliusmarminge Mar 4, 2024
4e0b11a
revv lock
juliusmarminge Mar 4, 2024
68b265b
fuck this autolinter
juliusmarminge Mar 4, 2024
3edaaed
cache context
juliusmarminge Mar 4, 2024
c89d7c2
niceee
juliusmarminge Mar 4, 2024
342537a
fix
juliusmarminge Mar 4, 2024
bf86986
feat: add proxy callback opts to `contextSelector`
dalechyn Mar 12, 2024
a01dfcd
Merge remote-tracking branch 'upstream/next' into cache-context
dalechyn Mar 12, 2024
68429bb
chore: apply lint and formatting fixes
autofix-ci[bot] Mar 12, 2024
e38a826
feat: requested changes
dalechyn Mar 28, 2024
a51b953
Merge remote-tracking branch 'upstream/next' into cache-context
dalechyn Mar 28, 2024
e0dda23
nit: comment
dalechyn Apr 18, 2024
2ab36b7
nit: revert lockfile changes
dalechyn Apr 22, 2024
525f326
nit: initialize client lazily
dalechyn Apr 22, 2024
531717e
Merge branch 'next' into cache-context
dalechyn Apr 22, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
Expand Up @@ -3,12 +3,20 @@ import { api } from '~/trpc/server-http';
export const ServerHttpGreeting = async () => {
const greeting1 = await api.greeting.query({ text: 'from server1' });
const greeting2 = await api.greeting.query({ text: 'from server2' });
const privateGreeting1 = await api.privateGreeting.query({
text: 'from server1 private',
});
const privateGreeting2 = await api.privateGreeting.query({
text: 'from server1 private',
});
const secret = await api.secret.query();

return (
<div>
<p>{greeting1}</p>
<p>{greeting2}</p>
<p>{privateGreeting1}</p>
<p>{privateGreeting2}</p>
<p>{secret}</p>
<form
action={async () => {
Expand All @@ -26,6 +34,26 @@ export const ServerHttpGreeting = async () => {
>
<button type="submit">Revalidate HTTP 2</button>
</form>
<form
action={async () => {
'use server';
await api.privateGreeting.revalidate({
text: 'from server1 private',
});
}}
>
<button type="submit">Revalidate HTTP 3</button>
</form>
<form
action={async () => {
'use server';
await api.privateGreeting.revalidate({
text: 'from server2 private',
});
}}
>
<button type="submit">Revalidate HTTP 4</button>
</form>
</div>
);
};
Expand Up @@ -11,13 +11,21 @@ export async function ServerInvokedGreeting() {
const greeting2 = await api.greeting.query({
text: 'i also never hit an endpoint',
});
const privateGreeting1 = await api.privateGreeting.query({
text: 'i never hit a private api endpoint',
});
const privateGreeting2 = await api.privateGreeting.query({
text: 'i also never hit a private endpoint',
});

const secret = await api.secret.query();

return (
<div>
<p>{greeting1}</p>
<p>{greeting2}</p>
<p>{privateGreeting1}</p>
<p>{privateGreeting2}</p>
<p>{secret}</p>
<form
action={async () => {
Expand All @@ -39,6 +47,26 @@ export async function ServerInvokedGreeting() {
>
<button type="submit">Revalidate Cache 2</button>
</form>
<form
action={async () => {
'use server';
await api.privateGreeting.revalidate({
text: 'i never hit an api endpoint',
});
}}
>
<button type="submit">Revalidate Cache 3</button>
</form>
<form
action={async () => {
'use server';
await api.privateGreeting.revalidate({
text: 'i also never hit an endpoint',
});
}}
>
<button type="submit">Revalidate Cache 4</button>
</form>
</div>
);
}
4 changes: 2 additions & 2 deletions examples/.experimental/next-app-dir/src/app/rsc/page.tsx
Expand Up @@ -18,15 +18,15 @@ export default async function Home() {

<div style={separator} />

<div style={{ height: 160 }}>
<div style={{ height: 260 }}>
<Suspense fallback={<>Loading Server...</>}>
<ServerHttpGreeting />
</Suspense>
</div>

<div style={separator} />

<div style={{ height: 160 }}>
<div style={{ height: 260 }}>
<Suspense fallback={<>Loading Server...</>}>
<ServerInvokedGreeting />
</Suspense>
Expand Down
3 changes: 3 additions & 0 deletions examples/.experimental/next-app-dir/src/server/context.ts
@@ -1,12 +1,15 @@
import type { FetchCreateContextFnOptions } from '@trpc/server/adapters/fetch';
import { auth } from '~/auth';
import { headers } from 'next/headers';

export async function createContext(opts?: FetchCreateContextFnOptions) {
const session = await auth();

return {
session,
headers: opts && Object.fromEntries(opts.req.headers),
// Mock user id which is used for testing. If you copy this file, delete the next property.
_userIdMock: headers().get('x-trpc-user-id'),
};
}

Expand Down
13 changes: 13 additions & 0 deletions examples/.experimental/next-app-dir/src/server/routers/_app.ts
Expand Up @@ -26,6 +26,19 @@ export const createPost = publicProcedure
});

export const appRouter = router({
privateGreeting: publicProcedure
.input(
z.object({
text: z.string(),
}),
)
.query(async (opts) => {
console.log('request from', opts.ctx.headers?.['x-trpc-source']);
return `hello ${opts.input.text} - ${Math.random()} from ${
opts.ctx._userIdMock
}`;
}),

greeting: publicProcedure
.input(
z.object({
Expand Down
1 change: 1 addition & 0 deletions examples/.experimental/next-app-dir/src/server/trpc.ts
Expand Up @@ -44,6 +44,7 @@ export const createAction = experimental_createServerActionHandler(t, {

return {
session,
_userIdMock: headers().get('x-trpc-user-id'),
headers: {
// Pass the cookie header to the API
cookies: headers().get('cookie') ?? '',
Expand Down
6 changes: 2 additions & 4 deletions examples/.experimental/next-app-dir/src/trpc/client.ts
@@ -1,12 +1,11 @@
'use client';

import { loggerLink } from '@trpc/client';
import { httpBatchLink, loggerLink } from '@trpc/client';
import {
experimental_createActionHook,
experimental_createTRPCNextAppDirClient,
experimental_serverActionLink,
} from '@trpc/next/app-dir/client';
import { experimental_nextHttpLink } from '@trpc/next/app-dir/links/nextHttp';
import type { AppRouter } from '~/server/routers/_app';
import superjson from 'superjson';
import { getUrl } from './shared';
Expand All @@ -18,9 +17,8 @@ export const api = experimental_createTRPCNextAppDirClient<AppRouter>({
loggerLink({
enabled: (op) => true,
}),
experimental_nextHttpLink({
httpBatchLink({
transformer: superjson,
batch: true,
url: getUrl(),
headers() {
return {
Expand Down
10 changes: 7 additions & 3 deletions examples/.experimental/next-app-dir/src/trpc/server-http.ts
Expand Up @@ -2,25 +2,29 @@ import { loggerLink } from '@trpc/client';
import { experimental_nextHttpLink } from '@trpc/next/app-dir/links/nextHttp';
import { experimental_createTRPCNextAppDirServer } from '@trpc/next/app-dir/server';
import type { AppRouter } from '~/server/routers/_app';
import { cookies } from 'next/headers';
import { cookies, headers } from 'next/headers';
import superjson from 'superjson';
import { getUrl } from './shared';
import { createContext } from './shared-server';

export const api = experimental_createTRPCNextAppDirServer<AppRouter>({
config() {
return {
createContext: () => createContext('http'),
contextSelector: (ctx) => [ctx.session?.user.id, ctx._userIdMock],
links: [
loggerLink({
enabled: (op) => true,
enabled: (_op) => true,
}),
experimental_nextHttpLink({
batch: true,
url: getUrl(),
transformer: superjson,
headers() {
headers: () => {
return {
cookie: cookies().toString(),
'x-trpc-source': 'rsc-http',
'x-trpc-user-id': headers().get('x-trpc-user-id') ?? undefined,
};
},
}),
Expand Down
29 changes: 17 additions & 12 deletions examples/.experimental/next-app-dir/src/trpc/server-invoker.ts
@@ -1,35 +1,40 @@
import { loggerLink } from '@trpc/client';
import { experimental_nextCacheLink } from '@trpc/next/app-dir/links/nextCache';
import { experimental_createTRPCNextAppDirServer } from '@trpc/next/app-dir/server';
import { auth } from '~/auth';
import { appRouter } from '~/server/routers/_app';
import { cookies } from 'next/headers';
import superjson from 'superjson';
import { createContext } from './shared-server';

/**
* This client invokes procedures directly on the server without fetching over HTTP.
*/
export const api = experimental_createTRPCNextAppDirServer<typeof appRouter>({
config() {
return {
createContext: () => createContext('invoke'),
/**
* Using the `contextSelector` property we can select the specific values to be included
* in the `cacheTag` generation.
*
* In the following example, if the requests' path doesn't have `privateGreeting` in it
* (which is a private route that uses some values from the context), we return an empty array
* meaning that we don't use any values from the context in other routes.
* On the other side if it has `privateGreeting`, we specify the values from the context that we use
* in the `privateGreeting` procedure.
*/
contextSelector: (ctx, callOpts) => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this example should show a "private by default" and opt-in to sharing cache for specific procedures thst doesnt have sensitive data

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good thoughts, will implement

if (!['privateGreeting'].includes(callOpts.path[0])) return [];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

a comment would be nice - i don't know if even i would know what this does if i revisit this example in a month or 2 ;)

return [ctx.session?.user.id, ctx._userIdMock];
},
links: [
loggerLink({
enabled: (op) => true,
enabled: (_op) => true,
}),
experimental_nextCacheLink({
// requests are cached for 5 seconds
revalidate: 5,
router: appRouter,
transformer: superjson,
createContext: async () => {
return {
session: await auth(),
headers: {
cookie: cookies().toString(),
'x-trpc-source': 'rsc-invoke',
},
};
},
}),
],
};
Expand Down
14 changes: 14 additions & 0 deletions examples/.experimental/next-app-dir/src/trpc/shared-server.ts
@@ -0,0 +1,14 @@
import { auth } from '~/auth';
import { cookies, headers } from 'next/headers';

export async function createContext(source: 'invoke' | 'http') {
return {
session: await auth(),
// Mock user id which is used for testing. If you copy this file, delete the next property.
_userIdMock: headers().get('x-trpc-user-id'),
headers: {
cookie: cookies().toString(),
'x-trpc-source': `rsc-${source}`,
},
};
}
27 changes: 27 additions & 0 deletions examples/.experimental/next-app-dir/test/server-cache.test.ts
Expand Up @@ -39,3 +39,30 @@ test('server-cacheLink: revalidating should load new content', async ({
);
expect(nonce1).not.toBe(nonce2);
});

test('server-cacheLink: different contexts should not have a common cache', async ({
page,
}) => {
// mocking session data
await page.setExtraHTTPHeaders({ 'x-trpc-user-id': 'foo' });
await page.goto('/rsc');
await page.reload();

await page.waitForSelector('text=hello i never hit a private api endpoint');
const nonce1 = await page.textContent(
'text=hello i never hit a private api endpoint',
);
await page.reload();
const nonce2 = await page.textContent(
'text=hello i never hit a private api endpoint',
);
expect(nonce1).toBe(nonce2);

// Mock new user
await page.setExtraHTTPHeaders({ 'x-trpc-user-id': 'bar' });
await page.reload();
const nonce3 = await page.textContent(
'text=hello i never hit a private api endpoint',
);
expect(nonce1).not.toBe(nonce3);
});
23 changes: 23 additions & 0 deletions examples/.experimental/next-app-dir/test/server-http.test.ts
Expand Up @@ -50,3 +50,26 @@ test('server-httpLink: requests are properly separated in the cache', async ({
await page.waitForSelector('text=hello from server1');
await page.waitForSelector('text=hello from server2');
});

test('server-httpLink: different contexts should not have a common cache', async ({
page,
}) => {
// mocking session data
await page.setExtraHTTPHeaders({ 'x-trpc-user-id': 'foo' });
await page.goto('/rsc');
await page.reload();

await page.waitForSelector('text=hello from server1 private');
const nonce1 = await page.textContent('text=hello from server1 private');

await page.reload();
const nonce2 = await page.textContent('text=hello from server1 private');
expect(nonce1).toBe(nonce2);

// Mock new user
await page.setExtraHTTPHeaders({ 'x-trpc-user-id': 'bar' });
await page.reload();

const nonce3 = await page.textContent('text=hello from server1 private');
expect(nonce1).not.toBe(nonce3);
});
8 changes: 7 additions & 1 deletion examples/.experimental/next-app-dir/tsconfig.json
Expand Up @@ -23,6 +23,12 @@
"~/*": ["./src/*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
"test/.server-cache.test.ts"
],
"exclude": ["node_modules"]
}
3 changes: 1 addition & 2 deletions packages/client/src/internals/getAbortController.ts
Expand Up @@ -8,11 +8,10 @@ export function getAbortController(
return customAbortControllerImpl;
}

// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (typeof window !== 'undefined' && window.AbortController) {
return window.AbortController;
}
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

???

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pnpm lint-fix did that, i undoed the changes and a workflow applied formatting once again 😵‍💫


if (typeof globalThis !== 'undefined' && globalThis.AbortController) {
return globalThis.AbortController;
}
Expand Down
3 changes: 1 addition & 2 deletions packages/client/src/links/internals/getTextDecoder.ts
Expand Up @@ -7,11 +7,10 @@ export function getTextDecoder(
return customTextDecoder;
}

// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
if (typeof window !== 'undefined' && window.TextDecoder) {
return new window.TextDecoder();
}
// eslint-disable-next-line @typescript-eslint/prefer-optional-chain
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

???

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.


if (typeof globalThis !== 'undefined' && globalThis.TextDecoder) {
return new globalThis.TextDecoder();
}
Expand Down
1 change: 0 additions & 1 deletion packages/next/package.json
Expand Up @@ -71,7 +71,6 @@
"ssrPrepass",
"!**/*.test.*"
],
"dependencies": {},
"peerDependencies": {
"@tanstack/react-query": "^5.25.0",
"@trpc/client": "10.45.1",
Expand Down