Skip to content

Commit

Permalink
Refactor UI client side only / Fix memory leak with compression
Browse files Browse the repository at this point in the history
  • Loading branch information
trevorsharp committed Jan 9, 2025
1 parent f6e0425 commit 1063dc9
Show file tree
Hide file tree
Showing 17 changed files with 207 additions and 48 deletions.
Binary file modified bun.lockb
Binary file not shown.
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,11 @@
"@hookform/resolvers": "^3.3.4",
"@t3-oss/env-nextjs": "^0.9.2",
"@tailwindcss/forms": "^0.5.7",
"brotli-compress": "^1.3.3",
"brotli": "^1.3.3",
"clsx": "^2.1.0",
"fast-xml-parser": "^4.3.6",
"next": "^14.1.3",
"prettier-plugin-style-order": "^0.2.2",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-hook-form": "^7.51.3",
Expand All @@ -28,6 +29,7 @@
"zod": "^3.24.1"
},
"devDependencies": {
"@types/brotli": "^1.3.4",
"@types/eslint": "^8.56.2",
"@types/node": "^20.11.20",
"@types/react": "^18.2.57",
Expand Down
8 changes: 3 additions & 5 deletions src/app/[feedId]/feed/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,13 @@ const GET = async (request: Request, { params }: { params: { feedId: string } })
const { feedId } = params;

const host = request.headers.get("host") ?? "";
const searchParams =
host && request.url ? new URL(`http://${host}${request.url}`).searchParams : undefined;

const modConfig = await decompressModConfig(feedId);
const modConfig = decompressModConfig(feedId);
if (!modConfig) {
return new NextResponse("Bad Request - Invalid feed parameter in URL", { status: 400 });
return new NextResponse("Bad Request - Invalid feed id", { status: 400 });
}

const feedData = await fetchFeedData(modConfig.sources, searchParams);
const feedData = await fetchFeedData(modConfig.sources);
if (!feedData) throw "Could not find feed data for sources";

const moddedFeedData = applyMods(feedData, modConfig);
Expand Down
4 changes: 1 addition & 3 deletions src/app/[feedId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import MainPage from "~/components/MainPage";
import { decompressModConfig } from "~/services/compressionService";

type PageProps = {
params: {
Expand All @@ -8,8 +7,7 @@ type PageProps = {
};

const Page = async ({ params }: PageProps) => {
const modConfig = await decompressModConfig(params.feedId);
return <MainPage initialModConfig={modConfig} />;
return <MainPage initialFeedId={params.feedId} />;
};

export default Page;
24 changes: 24 additions & 0 deletions src/app/api/mod-config/[feedId]/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { NextResponse } from "next/server";
import { decompressModConfig } from "~/services/compressionService";

const GET = async (request: Request, { params }: { params: { feedId: string } }) => {
try {
const { feedId } = params;

const modConfig = decompressModConfig(feedId);
if (!modConfig) {
return new NextResponse("Bad Request - Invalid feed id", { status: 400 });
}

return new NextResponse(JSON.stringify(modConfig), {
headers: { "Cache-Control": "max-age=86400", "Content-Type": "application/json" },
});
} catch (errorMessage) {
console.error(errorMessage);
return new NextResponse((errorMessage as string | undefined) ?? "Unexpected Error", {
status: 500,
});
}
};

export { GET };
28 changes: 28 additions & 0 deletions src/app/api/mod-config/feed-id/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { NextResponse } from "next/server";
import modConfigSchema from "~/schemas/modConfig";
import { compressModConfig } from "~/services/compressionService";

const POST = async (request: Request) => {
try {
const body = (await request.json()) as unknown;

const { data: modConfig, error } = modConfigSchema.safeParse(body);
if (error) {
return new NextResponse("Bad Request - Invalid mod config", { status: 400 });
}

const feedId = compressModConfig(modConfig);
if (!feedId) {
return new NextResponse("Bad Request - Could not compress mod config", { status: 400 });
}

return new NextResponse(feedId, { headers: { "Cache-Control": "max-age=86400" } });
} catch (errorMessage) {
console.error(errorMessage);
return new NextResponse((errorMessage as string | undefined) ?? "Unexpected Error", {
status: 500,
});
}
};

export { POST };
30 changes: 30 additions & 0 deletions src/app/api/source-feed-data/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { fetchFeedData } from "~/services/feedService";

const POST = async (request: Request) => {
try {
const body = (await request.json()) as unknown;

const { data: urls, error } = z.array(z.string().url()).safeParse(body);
if (error) {
return new NextResponse("Bad Request - Invalid urls", { status: 400 });
}

const feedData = await fetchFeedData(urls);
if (!feedData) {
return new NextResponse("Bad Request - Invalid sources", { status: 400 });
}

return new NextResponse(JSON.stringify(feedData), {
headers: { "Cache-Control": "max-age=900", "Content-Type": "application/json" },
});
} catch (errorMessage) {
console.error(errorMessage);
return new NextResponse((errorMessage as string | undefined) ?? "Unexpected Error", {
status: 500,
});
}
};

export { POST };
54 changes: 40 additions & 14 deletions src/components/CopyFeedButton.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,63 @@
"use client";

import { useState } from "react";
import { useEffect, useState } from "react";
import { LinkIcon } from "@heroicons/react/20/solid";
import { compressModConfig } from "~/services/compressionService";
import type { ModConfig } from "~/types/ModConfig";

const defaultButtonText = "Copy Feed URL";

type ButtonProps = {
modConfig: ModConfig;
};

const CopyFeedButton = ({ modConfig }: ButtonProps) => {
const [buttonText, setButtonText] = useState<string>(defaultButtonText);
const [feedId, setFeedId] = useState<string | undefined>(undefined);
const [buttonText, setButtonText] = useState("Generate Feed");
const [loading, setLoading] = useState(false);

useEffect(() => {
setFeedId(undefined);
setButtonText("Generate Feed");
}, [JSON.stringify(modConfig)]);

const generateFeed = async () => {
setLoading(true);

return await fetch(`/api/mod-config/feed-id`, {
method: "POST",
body: JSON.stringify(modConfig),
headers: { "Content-Type": "application/json" },
})
.then((response) => response.text())
.then((newFeedId) => {
setFeedId(newFeedId);
setButtonText("Copy Feed URL");
})
.catch(() => {
setButtonText("Failed to Generate");
setTimeout(() => setButtonText("Generate Feed"), 2000);
})
.finally(() => setLoading(false));
};

const onClick = () =>
compressModConfig(modConfig)
.then((feedId) => `${window.location.origin}/${feedId}/feed`)
.then((textToCopy) => navigator.clipboard.writeText(textToCopy))
const copyFeedUrl = async () => {
await navigator.clipboard
.writeText(`${window.location.origin}/${feedId}/feed`)
.then(() => {
setButtonText("Copied!");
setTimeout(() => setButtonText(defaultButtonText), 2000);
});
setButtonText("Copied");
setTimeout(() => setButtonText("Copy Feed URL"), 2000);
})
.catch(() => setButtonText("Failed to Copy"));
};

return (
<button
className="h-fit w-full rounded-md bg-podmod px-4 py-3 text-sm font-semibold text-white shadow-sm hover:bg-podmod-dark focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-podmod sm:w-48"
type="button"
onClick={onClick}
onClick={feedId ? copyFeedUrl : generateFeed}
disabled={loading}
>
<div className="flex items-center justify-center gap-3">
<LinkIcon className="h-4 w-4" />
<span className="whitespace-nowrap">{buttonText}</span>
<span className="whitespace-nowrap">{loading ? "Loading..." : buttonText}</span>
</div>
</button>
);
Expand Down
2 changes: 2 additions & 0 deletions src/components/EpisodeCard.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import formatDuration from "~/utils/formatDuration";

type EpisodeCardProps = {
Expand Down
2 changes: 2 additions & 0 deletions src/components/FeedPreview.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import Image from "next/image";
import { applyMods } from "~/services/modService";
import getValue from "~/utils/getValue";
Expand Down
6 changes: 3 additions & 3 deletions src/components/Form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import modConfigSchema from "~/schemas/modConfig";
import type { ModConfig } from "~/types/ModConfig";

type FormProps = {
initialModConfig: ModConfig | undefined;
modConfig: ModConfig | undefined;
setModConfig: (_: ModConfig) => void;
};

const Form = ({ initialModConfig, setModConfig }: FormProps) => {
const Form = ({ modConfig, setModConfig }: FormProps) => {
const form = useForm({
mode: "onChange",
resolver: zodResolver(
Expand All @@ -33,7 +33,7 @@ const Form = ({ initialModConfig, setModConfig }: FormProps) => {
...title.defaultValue,
...imageUrl.defaultValue,
...mods.defaultValue,
...initialModConfig,
...modConfig,
},
});

Expand Down
43 changes: 37 additions & 6 deletions src/components/MainPage.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,58 @@
"use client";

import { useEffect, useState } from "react";
import { fetchFeedData } from "~/services/feedService";
import { useRouter } from "next/navigation";
import feed from "~/schemas/feed";
import modConfigSchema from "~/schemas/modConfig";
import CopyFeedButton from "./CopyFeedButton";
import FeedPreview from "./FeedPreview";
import Form from "./Form";
import type { FeedData } from "~/types/FeedData";
import type { ModConfig } from "~/types/ModConfig";

type MainPageProps = {
initialModConfig?: ModConfig | undefined;
initialFeedId?: string | undefined;
};

const MainPage = ({ initialModConfig }: MainPageProps) => {
const [modConfig, setModConfig] = useState<ModConfig | undefined>(initialModConfig);
const MainPage = ({ initialFeedId }: MainPageProps) => {
const router = useRouter();

const [loading, setLoading] = useState(!!initialFeedId);
const [modConfig, setModConfig] = useState<ModConfig | undefined>(undefined);
const [sourceFeedData, setSourceFeedData] = useState<FeedData | undefined>(undefined);

useEffect(() => {
setLoading(true);
fetch(`/api/mod-config/${initialFeedId}`)
.then((response) => response.json())
.then((data) => modConfigSchema.parse(data))
.then((initialModConfig) => setModConfig(initialModConfig))
.catch(() => router.push("/"));
}, [initialFeedId]);

useEffect(() => {
if (modConfig) {
const _ = fetchFeedData(modConfig?.sources).then((feedData) => setSourceFeedData(feedData));
fetch(`/api/source-feed-data`, {
method: "POST",
body: JSON.stringify(modConfig.sources),
headers: { "Content-Type": "application/json" },
})
.then((response) => response.json())
.then((data) => feed.parse(data))
.then((feedData) => setSourceFeedData(feedData))
.catch(console.error)
.finally(() => setLoading(false));
}
}, [JSON.stringify(modConfig?.sources)]);

if (loading) {
return (
<div className="flex min-h-screen w-full justify-center p-24 text-xl font-extrabold xs:text-2xl">
Loading Feed Data...
</div>
);
}

return (
<div className="flex min-h-screen items-start p-8 2xl:items-center">
<div className="flex flex-col items-center justify-center gap-16 2xl:flex-row">
Expand All @@ -40,7 +71,7 @@ const MainPage = ({ initialModConfig }: MainPageProps) => {
{modConfig && sourceFeedData && <CopyFeedButton modConfig={modConfig} />}
</div>

<Form initialModConfig={initialModConfig} setModConfig={setModConfig} />
<Form modConfig={modConfig} setModConfig={setModConfig} />
</div>

<FeedPreview modConfig={modConfig} sourceFeedData={sourceFeedData} />
Expand Down
2 changes: 2 additions & 0 deletions src/components/SectionHeader.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import type { ReactNode } from "react";

type SectionTitleProps = {
Expand Down
2 changes: 0 additions & 2 deletions src/formSections/mods.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import { useFieldArray, useFormContext } from "react-hook-form";
import z from "zod";
import AddButton from "~/components/AddButton";
Expand Down
2 changes: 0 additions & 2 deletions src/formSections/sources.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
"use client";

import { useFieldArray, useFormContext } from "react-hook-form";
import z from "zod";
import AddButton from "~/components/AddButton";
Expand Down
41 changes: 31 additions & 10 deletions src/services/compressionService.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,38 @@
import { compress, decompress } from "brotli-compress";
"use server";

import "server-only";
import brotli from "brotli";
import modConfigSchema from "~/schemas/modConfig";
import type { ModConfig } from "~/types/ModConfig";

const compressModConfig = async (modConfig: ModConfig): Promise<string> => {
const compressedText = await compress(Buffer.from(JSON.stringify(modConfig)));
return Buffer.from(compressedText).toString("hex");
const { compress, decompress } = brotli as {
compress: (buffer: Buffer, options?: never) => Uint8Array;
decompress: (buffer: Buffer, outputSize?: number) => Uint8Array;
};

const compressModConfig = (modConfig: ModConfig): string => {
const decompressedString = JSON.stringify(modConfig);
const decompressedData = Buffer.from(decompressedString);

const compressedData = compress(decompressedData);
const compressedString = Buffer.from(compressedData.buffer).toString("hex");

return compressedString;
};

const decompressModConfig = async (compressedText: string): Promise<ModConfig | undefined> =>
decompress(Buffer.from(compressedText, "hex"))
.then((decompressedText) => Buffer.from(decompressedText).toString())
.then(JSON.parse)
.then((data) => modConfigSchema.parse(data))
.catch(() => undefined);
const decompressModConfig = (compressedText: string): ModConfig | undefined => {
try {
const compressedData = Buffer.from(compressedText, "hex");

const decompressedData = decompress(compressedData);
const decompressedText = Buffer.from(decompressedData.buffer).toString();

const modConfig = JSON.parse(decompressedText) as unknown;

return modConfigSchema.parse(modConfig);
} catch {
return undefined;
}
};

export { compressModConfig, decompressModConfig };
Loading

0 comments on commit 1063dc9

Please sign in to comment.