From d5cfd3e82872513c49298def64968f470cc1a888 Mon Sep 17 00:00:00 2001 From: Olaf Rosendahl Date: Fri, 26 Apr 2024 12:44:53 +0200 Subject: [PATCH] feat: single fetch (#66) --- web/app/components/Navbar.tsx | 12 +++++----- .../components/statistics/loader.server.ts | 4 ++-- web/app/root.tsx | 9 +++++--- web/app/routes/_authed.dashboard._index.tsx | 3 +-- web/app/routes/_authed.dashboard.new-team.tsx | 13 ++++++----- .../_authed.dashboard_.$teamSlug._index.tsx | 3 +-- .../_authed.dashboard_.$teamSlug.members.tsx | 15 +++++++------ ...uthed.dashboard_.$teamSlug.new-project.tsx | 16 ++++++++------ .../_authed.dashboard_.$teamSlug.settings.tsx | 16 ++++++++------ .../routes/_authed.dashboard_.$teamSlug.tsx | 7 +++--- .../_authed.dashboard_.$teamSlug.usage.tsx | 3 +-- ...hboard_.$teamSlug_.$projectSlug._index.tsx | 3 +-- ...oard_.$teamSlug_.$projectSlug.settings.tsx | 22 ++++++++++--------- ...hed.dashboard_.$teamSlug_.$projectSlug.tsx | 8 +++---- web/app/routes/_authed.profile.tsx | 3 +-- web/app/routes/_authed.tsx | 3 +-- web/app/routes/_index.tsx | 7 +++--- ...-internal.$teamSlug.$projectSlug.event.tsx | 7 +++--- ...l.$teamSlug.$projectSlug.pageview-next.tsx | 5 ++--- .../api.$teamSlug.$projectSlug.event.tsx | 5 ++--- .../api.$teamSlug.$projectSlug.pageview.tsx | 5 ++--- web/app/routes/auth.github._index.tsx | 6 ++--- .../public.$teamSlug.$projectSlug._index.tsx | 2 +- .../routes/public.$teamSlug.$projectSlug.tsx | 11 ++++------ web/app/routes/stream_.tsx | 3 +-- web/app/utils.server.ts | 18 +++++++++++++++ web/app/utils.ts | 8 ------- web/package.json | 4 +++- web/tsconfig.json | 2 +- web/vite.config.ts | 8 ++++++- 30 files changed, 122 insertions(+), 109 deletions(-) create mode 100644 web/app/utils.server.ts delete mode 100644 web/app/utils.ts diff --git a/web/app/components/Navbar.tsx b/web/app/components/Navbar.tsx index fa4b9cd..4c031e4 100644 --- a/web/app/components/Navbar.tsx +++ b/web/app/components/Navbar.tsx @@ -21,13 +21,11 @@ import { Avatar, AvatarFallback, AvatarImage } from './ui/avatar'; export type NavbarProps = { user: Pick; - teams: SerializeFrom< - Prisma.TeamGetPayload<{ - include: { - projects: true; - }; - }>[] - >; + teams: Prisma.TeamGetPayload<{ + include: { + projects: true; + }; + }>[]; }; export const Navbar = ({ user, teams }: NavbarProps) => { diff --git a/web/app/components/statistics/loader.server.ts b/web/app/components/statistics/loader.server.ts index 9914340..4a30737 100644 --- a/web/app/components/statistics/loader.server.ts +++ b/web/app/components/statistics/loader.server.ts @@ -5,13 +5,13 @@ import { format, setDay, subMinutes } from 'date-fns'; import { CURRENT_VISITORS_LAST_MINUTES, formatFilterDate, getFilteringParams, mapTrendDataToTrend } from './utils'; export type LoadStatistics = Awaited>; -export type LoadStatisticsSerialized = SerializeFrom>>; +export type LoadStatisticsSerialized = Awaited>; export type Trend = { x: Date; y: number; }; -export type TrendSerialized = SerializeFrom; +export type TrendSerialized = Trend; export type TopData = { name: string | null; diff --git a/web/app/root.tsx b/web/app/root.tsx index 928bccc..c33c571 100644 --- a/web/app/root.tsx +++ b/web/app/root.tsx @@ -2,7 +2,7 @@ import '@fontsource-variable/inter/index.css'; import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLocation, useParams } from '@remix-run/react'; import type { LinksFunction } from '@remix-run/node'; import { ErrorBoundary as BaseErrorBoundary } from '~/components/ErrorBoundary'; -import { useEffect } from 'react'; +import { useEffect, useMemo } from 'react'; import { stats } from './stats'; @@ -19,11 +19,14 @@ export type LayoutProps = { export const Layout = ({ children }: LayoutProps) => { const location = useLocation(); const params = useParams>(); + const pathname = useMemo( + () => Object.entries(params as Record).reduce((path, [key, param]) => path.replace(`/${param}`, `/:${key}`), location.pathname), + [location.pathname, params], + ); useEffect(() => { - const pathname = Object.entries(params as Record).reduce((path, [key, param]) => path.replace(`/${param}`, `/:${key}`), location.pathname); stats.pageview({ pathname }); - }, [location.pathname, location.search, params]); + }, [pathname]); return ( diff --git a/web/app/routes/_authed.dashboard._index.tsx b/web/app/routes/_authed.dashboard._index.tsx index d520cf1..b4fcfef 100644 --- a/web/app/routes/_authed.dashboard._index.tsx +++ b/web/app/routes/_authed.dashboard._index.tsx @@ -1,6 +1,5 @@ import { Link, NavLink, useLoaderData } from '@remix-run/react'; import type { LoaderFunctionArgs } from '@vercel/remix'; -import { json } from '@vercel/remix'; import { getUserOrRedirect } from '~/auth.server'; import { Typography } from '~/components/typography'; import { Button } from '~/components/ui/button'; @@ -20,7 +19,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }, }, }); - return json({ teams }); + return { teams }; }; export default function Dashboard() { diff --git a/web/app/routes/_authed.dashboard.new-team.tsx b/web/app/routes/_authed.dashboard.new-team.tsx index 4c80696..0448c4e 100644 --- a/web/app/routes/_authed.dashboard.new-team.tsx +++ b/web/app/routes/_authed.dashboard.new-team.tsx @@ -1,7 +1,6 @@ import { Prisma } from '@prisma/client'; import { Form, Link, useActionData, useNavigation } from '@remix-run/react'; import type { ActionFunctionArgs } from '@vercel/remix'; -import { json, redirect } from '@vercel/remix'; import { getUserOrRedirect } from '~/auth.server'; import { Typography } from '~/components/typography'; import { Button } from '~/components/ui/button'; @@ -9,11 +8,11 @@ import { Card } from '~/components/ui/card'; import { Input } from '~/components/ui/input'; import { Label } from '~/components/ui/label'; import { prismaClient } from '~/prismaClient'; -import { slugify } from '~/utils'; +import { redirect, slugify } from '~/utils.server'; export { ErrorBoundary } from '~/components/ErrorBoundary'; -export const action = async ({ request }: ActionFunctionArgs) => { +export const action = async ({ request, response }: ActionFunctionArgs) => { const user = await getUserOrRedirect(request); const formData = await request.formData(); const name = formData.get('name') as string; @@ -29,14 +28,16 @@ export const action = async ({ request }: ActionFunctionArgs) => { }, }, }); - return redirect(`/dashboard/${team.slug}`); + return redirect(response, `/dashboard/${team.slug}`); } catch (e) { console.error('[New Team]', e); if (e instanceof Prisma.PrismaClientKnownRequestError) { - return json({ errors: { name: 'This team name is already taken' } }, { status: 400 }); + response!.status = 400; + return { errors: { name: 'This team name is already taken' } }; } } - return json({ errors: { name: 'Something went wrong' } }, { status: 400 }); + response!.status = 400; + return { errors: { name: 'Something went wrong' } }; }; export default function CreateTeam() { diff --git a/web/app/routes/_authed.dashboard_.$teamSlug._index.tsx b/web/app/routes/_authed.dashboard_.$teamSlug._index.tsx index 5a838d8..4473506 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug._index.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug._index.tsx @@ -1,6 +1,5 @@ import { NavLink, useLoaderData } from '@remix-run/react'; import type { LoaderFunctionArgs } from '@vercel/remix'; -import { json } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import { prismaClient } from '~/prismaClient'; import invariant from 'tiny-invariant'; @@ -17,7 +16,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, }); - return json({ projects: await projects }); + return { projects: await projects }; }; export default function TeamDashboard() { diff --git a/web/app/routes/_authed.dashboard_.$teamSlug.members.tsx b/web/app/routes/_authed.dashboard_.$teamSlug.members.tsx index 144a662..af70c36 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug.members.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug.members.tsx @@ -1,6 +1,5 @@ import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from '@vercel/remix'; -import { json } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import { prismaClient } from '~/prismaClient'; import invariant from 'tiny-invariant'; @@ -27,10 +26,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }, }); - return json({ members: await members }); + return { members: await members }; }; -export const action = async ({ request, params }: ActionFunctionArgs) => { +export const action = async ({ request, params, response }: ActionFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); const team = await ensureIsTeamMember(request, params.teamSlug); @@ -49,7 +48,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }); if (!user) { - return json({ errors: { github_username: 'No users exists with this GitHub username' } }, { status: 400 }); + response!.status = 400; + return { errors: { github_username: 'No users exists with this GitHub username' } }; } await prismaClient.teamUser.create({ @@ -59,7 +59,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - return json({ detail: 'Member successfully added to the team' }); + return { detail: 'Member successfully added to the team' }; } if (request.method === 'DELETE') { const userId = formData.get('userId') as string; @@ -74,9 +74,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); - return json({ detail: 'Member successfully removed from the team' }); + return { detail: 'Member successfully removed from the team' }; } catch { - return json({ errors: { userId: 'Something went wrong' } }, { status: 400 }); + response!.status = 400; + return { errors: { userId: 'Something went wrong' } }; } } }; diff --git a/web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx b/web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx index 1e5e4cb..76b3069 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug.new-project.tsx @@ -1,10 +1,9 @@ import { Prisma } from '@prisma/client'; import { Form, Link, useActionData, useNavigation, useParams } from '@remix-run/react'; import type { ActionFunctionArgs } from '@vercel/remix'; -import { json, redirect } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import { prismaClient } from '~/prismaClient'; -import { slugify } from '~/utils'; +import { redirect, slugify } from '~/utils.server'; import invariant from 'tiny-invariant'; import { Card } from '~/components/ui/card'; import { Typography } from '~/components/typography'; @@ -14,7 +13,7 @@ import { Label } from '~/components/ui/label'; export { ErrorBoundary } from '~/components/ErrorBoundary'; -export const action = async ({ request, params }: ActionFunctionArgs) => { +export const action = async ({ request, params, response }: ActionFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); const team = await ensureIsTeamMember(request, params.teamSlug); const formData = await request.formData(); @@ -23,7 +22,8 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { const slug = slugify(name); if (slug === 'settings' || slug === 'members') { - return json({ errors: { name: `"settings" and "members" are protected project names, chose a different name and try again` } }, { status: 400 }); + response!.status = 400; + return { errors: { name: `"settings" and "members" are protected project names, chose a different name and try again` } }; } try { @@ -36,13 +36,15 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { allowed_hosts: '', }, }); - return redirect(`/dashboard/${team.slug}/${project.slug}`); + return redirect(response, `/dashboard/${team.slug}/${project.slug}`); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - return json({ errors: { name: `This team already contains a project named "${name}"` } }, { status: 400 }); + response!.status = 400; + return { errors: { name: `This team already contains a project named "${name}"` } }; } } - return json({ errors: { name: 'Something went wrong' } }, { status: 400 }); + response!.status = 400; + return { errors: { name: 'Something went wrong' } }; }; export default function CreateProject() { diff --git a/web/app/routes/_authed.dashboard_.$teamSlug.settings.tsx b/web/app/routes/_authed.dashboard_.$teamSlug.settings.tsx index 104cecf..c466c85 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug.settings.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug.settings.tsx @@ -1,11 +1,11 @@ import { Prisma } from '@prisma/client'; import { Form, useActionData, useLoaderData, useNavigation } from '@remix-run/react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from '@vercel/remix'; -import { json, redirect } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import { prismaClient } from '~/prismaClient'; import { useState } from 'react'; import invariant from 'tiny-invariant'; +import { redirect } from '~/utils.server'; import { Card } from '~/components/ui/card'; import { Typography } from '~/components/typography'; import { Input } from '~/components/ui/input'; @@ -17,10 +17,10 @@ export { ErrorBoundary } from '~/components/ErrorBoundary'; export const loader = async ({ request, params }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); const team = await ensureIsTeamMember(request, params.teamSlug); - return json({ team }); + return { team }; }; -export const action = async ({ request, params }: ActionFunctionArgs) => { +export const action = async ({ request, params, response }: ActionFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); await ensureIsTeamMember(request, params.teamSlug); if (request.method === 'PUT') { @@ -35,10 +35,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { slug: params.teamSlug.toLowerCase(), }, }); - return redirect(`/dashboard/${team.slug}/settings`); + return redirect(response, `/dashboard/${team.slug}/settings`); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - return json({ errors: { name: 'This team name is already taken' } }, { status: 400 }); + response!.status = 400; + return { errors: { name: 'This team name is already taken' } }; } } } @@ -48,9 +49,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { slug: params.teamSlug.toLowerCase(), }, }); - return redirect(`/dashboard`); + return redirect(response, `/dashboard`); } - return json({ errors: { name: 'Something went wrong' } }, { status: 400 }); + response!.status = 400; + return { errors: { name: 'Something went wrong' } }; }; export default function TeamSettings() { diff --git a/web/app/routes/_authed.dashboard_.$teamSlug.tsx b/web/app/routes/_authed.dashboard_.$teamSlug.tsx index 68b3735..04b0db2 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug.tsx @@ -1,8 +1,7 @@ -import { Link, Outlet, useLoaderData } from '@remix-run/react'; +import { Link, Outlet, useLoaderData, type MetaArgs_SingleFetch } from '@remix-run/react'; import type { LoaderFunctionArgs, MetaFunction } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import { LinkTabs } from '~/components/LinkTabs'; -import { jsonHash } from 'remix-utils/json-hash'; import invariant from 'tiny-invariant'; import { Button } from '~/components/ui/button'; import { Card } from '~/components/ui/card'; @@ -10,11 +9,11 @@ import { Typography } from '~/components/typography'; export { ErrorBoundary } from '~/components/ErrorBoundary'; -export const meta: MetaFunction = ({ data }) => [{ title: `${data?.team.name} | Stats` }]; +export const meta = ({ data }: MetaArgs_SingleFetch) => [{ title: `${data?.team.name} | Stats` }]; export const loader = async ({ request, params }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); - return jsonHash({ team: ensureIsTeamMember(request, params.teamSlug) }); + return { team: await ensureIsTeamMember(request, params.teamSlug) }; }; const TABS = [ diff --git a/web/app/routes/_authed.dashboard_.$teamSlug.usage.tsx b/web/app/routes/_authed.dashboard_.$teamSlug.usage.tsx index aaec287..1d44fad 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug.usage.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug.usage.tsx @@ -3,7 +3,6 @@ import type { LoaderFunctionArgs } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import type { Usage } from '~/utils_usage.server'; import { getTeamUsage } from '~/utils_usage.server'; -import { jsonHash } from 'remix-utils/json-hash'; import invariant from 'tiny-invariant'; import { Card } from '~/components/ui/card'; import { Typography } from '~/components/typography'; @@ -15,7 +14,7 @@ export { ErrorBoundary } from '~/components/ErrorBoundary'; export const loader = async ({ request, params }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); await ensureIsTeamMember(request, params.teamSlug); - return jsonHash({ usage: getTeamUsage(params.teamSlug) }); + return { usage: await getTeamUsage(params.teamSlug) }; }; export const UsageDisplay = ({ label, description, usage }: { label: string; description?: string; usage: Usage }) => { diff --git a/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug._index.tsx b/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug._index.tsx index 00074b0..a935c0a 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug._index.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug._index.tsx @@ -5,7 +5,6 @@ import { Statistics } from '~/components/statistics'; import { loadStatistics } from '~/components/statistics/loader.server'; import { useInterval } from '~/hooks/useInterval'; import { secondsToMilliseconds } from 'date-fns'; -import { jsonHash } from 'remix-utils/json-hash'; import invariant from 'tiny-invariant'; export { ErrorBoundary } from '~/components/ErrorBoundary'; @@ -14,7 +13,7 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); invariant(params.projectSlug, 'Expected params.projectSlug'); await ensureIsTeamMember(request, params.teamSlug); - return jsonHash({ statistics: loadStatistics({ request, teamSlug: params.teamSlug, projectSlug: params.projectSlug }) }); + return { statistics: await loadStatistics({ request, teamSlug: params.teamSlug, projectSlug: params.projectSlug }) }; }; export default function ProjectPageviewsStatistics() { diff --git a/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.settings.tsx b/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.settings.tsx index 4bad459..8fe4d54 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.settings.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.settings.tsx @@ -1,12 +1,12 @@ import { Prisma } from '@prisma/client'; import { Form, useActionData, useLoaderData, useNavigation, useSubmit } from '@remix-run/react'; import type { ActionFunctionArgs, LoaderFunctionArgs } from '@vercel/remix'; -import { json, redirect } from '@vercel/remix'; import { ensureIsTeamMember } from '~/auth.server'; import { useIsClient } from '~/hooks/useIsClient'; import { prismaClient } from '~/prismaClient'; import { useCallback, useState } from 'react'; import invariant from 'tiny-invariant'; +import { redirect } from '~/utils.server'; import { Card } from '~/components/ui/card'; import { Button } from '~/components/ui/button'; import { Typography } from '~/components/typography'; @@ -28,7 +28,7 @@ import { export { ErrorBoundary } from '~/components/ErrorBoundary'; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { +export const loader = async ({ request, params, response }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); invariant(params.projectSlug, 'Expected params.projectSlug'); await ensureIsTeamMember(request, params.teamSlug); @@ -40,13 +40,13 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); if (!project) { - throw redirect(`/dashboard/${params.teamSlug}`); + throw redirect(response, `/dashboard/${params.teamSlug}`); } - return json({ project }); + return { project }; }; -export const action = async ({ request, params }: ActionFunctionArgs) => { +export const action = async ({ request, params, response }: ActionFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); invariant(params.projectSlug, 'Expected params.projectSlug'); await ensureIsTeamMember(request, params.teamSlug); @@ -73,7 +73,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }); } - return redirect(`/dashboard/${params.teamSlug}/${params.projectSlug}/settings`); + return redirect(response, `/dashboard/${params.teamSlug}/${params.projectSlug}/settings`); } if (request.method === 'PUT') { const formData = await request.formData(); @@ -96,10 +96,11 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }, }); - return redirect(`/dashboard/${params.teamSlug}/${project.slug}/settings`); + return redirect(response, `/dashboard/${params.teamSlug}/${project.slug}/settings`); } catch (e) { if (e instanceof Prisma.PrismaClientKnownRequestError) { - return json({ errors: { name: 'This team already contains a project with this name' } }, { status: 400 }); + response!.status = 400; + return { errors: { name: 'This team already contains a project with this name' } }; } } } @@ -112,9 +113,10 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { }, }, }); - return redirect(`/dashboard/${params.teamSlug}`); + return redirect(response, `/dashboard/${params.teamSlug}`); } - return json({ errors: { name: 'Something went wrong' } }, { status: 400 }); + response!.status = 400; + return { errors: { name: 'Something went wrong' } }; }; export default function ProjectSettings() { diff --git a/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.tsx b/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.tsx index a81f0ec..53db2b2 100644 --- a/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.tsx +++ b/web/app/routes/_authed.dashboard_.$teamSlug_.$projectSlug.tsx @@ -1,9 +1,9 @@ import { Outlet, useLoaderData } from '@remix-run/react'; import type { LoaderFunctionArgs, MetaFunction } from '@vercel/remix'; -import { json, redirect } from '@vercel/remix'; import { LinkTabs } from '~/components/LinkTabs'; import { prismaClient } from '~/prismaClient'; import invariant from 'tiny-invariant'; +import { redirect } from '~/utils.server'; import { Card } from '~/components/ui/card'; import { Typography } from '~/components/typography'; @@ -11,7 +11,7 @@ export { ErrorBoundary } from '~/components/ErrorBoundary'; export const meta: MetaFunction = ({ data }) => [{ title: `${data?.project.name} | Stats` }]; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { +export const loader = async ({ response, params }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); invariant(params.projectSlug, 'Expected params.projectSlug'); @@ -21,9 +21,9 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { const project = await prismaClient.project.findFirst({ where: { slug: projectSlug, teamSlug: teamSlug } }); if (!project) { - throw redirect(`/dashboard/${teamSlug}`); + throw redirect(response, `/dashboard/${teamSlug}`); } - return json({ teamSlug, project }); + return { teamSlug, project }; }; const TABS = [ diff --git a/web/app/routes/_authed.profile.tsx b/web/app/routes/_authed.profile.tsx index d11f143..762c799 100644 --- a/web/app/routes/_authed.profile.tsx +++ b/web/app/routes/_authed.profile.tsx @@ -1,7 +1,6 @@ import { Link, useLoaderData } from '@remix-run/react'; import type { LoaderFunctionArgs, MetaFunction } from '@vercel/remix'; import { getUserOrRedirect } from '~/auth.server'; -import { jsonHash } from 'remix-utils/json-hash'; import { Card } from '~/components/ui/card'; import { Typography } from '~/components/typography'; import { Avatar, AvatarFallback, AvatarImage } from '~/components/ui/avatar'; @@ -13,7 +12,7 @@ export const meta: MetaFunction = () => [{ title: 'Profile | Stats' }]; export const loader = async ({ request }: LoaderFunctionArgs) => { const user = await getUserOrRedirect(request); - return jsonHash({ user }); + return { user }; }; export default function Profile() { diff --git a/web/app/routes/_authed.tsx b/web/app/routes/_authed.tsx index 367afe2..47bb235 100644 --- a/web/app/routes/_authed.tsx +++ b/web/app/routes/_authed.tsx @@ -3,7 +3,6 @@ import type { LoaderFunctionArgs } from '@vercel/remix'; import { getUserOrRedirect } from '~/auth.server'; import { Navbar } from '~/components/Navbar'; import { prismaClient } from '~/prismaClient'; -import { jsonHash } from 'remix-utils/json-hash'; import { Container } from '~/components/container'; import { RepositoryLink } from '~/components/repository-link'; @@ -21,7 +20,7 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }, include: { projects: true }, }); - return jsonHash({ teams, user }); + return { teams, user }; }; export default function Index() { diff --git a/web/app/routes/_index.tsx b/web/app/routes/_index.tsx index c640f67..5fb7170 100644 --- a/web/app/routes/_index.tsx +++ b/web/app/routes/_index.tsx @@ -1,5 +1,5 @@ import { Form } from '@remix-run/react'; -import { type LoaderFunctionArgs, redirect } from '@vercel/remix'; +import { type LoaderFunctionArgs } from '@vercel/remix'; import { authenticator } from '~/auth.server'; import { Container } from '~/components/container'; import { RepositoryLink } from '~/components/repository-link'; @@ -8,13 +8,14 @@ import { Button } from '~/components/ui/button'; import { Card } from '~/components/ui/card'; import { Separator } from '~/components/ui/separator'; import { stats } from '~/stats'; +import { redirect } from '~/utils.server'; export { ErrorBoundary } from '~/components/ErrorBoundary'; -export const loader = async ({ request }: LoaderFunctionArgs) => { +export const loader = async ({ request, response }: LoaderFunctionArgs) => { const user = await authenticator.isAuthenticated(request); if (user) { - return redirect(`/dashboard`); + return redirect(response, `/dashboard`); } return null; }; diff --git a/web/app/routes/api-internal.$teamSlug.$projectSlug.event.tsx b/web/app/routes/api-internal.$teamSlug.$projectSlug.event.tsx index 29c5a16..0e99dfc 100644 --- a/web/app/routes/api-internal.$teamSlug.$projectSlug.event.tsx +++ b/web/app/routes/api-internal.$teamSlug.$projectSlug.event.tsx @@ -1,6 +1,5 @@ import type { Prisma, Project } from '@prisma/client'; import type { ActionFunctionArgs } from '@vercel/remix'; -import { json } from '@vercel/remix'; import { prismaClient } from '~/prismaClient'; import { getProjectAndCheckPermissions } from '~/utils_api.server'; import { getCustomEventsUsage } from '~/utils_usage.server'; @@ -12,7 +11,7 @@ import type { CustomEventInput } from '~/types'; const parseCustomEventInput = async (request: Request): Promise => { const data = (await request.json()) as CustomEventInput; if (!data.name) { - throw json({ errors: { name: `Name isn't defined` } }, { status: 400 }); + throw Response.json({ errors: { name: `Name isn't defined` } }, { status: 400 }); } return data; @@ -60,9 +59,9 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await trackCustomEvent(request, project); - return json({ ok: true }, { status: 202 }); + return { ok: true }; } catch (e) { console.error('[API-Internal - Event]', e); - return json({ ok: false }, { status: 400 }); + return Response.json({ ok: false }, { status: 400 }); } }; diff --git a/web/app/routes/api-internal.$teamSlug.$projectSlug.pageview-next.tsx b/web/app/routes/api-internal.$teamSlug.$projectSlug.pageview-next.tsx index 1e8ee2d..39a5d68 100644 --- a/web/app/routes/api-internal.$teamSlug.$projectSlug.pageview-next.tsx +++ b/web/app/routes/api-internal.$teamSlug.$projectSlug.pageview-next.tsx @@ -1,7 +1,6 @@ import type { Location, Prisma, Project } from '@prisma/client'; import type { Geo } from '@vercel/edge'; import type { ActionFunctionArgs } from '@vercel/remix'; -import { json } from '@vercel/remix'; import { prismaClient } from '~/prismaClient'; import { getProjectAndCheckPermissions } from '~/utils_api.server'; import { getPageViewsNextUsage } from '~/utils_usage.server'; @@ -89,7 +88,7 @@ export const action = async ({ request, params }: ActionFunctionArgs) => { await trackPageviewNext(request, project); } catch (e) { console.error('[API-Internal - PageviewNext]', e); - return json({ ok: false }, { status: 400 }); + return Response.json({ ok: false }, { status: 400 }); } - return json({ ok: true }, { status: 202 }); + return Response.json({ ok: true }, { status: 200 }); }; diff --git a/web/app/routes/api.$teamSlug.$projectSlug.event.tsx b/web/app/routes/api.$teamSlug.$projectSlug.event.tsx index bb5539c..819d1f4 100644 --- a/web/app/routes/api.$teamSlug.$projectSlug.event.tsx +++ b/web/app/routes/api.$teamSlug.$projectSlug.event.tsx @@ -1,6 +1,5 @@ import type { RequestContext } from '@vercel/edge'; import type { ActionFunctionArgs } from '@vercel/remix'; -import { json } from '@vercel/remix'; import { forwardRequestToInternalApi } from '~/utils_edge.server'; import invariant from 'tiny-invariant'; @@ -17,9 +16,9 @@ export const action = async ({ request, params, context }: ActionFunctionArgs) = } else { await forwardRequestToInternalApi(request, `${params.teamSlug}/${params.projectSlug}/event/`); } - return json({ ok: true }, { status: 200 }); + return Response.json({ ok: true }, { status: 200 }); } catch (e) { console.error('[API - Event]', e); - return json({ ok: false }, { status: 200 }); + return Response.json({ ok: false }, { status: 400 }); } }; diff --git a/web/app/routes/api.$teamSlug.$projectSlug.pageview.tsx b/web/app/routes/api.$teamSlug.$projectSlug.pageview.tsx index 15caab3..2082006 100644 --- a/web/app/routes/api.$teamSlug.$projectSlug.pageview.tsx +++ b/web/app/routes/api.$teamSlug.$projectSlug.pageview.tsx @@ -1,7 +1,6 @@ import { geolocation, ipAddress } from '@vercel/edge'; import type { RequestContext } from '@vercel/edge'; import type { ActionFunctionArgs } from '@vercel/remix'; -import { json } from '@vercel/remix'; import type { UserAgentData } from '~/user-agent'; import { userAgent } from '~/user-agent'; import { forwardRequestToInternalApi } from '~/utils_edge.server'; @@ -97,7 +96,7 @@ export const action = async ({ request, params, context }: ActionFunctionArgs) = } } catch (e) { console.error('[API - Pageview]', e); - return json({ ok: false }, { status: 200 }); + return Response.json({ ok: false }, { status: 400 }); } - return json({ ok: true }, { status: 200 }); + return Response.json({ ok: true }, { status: 200 }); }; diff --git a/web/app/routes/auth.github._index.tsx b/web/app/routes/auth.github._index.tsx index 895e622..acc0997 100644 --- a/web/app/routes/auth.github._index.tsx +++ b/web/app/routes/auth.github._index.tsx @@ -1,7 +1,7 @@ -import type { ActionFunctionArgs } from '@vercel/remix'; -import { redirect } from '@vercel/remix'; +import type { ActionFunctionArgs, LoaderFunctionArgs } from '@vercel/remix'; import { authenticator } from '~/auth.server'; +import { redirect } from '~/utils.server'; -export const loader = async () => redirect('/'); +export const loader = async ({ response }: LoaderFunctionArgs) => redirect(response, '/'); export const action = async ({ request }: ActionFunctionArgs) => authenticator.authenticate('github', request); diff --git a/web/app/routes/public.$teamSlug.$projectSlug._index.tsx b/web/app/routes/public.$teamSlug.$projectSlug._index.tsx index 2d6feac..b679536 100644 --- a/web/app/routes/public.$teamSlug.$projectSlug._index.tsx +++ b/web/app/routes/public.$teamSlug.$projectSlug._index.tsx @@ -12,7 +12,7 @@ export { ErrorBoundary } from '~/components/ErrorBoundary'; export const loader = async ({ request, params }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); invariant(params.projectSlug, 'Expected params.projectSlug'); - return jsonHash({ statistics: loadStatistics({ request, teamSlug: params.teamSlug, projectSlug: params.projectSlug }) }); + return { statistics: await loadStatistics({ request, teamSlug: params.teamSlug, projectSlug: params.projectSlug }) }; }; export default function PublicPageviewsStatistics() { diff --git a/web/app/routes/public.$teamSlug.$projectSlug.tsx b/web/app/routes/public.$teamSlug.$projectSlug.tsx index 8b563e5..ba7847a 100644 --- a/web/app/routes/public.$teamSlug.$projectSlug.tsx +++ b/web/app/routes/public.$teamSlug.$projectSlug.tsx @@ -1,9 +1,8 @@ import { Outlet, useLoaderData } from '@remix-run/react'; import type { LoaderFunctionArgs, MetaFunction } from '@vercel/remix'; -import { redirect } from '@vercel/remix'; import { prismaClient } from '~/prismaClient'; -import { jsonHash } from 'remix-utils/json-hash'; import invariant from 'tiny-invariant'; +import { redirect } from '~/utils.server'; import { Container } from '~/components/container'; import { RepositoryLink } from '~/components/repository-link'; import { Typography } from '~/components/typography'; @@ -12,7 +11,7 @@ export { ErrorBoundary } from '~/components/ErrorBoundary'; export const meta: MetaFunction = ({ data }) => [{ title: `${data?.project.team.name}/${data?.project.name} | Stats` }]; -export const loader = async ({ request, params }: LoaderFunctionArgs) => { +export const loader = async ({ params, response }: LoaderFunctionArgs) => { invariant(params.teamSlug, 'Expected params.teamSlug'); invariant(params.projectSlug, 'Expected params.projectSlug'); @@ -33,13 +32,11 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => { }); if (!project) { - throw redirect('/'); + throw redirect(response, '/'); } try { - return jsonHash({ - project, - }); + return { project }; } catch (e) { console.error(e); throw e; diff --git a/web/app/routes/stream_.tsx b/web/app/routes/stream_.tsx index 8500e30..e19e7d7 100644 --- a/web/app/routes/stream_.tsx +++ b/web/app/routes/stream_.tsx @@ -3,7 +3,6 @@ import type { GlobeWithCitiesProps } from '~/components/statistics/GlobeWithCiti import { GlobeWithCities } from '~/components/statistics/GlobeWithCities'; import { subHours } from 'date-fns'; import { useEffect, useState } from 'react'; -import { jsonHash } from 'remix-utils/json-hash'; import { useEventSource } from 'remix-utils/sse/react'; import type { GeoLocationsEventData } from './stream.geolocations'; @@ -14,7 +13,7 @@ import { Typography } from '~/components/typography'; export { ErrorBoundary } from '~/components/ErrorBoundary'; -export const loader = async () => jsonHash({ topGeoLocations: getTopGeoLocations(subHours(new Date(), 72)) }); +export const loader = async () => ({ topGeoLocations: await getTopGeoLocations(subHours(new Date(), 72)) }); export default function Stream() { const { topGeoLocations } = useLoaderData(); diff --git a/web/app/utils.server.ts b/web/app/utils.server.ts new file mode 100644 index 0000000..a1515d9 --- /dev/null +++ b/web/app/utils.server.ts @@ -0,0 +1,18 @@ +import { LoaderFunctionArgs } from '@remix-run/node'; +import create_slug from 'slugify'; + +export const slugify = (string: string) => + create_slug(string, { + lower: true, + strict: true, + trim: true, + }); + +export const redirect = (response: LoaderFunctionArgs['response'], to: string): never => { + if (response) { + response.status = 302; + response.headers.set('Location', to); + return response as never; + } + throw new Error("[redirect()] 'response' can't be null when using redirect"); +}; diff --git a/web/app/utils.ts b/web/app/utils.ts deleted file mode 100644 index fdd8853..0000000 --- a/web/app/utils.ts +++ /dev/null @@ -1,8 +0,0 @@ -import create_slug from 'slugify'; - -export const slugify = (string: string) => - create_slug(string, { - lower: true, - strict: true, - trim: true, - }); diff --git a/web/package.json b/web/package.json index aeeccd9..5231232 100644 --- a/web/package.json +++ b/web/package.json @@ -8,7 +8,9 @@ "node": ">=20" }, "scripts": { - "build": "pnpm run db:generate && remix vite:build", + "prebuild": "pnpm run db:generate", + "build": "remix vite:build", + "postinstall": "pnpm run db:generate", "dev": "remix vite:dev", "start": "remix-serve ./build/server/index.js", "typecheck": "tsc -b", diff --git a/web/tsconfig.json b/web/tsconfig.json index 7fe59a3..77d0115 100644 --- a/web/tsconfig.json +++ b/web/tsconfig.json @@ -1,5 +1,5 @@ { - "include": ["env.d.ts", "**/*.ts", "**/*.tsx"], + "include": ["env.d.ts", "**/*.ts", "**/*.tsx", "node_modules/@remix-run/react/future/single-fetch.d.ts"], "compilerOptions": { "lib": ["DOM", "DOM.Iterable", "ES2022"], "isolatedModules": true, diff --git a/web/vite.config.ts b/web/vite.config.ts index 68410d0..afae101 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -5,7 +5,7 @@ import { defineConfig } from 'vite'; import tsconfigPaths from 'vite-tsconfig-paths'; import tailwindcss from '@tailwindcss/vite'; -installGlobals(); +installGlobals({ nativeFetch: true }); export default defineConfig({ server: { @@ -19,6 +19,12 @@ export default defineConfig({ serverModuleFormat: 'esm', ignoredRouteFiles: ['**/.*'], presets: [vercelPreset()], + future: { + v3_throwAbortReason: true, + v3_fetcherPersist: true, + v3_relativeSplatPath: true, + unstable_singleFetch: true, + }, }), tsconfigPaths(), tailwindcss(),