Skip to content

Commit

Permalink
the /intuita/app-router-migration squashed commit
Browse files Browse the repository at this point in the history
  • Loading branch information
DmytroHryshyn authored and grzpab committed Nov 9, 2023
1 parent 6848362 commit b802bc5
Show file tree
Hide file tree
Showing 135 changed files with 1,893 additions and 202 deletions.
45 changes: 45 additions & 0 deletions apps/web/abTest/middlewareFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { getBucket } from "abTest/utils";
import type { NextFetchEvent, NextMiddleware, NextRequest } from "next/server";
import { NextResponse } from "next/server";
import z from "zod";

const ROUTE_MAP = new Map<string, boolean>([
["/event-types", Boolean(process.env.APP_ROUTER_EVENT_TYPES_ENABLED)] as const,
]);

const FUTURE_ROUTES_OVERRIDE_COOKIE_NAME = "x-calcom-future-routes-override";
const FUTURE_ROUTES_ENABLED_COOKIE_NAME = "x-calcom-future-routes-enabled";

const bucketSchema = z.union([z.literal("legacy"), z.literal("future")]).default("legacy");

export const abTestMiddlewareFactory =
(next: NextMiddleware): NextMiddleware =>
async (req: NextRequest, event: NextFetchEvent) => {
const { pathname } = req.nextUrl;

const override = req.cookies.has(FUTURE_ROUTES_OVERRIDE_COOKIE_NAME);

const enabled = ROUTE_MAP.has(pathname) ? (ROUTE_MAP.get(pathname) ?? false) || override : false;

if (pathname.includes("future") || !enabled) {
return next(req, event);
}

const safeParsedBucket = override
? { success: true as const, data: "future" as const }
: bucketSchema.safeParse(req.cookies.get(FUTURE_ROUTES_ENABLED_COOKIE_NAME)?.value);

if (!safeParsedBucket.success) {
// cookie does not exist or it has incorrect value

const res = NextResponse.next();
res.cookies.set(FUTURE_ROUTES_ENABLED_COOKIE_NAME, getBucket(), { expires: 1000 * 60 * 30 }); // 30 min in ms
return res;
}

const bucketUrlPrefix = safeParsedBucket.data === "future" ? "future" : "";

const url = req.nextUrl.clone();
url.pathname = `${bucketUrlPrefix}${pathname}/`;
return NextResponse.rewrite(url);
};
9 changes: 9 additions & 0 deletions apps/web/abTest/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { AB_TEST_BUCKET_PROBABILITY } from "@calcom/lib/constants";

const cryptoRandom = () => {
return crypto.getRandomValues(new Uint8Array(1))[0] / 0xff;
};

export const getBucket = () => {
return cryptoRandom() * 100 < AB_TEST_BUCKET_PROBABILITY ? "future" : "legacy";
};
36 changes: 36 additions & 0 deletions apps/web/app/_not-found.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import NotFoundPage from "@pages/404";
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { cookies, headers } from "next/headers";

import { getLocale } from "@calcom/features/auth/lib/getLocale";

import PageWrapper from "@components/PageWrapperAppDir";

const getProps = async (h: ReturnType<typeof headers>, c: ReturnType<typeof cookies>) => {
// @ts-expect-error we cannot access ctx.req in app dir, however headers and cookies are only properties needed to extract the locale
const locale = await getLocale({ headers: h, cookies: c });

const i18n = (await serverSideTranslations(locale)) || "en";

return {
i18n,
};
};

const NotFound = async () => {
const h = headers();
const c = cookies();

const nonce = h.get("x-nonce") ?? undefined;

const { i18n } = await getProps(h, c);

return (
// @ts-expect-error withTrpc expects AppProps
<PageWrapper requiresLicense={false} pageProps={{ i18n }} nonce={nonce} themeBasis={null} i18n={i18n}>
<NotFoundPage />
</PageWrapper>
);
};

export default NotFound;
8 changes: 8 additions & 0 deletions apps/web/app/_trpc/HydrateClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
"use client";

import { createHydrateClient } from "app/_trpc/createHydrateClient";
import superjson from "superjson";

export const HydrateClient = createHydrateClient({
transformer: superjson,
});
5 changes: 5 additions & 0 deletions apps/web/app/_trpc/client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import type { AppRouter } from "@calcom/trpc/server/routers/_app";

import { createTRPCReact } from "@trpc/react-query";

export const trpc = createTRPCReact<AppRouter>({});
21 changes: 21 additions & 0 deletions apps/web/app/_trpc/createHydrateClient.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
"use client";

import { type DehydratedState, Hydrate } from "@tanstack/react-query";
import { useMemo } from "react";

import type { DataTransformer } from "@trpc/server";

export function createHydrateClient(opts: { transformer?: DataTransformer }) {
return function HydrateClient(props: { children: React.ReactNode; state: DehydratedState }) {
const { state, children } = props;

const transformedState: DehydratedState = useMemo(() => {
if (opts.transformer) {
return opts.transformer.deserialize(state);
}
return state;
}, [state]);

return <Hydrate state={transformedState}>{children}</Hydrate>;
};
}
212 changes: 212 additions & 0 deletions apps/web/app/_trpc/createTRPCNextLayout.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
// originally from in the "experimental playground for tRPC + next.js 13" repo owned by trpc team
// file link: https://github.com/trpc/next-13/blob/main/%40trpc/next-layout/createTRPCNextLayout.ts
// repo link: https://github.com/trpc/next-13
// code is / will continue to be adapted for our usage
import { dehydrate, QueryClient } from "@tanstack/query-core";
import type { DehydratedState, QueryKey } from "@tanstack/react-query";

import type { Maybe, TRPCClientError, TRPCClientErrorLike } from "@calcom/trpc";
import {
callProcedure,
type AnyProcedure,
type AnyQueryProcedure,
type AnyRouter,
type DataTransformer,
type inferProcedureInput,
type inferProcedureOutput,
type inferRouterContext,
type MaybePromise,
type ProcedureRouterRecord,
} from "@calcom/trpc/server";

import { createRecursiveProxy, createFlatProxy } from "@trpc/server/shared";

export function getArrayQueryKey(
queryKey: string | [string] | [string, ...unknown[]] | unknown[],
type: string
): QueryKey {
const queryKeyArrayed = Array.isArray(queryKey) ? queryKey : [queryKey];
const [arrayPath, input] = queryKeyArrayed;

if (!input && (!type || type === "any")) {
return arrayPath.length ? [arrayPath] : ([] as unknown as QueryKey);
}

return [
arrayPath,
{
...(typeof input !== "undefined" && { input: input }),
...(type && type !== "any" && { type: type }),
},
];
}

// copy starts
// copied from trpc/trpc repo
// ref: https://github.com/trpc/trpc/blob/main/packages/next/src/withTRPC.tsx#L37-#L58
function transformQueryOrMutationCacheErrors<
TState extends DehydratedState["queries"][0] | DehydratedState["mutations"][0]
>(result: TState): TState {
const error = result.state.error as Maybe<TRPCClientError<any>>;
if (error instanceof Error && error.name === "TRPCClientError") {
const newError: TRPCClientErrorLike<any> = {
message: error.message,
data: error.data,
shape: error.shape,
};
return {
...result,
state: {
...result.state,
error: newError,
},
};
}
return result;
}
// copy ends

interface CreateTRPCNextLayoutOptions<TRouter extends AnyRouter> {
router: TRouter;
createContext: () => MaybePromise<inferRouterContext<TRouter>>;
transformer?: DataTransformer;
}

/**
* @internal
*/
export type DecorateProcedure<TProcedure extends AnyProcedure> = TProcedure extends AnyQueryProcedure
? {
fetch(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
fetchInfinite(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
prefetch(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
prefetchInfinite(input: inferProcedureInput<TProcedure>): Promise<inferProcedureOutput<TProcedure>>;
}
: never;

type OmitNever<TType> = Pick<
TType,
{
[K in keyof TType]: TType[K] extends never ? never : K;
}[keyof TType]
>;
/**
* @internal
*/
export type DecoratedProcedureRecord<
TProcedures extends ProcedureRouterRecord,
TPath extends string = ""
> = OmitNever<{
[TKey in keyof TProcedures]: TProcedures[TKey] extends AnyRouter
? DecoratedProcedureRecord<TProcedures[TKey]["_def"]["record"], `${TPath}${TKey & string}.`>
: TProcedures[TKey] extends AnyQueryProcedure
? DecorateProcedure<TProcedures[TKey]>
: never;
}>;

type CreateTRPCNextLayout<TRouter extends AnyRouter> = DecoratedProcedureRecord<TRouter["_def"]["record"]> & {
dehydrate(): Promise<DehydratedState>;
queryClient: QueryClient;
};

const getStateContainer = <TRouter extends AnyRouter>(opts: CreateTRPCNextLayoutOptions<TRouter>) => {
let _trpc: {
queryClient: QueryClient;
context: inferRouterContext<TRouter>;
} | null = null;

return () => {
if (_trpc === null) {
_trpc = {
context: opts.createContext(),
queryClient: new QueryClient(),
};
}

return _trpc;
};
};

export function createTRPCNextLayout<TRouter extends AnyRouter>(
opts: CreateTRPCNextLayoutOptions<TRouter>
): CreateTRPCNextLayout<TRouter> {
const getState = getStateContainer(opts);

const transformer = opts.transformer ?? {
serialize: (v) => v,
deserialize: (v) => v,
};

return createFlatProxy((key) => {
const state = getState();
const { queryClient } = state;
if (key === "queryClient") {
return queryClient;
}

if (key === "dehydrate") {
// copy starts
// copied from trpc/trpc repo
// ref: https://github.com/trpc/trpc/blob/main/packages/next/src/withTRPC.tsx#L214-#L229
const dehydratedCache = dehydrate(queryClient, {
shouldDehydrateQuery() {
// makes sure errors are also dehydrated
return true;
},
});

// since error instances can't be serialized, let's make them into `TRPCClientErrorLike`-objects
const dehydratedCacheWithErrors = {
...dehydratedCache,
queries: dehydratedCache.queries.map(transformQueryOrMutationCacheErrors),
mutations: dehydratedCache.mutations.map(transformQueryOrMutationCacheErrors),
};

return () => transformer.serialize(dehydratedCacheWithErrors);
}
// copy ends

return createRecursiveProxy(async (callOpts) => {
const path = [key, ...callOpts.path];
const utilName = path.pop();
const ctx = await state.context;

const caller = opts.router.createCaller(ctx);

const pathStr = path.join(".");
const input = callOpts.args[0];

if (utilName === "fetchInfinite") {
return queryClient.fetchInfiniteQuery(getArrayQueryKey([path, input], "infinite"), () =>
caller.query(pathStr, input)
);
}

if (utilName === "prefetch") {
return queryClient.prefetchQuery({
queryKey: getArrayQueryKey([path, input], "query"),
queryFn: async () => {
const res = await callProcedure({
procedures: opts.router._def.procedures,
path: pathStr,
rawInput: input,
ctx,
type: "query",
});
return res;
},
});
}

if (utilName === "prefetchInfinite") {
return queryClient.prefetchInfiniteQuery(getArrayQueryKey([path, input], "infinite"), () =>
caller.query(pathStr, input)
);
}

return queryClient.fetchQuery(getArrayQueryKey([path, input], "query"), () =>
caller.query(pathStr, input)
);
}) as CreateTRPCNextLayout<TRouter>;
});
}
3 changes: 3 additions & 0 deletions apps/web/app/_trpc/serverClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { appRouter } from "@calcom/trpc/server/routers/_app";

export const serverClient = appRouter.createCaller({});
34 changes: 34 additions & 0 deletions apps/web/app/_trpc/ssgInit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { serverSideTranslations } from "next-i18next/serverSideTranslations";
import { headers } from "next/headers";
import superjson from "superjson";

import { CALCOM_VERSION } from "@calcom/lib/constants";
import prisma from "@calcom/prisma";
import { appRouter } from "@calcom/trpc/server/routers/_app";

import { createTRPCNextLayout } from "./createTRPCNextLayout";

export async function ssgInit() {
const locale = headers().get("x-locale") ?? "en";

const i18n = (await serverSideTranslations(locale, ["common"])) || "en";

const ssg = createTRPCNextLayout({
router: appRouter,
transformer: superjson,
createContext() {
return { prisma, session: null, locale, i18n };
},
});

// i18n translations are already retrieved from serverSideTranslations call, there is no need to run a i18n.fetch
// we can set query data directly to the queryClient
const queryKey = [
["viewer", "public", "i18n"],
{ input: { locale, CalComVersion: CALCOM_VERSION }, type: "query" },
];

ssg.queryClient.setQueryData(queryKey, { i18n });

return ssg;
}
Loading

0 comments on commit b802bc5

Please sign in to comment.