diff --git a/api/hits.ts b/api/hits.ts index 213887f5..8293de2f 100644 --- a/api/hits.ts +++ b/api/hits.ts @@ -4,11 +4,29 @@ import { VercelRequest, VercelResponse } from "@vercel/node"; import { Client, query as q } from "faunadb"; import numeral from "numeral"; import pluralize from "pluralize"; +import rssParser from "rss-parser"; + +const baseUrl = "https://jarv.is/"; +type PageStats = { + title?: string; + url?: string; + date?: string; + slug: string; + hits: number; + pretty_hits: string; + pretty_unit: string; +}; +type OverallStats = { + total: { + hits: number; + pretty_hits?: string; + pretty_unit?: string; + }; + pages: PageStats[]; +}; // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types export default async (req: VercelRequest, res: VercelResponse) => { - const { slug } = req.query; - try { // some rudimentary error handling if (!process.env.FAUNADB_SERVER_SECRET) { @@ -17,40 +35,36 @@ export default async (req: VercelRequest, res: VercelResponse) => { if (req.method !== "GET") { throw new Error(`Method ${req.method} not allowed.`); } - if (!slug || slug === "/") { - throw new Error("Parameter `slug` is required."); - } const client = new Client({ secret: process.env.FAUNADB_SERVER_SECRET, }); - - type PageHits = { - slug: string; - hits: number; - pretty_hits?: string; - pretty_unit?: string; - }; - - // refer to snippet below for the `hit` function defined in the Fauna cloud + const { slug } = req.query; // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await client.query(q.Call(q.Function("hit"), slug)); + let result: any; - const hits: PageHits = { - ...result.data, - pretty_hits: numeral(result.data.hits).format("0,0"), - pretty_unit: pluralize("hit", result.data.hits), - }; + if (!slug || slug === "/") { + // return overall site stats if slug not specified + result = await getSiteStats(client); + + // let Vercel edge and browser cache results for 15 mins + res.setHeader("Cache-Control", "public, max-age=900, s-maxage=900, stale-while-revalidate"); + res.setHeader("Access-Control-Allow-Methods", "GET"); + res.setHeader("Access-Control-Allow-Origin", "*"); + } else { + // increment this page's hits + result = await incrementPageHits(slug, client); + + // disable caching on both ends + res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate"); + res.setHeader("Expires", 0); + res.setHeader("Pragma", "no-cache"); + } - // disable caching on both ends - res.setHeader("Cache-Control", "private, no-cache, no-store, must-revalidate"); - res.setHeader("Expires", 0); - res.setHeader("Pragma", "no-cache"); res.setHeader("Access-Control-Allow-Methods", "GET"); res.setHeader("Access-Control-Allow-Origin", "*"); - // send client the *new* hit count - res.status(200).json(hits); + res.status(200).json(result); } catch (error) { console.error(error); @@ -58,34 +72,86 @@ export default async (req: VercelRequest, res: VercelResponse) => { } }; -/** - -This is the FaunaDB function named `hit` defined in the cloud: -https://dashboard.fauna.com/functions/hit/@db/global/jarv.is -https://docs.fauna.com/fauna/current/api/fql/user_defined_functions - -{ - name: "hit", - role: null, - body: Query( - Lambda( - "slug", - Let( - { match: Match(Index("hits_by_slug"), Var("slug")) }, - If( - Exists(Var("match")), - Let( - { - ref: Select("ref", Get(Var("match"))), - hits: ToInteger(Select("hits", Select("data", Get(Var("match"))))) - }, - Update(Var("ref"), { data: { hits: Add(Var("hits"), 1) } }) - ), - Create(Collection("hits"), { data: { slug: Var("slug"), hits: 1 } }) - ) +const incrementPageHits = async (slug: string | string[], client: Client) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const result = await client.query( + q.Let( + { match: q.Match(q.Index("hits_by_slug"), slug) }, + q.If( + q.Exists(q.Var("match")), + q.Let( + { + ref: q.Select("ref", q.Get(q.Var("match"))), + hits: q.ToInteger(q.Select("hits", q.Select("data", q.Get(q.Var("match"))))), + }, + q.Update(q.Var("ref"), { data: { hits: q.Add(q.Var("hits"), 1) } }) + ), + q.Create(q.Collection("hits"), { data: { slug: slug, hits: 1 } }) ) ) - ) -} + ); -*/ + // add formatted hits with comma and pluralized "hit(s)", simpler to do here than in browser + const hits: PageStats = { + ...result.data, + pretty_hits: numeral(result.data.hits).format("0,0"), + pretty_unit: pluralize("hit", result.data.hits), + }; + + // send client the *new* hit count + return hits; +}; + +const getSiteStats = async (client: Client) => { + const parser = new rssParser({ + timeout: 3000, + }); + + // get database and RSS results asynchronously + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const [feed, result] = await Promise.all<{ [key: string]: any }, any>([ + parser.parseURL(baseUrl + "feed.xml"), + client.query( + q.Map( + q.Paginate(q.Documents(q.Collection("hits"))), + q.Lambda((x) => q.Select("data", q.Get(x))) + ) + ), + ]); + + const pages: PageStats[] = result.data; + const stats: OverallStats = { + total: { hits: 0 }, + pages, + }; + + pages.map((p: PageStats) => { + // match URLs from RSS feed with db to populate some metadata + const match = feed.items.find((x: { link: string }) => x.link === baseUrl + p.slug + "/"); + if (match) { + p.title = match.title; + p.url = match.link; + p.date = match.isoDate; + } + + // it's easier to add comma-separated numbers and proper pluralization here on the backend + p.pretty_hits = numeral(p.hits).format("0,0"); + p.pretty_unit = pluralize("hit", p.hits); + + // add these hits to running tally + stats.total.hits += p.hits; + + return p; + }); + + // sort by hits (descending) + stats.pages.sort((a: { hits: number }, b: { hits: number }) => { + return a.hits > b.hits ? -1 : 1; + }); + + // do same prettification as above to totals + stats.total.pretty_hits = numeral(stats.total.hits).format("0,0"); + stats.total.pretty_unit = pluralize("hit", stats.total.hits); + + return stats; +}; diff --git a/api/projects.ts b/api/projects.ts index 8c477b6e..b0986161 100644 --- a/api/projects.ts +++ b/api/projects.ts @@ -9,8 +9,54 @@ import { gql } from "graphql-tag"; const username = "jakejarvis"; const endpoint = "https://api.github.com/graphql"; +type Repository = { + name: string; + url: string; + description: string; + primaryLanguage?: { + color: string; + name: string; + }; + stargazerCount: number; + stargazerCount_pretty?: string; + forkCount: number; + forkCount_pretty?: string; + pushedAt: string; + pushedAt_relative?: string; +}; -async function fetchRepos(sort: string, limit: number) { +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export default async (req: VercelRequest, res: VercelResponse) => { + try { + // some rudimentary error handling + if (req.method !== "GET") { + throw new Error(`Method ${req.method} not allowed.`); + } + if (!process.env.GH_PUBLIC_TOKEN) { + throw new Error("GitHub API credentials aren't set."); + } + + // default to latest repos + let sortBy = "PUSHED_AT"; + // get most popular repos (/projects/?top) + if (typeof req.query.top !== "undefined") sortBy = "STARGAZERS"; + + const repos = await fetchRepos(sortBy, 16); + + // let Vercel edge and browser cache results for 15 mins + res.setHeader("Cache-Control", "public, max-age=900, s-maxage=900, stale-while-revalidate"); + res.setHeader("Access-Control-Allow-Methods", "GET"); + res.setHeader("Access-Control-Allow-Origin", "*"); + + res.status(200).json(repos); + } catch (error) { + console.error(error); + + res.status(400).json({ message: error.message }); + } +}; + +const fetchRepos = async (sort: string, limit: number): Promise => { // https://docs.github.com/en/graphql/guides/forming-calls-with-graphql const client = new GraphQLClient(endpoint, { headers: { @@ -53,22 +99,6 @@ async function fetchRepos(sort: string, limit: number) { } `; - type Repository = { - name: string; - url: string; - description: string; - primaryLanguage?: { - color: string; - name: string; - }; - stargazerCount: number; - stargazerCount_pretty?: string; - forkCount: number; - forkCount_pretty?: string; - pushedAt: string; - pushedAt_relative?: string; - }; - const response = await client.request(query, { sort, limit }); const currentRepos: Repository[] = response.user.repositories.edges.map( ({ node: repo }: { [key: string]: Repository }) => ({ @@ -81,35 +111,4 @@ async function fetchRepos(sort: string, limit: number) { ); return currentRepos; -} - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export default async (req: VercelRequest, res: VercelResponse) => { - try { - // some rudimentary error handling - if (req.method !== "GET") { - throw new Error(`Method ${req.method} not allowed.`); - } - if (!process.env.GH_PUBLIC_TOKEN) { - throw new Error("GitHub API credentials aren't set."); - } - - // default to latest repos - let sortBy = "PUSHED_AT"; - // get most popular repos (/projects/?top) - if (typeof req.query.top !== "undefined") sortBy = "STARGAZERS"; - - const repos = await fetchRepos(sortBy, 16); - - // let Vercel edge cache results for 15 mins - res.setHeader("Cache-Control", "s-maxage=900, stale-while-revalidate"); - res.setHeader("Access-Control-Allow-Methods", "GET"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - res.status(200).json(repos); - } catch (error) { - console.error(error); - - res.status(400).json({ message: error.message }); - } }; diff --git a/api/stats.ts b/api/stats.ts deleted file mode 100644 index 39960fe8..00000000 --- a/api/stats.ts +++ /dev/null @@ -1,105 +0,0 @@ -"use strict"; - -import { VercelRequest, VercelResponse } from "@vercel/node"; -import { Client, query as q } from "faunadb"; -import numeral from "numeral"; -import pluralize from "pluralize"; -import rssParser from "rss-parser"; - -const baseUrl = "https://jarv.is/"; - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export default async (req: VercelRequest, res: VercelResponse) => { - try { - // some rudimentary error handling - if (!process.env.FAUNADB_SERVER_SECRET) { - throw new Error("Database credentials aren't set."); - } - if (req.method !== "GET") { - throw new Error(`Method ${req.method} not allowed.`); - } - - const parser = new rssParser({ - timeout: 3000, - }); - const client = new Client({ - secret: process.env.FAUNADB_SERVER_SECRET, - }); - - // get database and RSS results asynchronously - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const [feed, result] = await Promise.all<{ [key: string]: any }, any>([ - parser.parseURL(baseUrl + "feed.xml"), - client.query( - q.Map( - q.Paginate(q.Documents(q.Collection("hits"))), - q.Lambda((x) => q.Select("data", q.Get(x))) - ) - ), - ]); - - type PageStats = { - title: string; - url: string; - date: string; - slug?: string; - hits: number; - pretty_hits: string; - pretty_unit: string; - }; - type OverallStats = { - total: { - hits: number; - pretty_hits?: string; - pretty_unit?: string; - }; - pages: PageStats[]; - }; - - const pages: PageStats[] = result.data; - const stats: OverallStats = { - total: { hits: 0 }, - pages, - }; - - pages.map((p: PageStats) => { - // match URLs from RSS feed with db to populate some metadata - const match = feed.items.find((x: { link: string }) => x.link === baseUrl + p.slug + "/"); - if (match) { - p.title = match.title; - p.url = match.link; - p.date = match.isoDate; - delete p.slug; - } - - // it's easier to add comma-separated numbers and proper pluralization here on the backend - p.pretty_hits = numeral(p.hits).format("0,0"); - p.pretty_unit = pluralize("hit", p.hits); - - // add these hits to running tally - stats.total.hits += p.hits; - - return p; - }); - - // sort by hits (descending) - stats.pages.sort((a: { hits: number }, b: { hits: number }) => { - return a.hits > b.hits ? -1 : 1; - }); - - // do same prettification as above to totals - stats.total.pretty_hits = numeral(stats.total.hits).format("0,0"); - stats.total.pretty_unit = pluralize("hit", stats.total.hits); - - // let Vercel edge cache results for 15 mins - res.setHeader("Cache-Control", "s-maxage=900, stale-while-revalidate"); - res.setHeader("Access-Control-Allow-Methods", "GET"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - res.status(200).json(stats); - } catch (error) { - console.error(error); - - res.status(400).json({ message: error.message }); - } -}; diff --git a/api/music.ts b/api/tracks.ts similarity index 91% rename from api/music.ts rename to api/tracks.ts index 1fb4c63f..7106a3c6 100644 --- a/api/music.ts +++ b/api/tracks.ts @@ -1,5 +1,6 @@ "use strict"; +// Fetches my Spotify most-played tracks or currently playing track. // Heavily inspired by @leerob: https://leerob.io/snippets/spotify import { VercelRequest, VercelResponse } from "@vercel/node"; @@ -32,7 +33,6 @@ type TrackSchema = { spotify: string; }; }; - type Track = { isPlaying: boolean; artist?: string; @@ -42,6 +42,43 @@ type Track = { songUrl?: string; }; +// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types +export default async (req: VercelRequest, res: VercelResponse) => { + try { + // some rudimentary error handling + if (req.method !== "GET") { + throw new Error(`Method ${req.method} not allowed.`); + } + if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET || !process.env.SPOTIFY_REFRESH_TOKEN) { + throw new Error("Spotify API credentials aren't set."); + } + + // default to top tracks + let response; + // get currently playing track (/music/?now), otherwise top 10 tracks + if (typeof req.query.now !== "undefined") { + response = await getNowPlaying(); + + // let Vercel edge and browser cache results for 5 mins + res.setHeader("Cache-Control", "public, max-age=300, s-maxage=300, stale-while-revalidate"); + } else { + response = await getTopTracks(); + + // let Vercel edge and browser cache results for 3 hours + res.setHeader("Cache-Control", "public, max-age=10800, s-maxage=10800, stale-while-revalidate"); + } + + res.setHeader("Access-Control-Allow-Methods", "GET"); + res.setHeader("Access-Control-Allow-Origin", "*"); + + res.status(200).json(response); + } catch (error) { + console.error(error); + + res.status(400).json({ message: error.message }); + } +}; + const getAccessToken = async () => { const response = await fetch(TOKEN_ENDPOINT, { method: "POST", @@ -124,40 +161,3 @@ const getTopTracks = async (): Promise => { return tracks; }; - -// eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types -export default async (req: VercelRequest, res: VercelResponse) => { - try { - // some rudimentary error handling - if (req.method !== "GET") { - throw new Error(`Method ${req.method} not allowed.`); - } - if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET || !process.env.SPOTIFY_REFRESH_TOKEN) { - throw new Error("Spotify API credentials aren't set."); - } - - // default to top tracks - let response; - // get currently playing track (/music/?now), otherwise top 10 tracks - if (typeof req.query.now !== "undefined") { - response = await getNowPlaying(); - - // let Vercel edge cache results for 5 mins - res.setHeader("Cache-Control", "public, s-maxage=300, stale-while-revalidate"); - } else { - response = await getTopTracks(); - - // let Vercel edge cache results for 3 hours - res.setHeader("Cache-Control", "public, s-maxage=10800, stale-while-revalidate"); - } - - res.setHeader("Access-Control-Allow-Methods", "GET"); - res.setHeader("Access-Control-Allow-Origin", "*"); - - res.status(200).json(response); - } catch (error) { - console.error(error); - - res.status(400).json({ message: error.message }); - } -}; diff --git a/yarn.lock b/yarn.lock index 69ee4d1f..5045b93e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2013,9 +2013,9 @@ caniuse-api@^3.0.0: lodash.uniq "^4.5.0" caniuse-lite@^1.0.0, caniuse-lite@^1.0.30001109, caniuse-lite@^1.0.30001166, caniuse-lite@^1.0.30001179, caniuse-lite@^1.0.30001219, caniuse-lite@^1.0.30001230: - version "1.0.30001236" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001236.tgz#0a80de4cdf62e1770bb46a30d884fc8d633e3958" - integrity sha512-o0PRQSrSCGJKCPZcgMzl5fUaj5xHe8qA2m4QRvnyY4e1lITqoNkr7q/Oh1NcpGSy0Th97UZ35yoKcINPoq7YOQ== + version "1.0.30001237" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001237.tgz#4b7783661515b8e7151fc6376cfd97f0e427b9e5" + integrity sha512-pDHgRndit6p1NR2GhzMbQ6CkRrp4VKuSsqbcLeOQppYPKOYkKT/6ZvZDvKJUqcmtyWIAHuZq3SVS2vc1egCZzw== caw@^2.0.0, caw@^2.0.1: version "2.0.1" @@ -6079,9 +6079,9 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.0.2, postcss-value-parser@^ integrity sha512-97DXOFbQJhk71ne5/Mt6cOu6yxsSfM0QGQyl0L25Gca4yGWEGJaig7l7gbCX623VqTBNGLRLaVUCnNkcedlRSQ== postcss@^7.0.14, postcss@^7.0.17, postcss@^7.0.2, postcss@^7.0.21, postcss@^7.0.26, postcss@^7.0.31, postcss@^7.0.32, postcss@^7.0.35, postcss@^7.0.6: - version "7.0.35" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.35.tgz#d2be00b998f7f211d8a276974079f2e92b970e24" - integrity sha512-3QT8bBJeX/S5zKTTjTCIjRF3If4avAT6kqxcASlTWEtAFCb9NH0OUxNDfgZSWdP5fJnBYCMEWkIFfWeugjzYMg== + version "7.0.36" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-7.0.36.tgz#056f8cffa939662a8f5905950c07d5285644dfcb" + integrity sha512-BebJSIUMwJHRH0HAQoxN4u1CN86glsrwsW0q7T+/m44eXOUAxSNdHRkNZPYz5vVUbg17hFgOQDE7fZk7li3pZw== dependencies: chalk "^2.4.2" source-map "^0.6.1" @@ -7379,9 +7379,9 @@ tslib@^1.8.1, tslib@^1.9.0: integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== tslib@^2.1.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.2.0.tgz#fb2c475977e35e241311ede2693cee1ec6698f5c" - integrity sha512-gS9GVHRU+RGn5KQM2rllAlR3dU6m7AcpJKdtH8gFvQiC4Otgk98XnmMU+nZenHt/+VhnBPWwgrJsyrdcw6i23w== + version "2.3.0" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.0.tgz#803b8cdab3e12ba581a4ca41c8839bbb0dacb09e" + integrity sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg== tsutils@^3.21.0: version "3.21.0"