Skip to content

Commit

Permalink
Merge pull request #486 from steven-tey/edge-runtime
Browse files Browse the repository at this point in the history
  • Loading branch information
steven-tey authored Dec 5, 2023
2 parents c17f826 + 10685f9 commit 2bbcc6f
Show file tree
Hide file tree
Showing 11 changed files with 266 additions and 184 deletions.
48 changes: 48 additions & 0 deletions apps/web/app/api/edge/metatags/route-wip.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { ratelimit } from "@/lib/upstash";
import { LOCALHOST_IP, isValidUrl } from "@dub/utils";
import { ipAddress } from "@vercel/edge";
import { getToken } from "next-auth/jwt";
import { NextFetchEvent, NextRequest } from "next/server";
import { getMetaTags } from "./utils";

export const runtime = "edge";

// TODO: waitUntil() is not supported in App Router yet: https://vercel.com/docs/functions/edge-functions/edge-functions-api#waituntil
export async function GET(req: NextRequest, ev: NextFetchEvent) {
const url = req.nextUrl.searchParams.get("url");
if (!url || !isValidUrl(url)) {
return new Response("Invalid URL", { status: 400 });
}

// Rate limit if user is not logged in
const session = await getToken({
req,
secret: process.env.NEXTAUTH_SECRET,
});
if (!session?.email) {
const ip = ipAddress(req) || LOCALHOST_IP;
const { success } = await ratelimit().limit(ip);
if (!success) {
return new Response("Don't DDoS me pls 🥺", { status: 429 });
}
}

const metatags = await getMetaTags(url, ev);
return new Response(JSON.stringify(metatags), {
status: 200,
headers: {
"Content-Type": "application/json",
"Access-Control-Allow-Origin": "*",
},
});
}

export function OPTIONS() {
return new Response(null, {
status: 204,
headers: {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, OPTIONS",
},
});
}
111 changes: 111 additions & 0 deletions apps/web/app/api/edge/metatags/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { parse } from "node-html-parser";
import { recordMetatags } from "@/lib/upstash";
import { isValidUrl } from "@dub/utils";
import { NextFetchEvent } from "next/server";

export const getHtml = async (url: string) => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // timeout if it takes longer than 5 seconds
const response = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent": "dub-bot/1.0",
},
});
clearTimeout(timeoutId);
return await response.text();
} catch (error) {
if (error.name === "AbortError") {
// Handle fetch request abort (e.g., due to timeout)
console.error("Fetch request aborted due to timeout.");
} else {
// Handle other fetch errors
console.error("Fetch request failed:", error);
}
return null;
}
};

export const getHeadChildNodes = (html) => {
const ast = parse(html); // parse the html into AST format with node-html-parser
const metaTags = ast.querySelectorAll("meta").map(({ attributes }) => {
const property = attributes.property || attributes.name || attributes.href;
return {
property,
content: attributes.content,
};
});
const title = ast.querySelector("title")?.innerText;
const linkTags = ast.querySelectorAll("link").map(({ attributes }) => {
const { rel, href } = attributes;
return {
rel,
href,
};
});

return { metaTags, title, linkTags };
};

export const getRelativeUrl = (url: string, imageUrl: string) => {
if (!imageUrl) {
return null;
}
if (isValidUrl(imageUrl)) {
return imageUrl;
}
const { protocol, host } = new URL(url);
const baseURL = `${protocol}//${host}`;
return new URL(imageUrl, baseURL).toString();
};

export const getMetaTags = async (url: string, ev?: NextFetchEvent) => {
const html = await getHtml(url);
if (!html) {
return {
title: url,
description: "No description",
image: null,
};
}
const { metaTags, title: titleTag, linkTags } = getHeadChildNodes(html);

let object = {};

for (let k in metaTags) {
let { property, content } = metaTags[k];

property && (object[property] = content);
}

for (let m in linkTags) {
let { rel, href } = linkTags[m];

rel && (object[rel] = href);
}

const title = object["og:title"] || object["twitter:title"] || titleTag;

const description =
object["description"] ||
object["og:description"] ||
object["twitter:description"];

const image =
object["og:image"] ||
object["twitter:image"] ||
object["image_src"] ||
object["icon"] ||
object["shortcut icon"];

ev?.waitUntil(
recordMetatags(url, title && description && image ? false : true),
);

return {
title: title || url,
description: description || "No description",
image: getRelativeUrl(url, image),
};
};
3 changes: 1 addition & 2 deletions apps/web/app/api/edge/stats/[endpoint]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,7 @@ import { LOCALHOST_IP } from "@dub/utils";
import { ipAddress } from "@vercel/edge";
import { NextResponse, type NextRequest } from "next/server";

// TODO: switch to 'edge' after https://github.com/vercel/next.js/issues/48295 is fixed
// export const runtime = "edge";
export const runtime = "edge";

export const GET = async (
req: NextRequest,
Expand Down
3 changes: 1 addition & 2 deletions apps/web/app/api/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { NextResponse } from "next/server";
import { OpenAPIV3 } from "openapi-types";

// TODO: switch to 'edge' after https://github.com/vercel/next.js/issues/48295 is fixed
// export const runtime = "edge";
export const runtime = "edge";

export function GET(): NextResponse<OpenAPIV3.Document> {
return NextResponse.json({
Expand Down
2 changes: 1 addition & 1 deletion apps/web/app/rewrite/[url]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { getMetaTags } from "@/pages/api/edge/metatags";
import {
GOOGLE_FAVICON_URL,
constructMetadata,
getApexDomain,
} from "@dub/utils";
import { getMetaTags } from "app/api/edge/metatags/utils";

export const runtime = "edge";

Expand Down
1 change: 0 additions & 1 deletion apps/web/lib/tinybird.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ export async function recordClick({
).then((res) => res.json()),
// increment the click count for the link if key is specified (not root click)
// also increment the usage count for the project, and then we have a cron that will reset it at the start of new billing cycle
// TODO: might wanna include root clicks in the usage count as well?
...(conn
? [
key
Expand Down
35 changes: 20 additions & 15 deletions apps/web/lib/upstash.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { nanoid } from "@dub/utils";
import { getDomainWithoutWWW, nanoid } from "@dub/utils";
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

Expand All @@ -8,6 +8,17 @@ export const redis = new Redis({
token: process.env.UPSTASH_REDIS_REST_TOKEN || "",
});

export const ratelimitRedis = new Redis({
url:
process.env.RATELIMIT_UPSTASH_REDIS_REST_URL ||
process.env.UPSTASH_REDIS_REST_URL ||
"",
token:
process.env.RATELIMIT_UPSTASH_REDIS_REST_TOKEN ||
process.env.UPSTASH_REDIS_REST_TOKEN ||
"",
});

// Create a new ratelimiter, that allows 10 requests per 10 seconds by default
export const ratelimit = (
requests: number = 10,
Expand All @@ -19,16 +30,7 @@ export const ratelimit = (
| `${number} d` = "10 s",
) => {
return new Ratelimit({
redis: new Redis({
url:
process.env.RATELIMIT_UPSTASH_REDIS_REST_URL ||
process.env.UPSTASH_REDIS_REST_URL ||
"",
token:
process.env.RATELIMIT_UPSTASH_REDIS_REST_TOKEN ||
process.env.UPSTASH_REDIS_REST_TOKEN ||
"",
}),
redis: ratelimitRedis,
limiter: Ratelimit.slidingWindow(requests, seconds),
analytics: true,
prefix: "dub",
Expand Down Expand Up @@ -67,9 +69,12 @@ export async function recordMetatags(url: string, error: boolean) {
if (url === "https://github.com/steven-tey/dub") {
// don't log metatag generation for default URL
return null;
} else {
return await redis.lpush(error ? "metatags-errors" : "metatags", {
url,
});
}

if (error) {
return await ratelimitRedis.zincrby("metatags-error-zset", 1, url);
}

const domain = getDomainWithoutWWW(url);
return await ratelimitRedis.zincrby("metatags-zset", 1, domain);
}
2 changes: 1 addition & 1 deletion apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
"js-cookie": "^3.0.5",
"lucide-react": "^0.269.0",
"nanoid": "^5.0.1",
"next": "14.0.3-canary.8",
"next": "14.0.4-canary.39",
"next-auth": "^4.24.4",
"node-html-parser": "^6.1.4",
"nodemailer": "^6.9.3",
Expand Down
111 changes: 2 additions & 109 deletions apps/web/pages/api/edge/metatags.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { ratelimit, recordMetatags } from "@/lib/upstash";
import { ratelimit } from "@/lib/upstash";
import { LOCALHOST_IP, isValidUrl } from "@dub/utils";
import { ipAddress } from "@vercel/edge";
import { getMetaTags } from "app/api/edge/metatags/utils";
import { getToken } from "next-auth/jwt";
import { NextFetchEvent, NextRequest } from "next/server";
import { parse } from "node-html-parser";

export const config = {
runtime: "edge",
Expand Down Expand Up @@ -49,110 +49,3 @@ export default async function handler(req: NextRequest, ev: NextFetchEvent) {
return new Response(`Method ${req.method} Not Allowed`, { status: 405 });
}
}

const getHtml = async (url: string) => {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 5000); // timeout if it takes longer than 5 seconds
const response = await fetch(url, {
signal: controller.signal,
headers: {
"User-Agent": "dub-bot/1.0",
},
});
clearTimeout(timeoutId);
return await response.text();
} catch (error) {
if (error.name === "AbortError") {
// Handle fetch request abort (e.g., due to timeout)
console.error("Fetch request aborted due to timeout.");
} else {
// Handle other fetch errors
console.error("Fetch request failed:", error);
}
return null;
}
};

const getHeadChildNodes = (html) => {
const ast = parse(html); // parse the html into AST format with node-html-parser
const metaTags = ast.querySelectorAll("meta").map(({ attributes }) => {
const property = attributes.property || attributes.name || attributes.href;
return {
property,
content: attributes.content,
};
});
const title = ast.querySelector("title")?.innerText;
const linkTags = ast.querySelectorAll("link").map(({ attributes }) => {
const { rel, href } = attributes;
return {
rel,
href,
};
});

return { metaTags, title, linkTags };
};

const getRelativeUrl = (url: string, imageUrl: string) => {
if (!imageUrl) {
return null;
}
if (isValidUrl(imageUrl)) {
return imageUrl;
}
const { protocol, host } = new URL(url);
const baseURL = `${protocol}//${host}`;
return new URL(imageUrl, baseURL).toString();
};

export const getMetaTags = async (url: string, ev?: NextFetchEvent) => {
const html = await getHtml(url);
if (!html) {
return {
title: url,
description: "No description",
image: null,
};
}
const { metaTags, title: titleTag, linkTags } = getHeadChildNodes(html);

let object = {};

for (let k in metaTags) {
let { property, content } = metaTags[k];

property && (object[property] = content);
}

for (let m in linkTags) {
let { rel, href } = linkTags[m];

rel && (object[rel] = href);
}

const title = object["og:title"] || object["twitter:title"] || titleTag;

const description =
object["description"] ||
object["og:description"] ||
object["twitter:description"];

const image =
object["og:image"] ||
object["twitter:image"] ||
object["image_src"] ||
object["icon"] ||
object["shortcut icon"];

ev?.waitUntil(
recordMetatags(url, title && description && image ? false : true),
);

return {
title: title || url,
description: description || "No description",
image: getRelativeUrl(url, image),
};
};
Loading

0 comments on commit 2bbcc6f

Please # to comment.