Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Prerender User & Concept pages with Vercel Prerender Functions #2645

Closed
wants to merge 11 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion app/components/concept/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ export default class ConceptComponent extends Component<Signature> {
super(owner, args);

// Temporary hack to allow for deep linking to a specific block group. (Only for admins)
const urlParams = new URLSearchParams(window.location.search);
const urlParams = new URLSearchParams(window?.location?.search || '');
const bgiQueryParam = urlParams.get('bgi');

if (bgiQueryParam) {
Expand Down
1 change: 1 addition & 0 deletions app/config/environment.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ declare const config: {
helpscoutBeaconId: string;
isCI: boolean;
metaTagImagesBaseURL: string;
metaTagUserProfilePictureBaseURL: string;
stripePublishableKey: string;
version: string;
};
Expand Down
56 changes: 50 additions & 6 deletions app/routes/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ import type RouterService from '@ember/routing/router-service';
import { inject as service } from '@ember/service';
import type UserModel from 'codecrafters-frontend/models/user';
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
import type MetaDataService from 'codecrafters-frontend/services/meta-data';
import BaseRoute from 'codecrafters-frontend/utils/base-route';
import RouteInfoMetadata, { HelpscoutBeaconVisibility } from 'codecrafters-frontend/utils/route-info-metadata';
import config from 'codecrafters-frontend/config/environment';

export type ModelType = UserModel | undefined;

Expand All @@ -13,23 +15,65 @@ export default class UserRoute extends BaseRoute {
@service declare authenticator: AuthenticatorService;
@service declare router: RouterService;
@service declare store: Store;
@service declare metaData: MetaDataService;

previousMetaImageUrl: string | undefined;
previousMetaTitle: string | undefined;
previousMetaDescription: string | undefined;

afterModel(model: ModelType): void {
if (!model) {
this.router.transitionTo('not-found');

return;
}

this.previousMetaImageUrl = this.metaData.imageUrl;
this.previousMetaTitle = this.metaData.title;
this.previousMetaDescription = this.metaData.description;

this.metaData.imageUrl = `${config.x.metaTagUserProfilePictureBaseURL}${model.username}`;
this.metaData.title = `${model.username}'s CodeCrafters Profile`;
this.metaData.description = `View ${model.username}'s profile on CodeCrafters`;
}

buildRouteInfoMetadata() {
return new RouteInfoMetadata({ beaconVisibility: HelpscoutBeaconVisibility.Hidden });
}

async model(params: { username: string }): Promise<ModelType> {
const users = (await this.store.query('user', {
username: params.username,
include: 'course-participations.language,course-participations.course.stages,course-participations.current-stage,profile-events',
})) as unknown as UserModel[];
deactivate() {
this.metaData.imageUrl = this.previousMetaImageUrl;
this.metaData.title = this.previousMetaTitle;
this.metaData.description = this.previousMetaDescription;
}

async #fetchUserRecord(username: string) {
return (
(await this.store.query('user', {
username,
include: 'course-participations.language,course-participations.course.stages,course-participations.current-stage,profile-events',
})) as unknown as UserModel[]
)[0];
}

#findCachedUserRecord(username: string) {
return this.store.peekAll('user').find((u) => u.username === username);
}

async model({ username }: { username: string }): Promise<ModelType> {
// Look up the record in the store, in case it's already there,
// for example, inserted via FastBoot Shoebox
const existingRecord = this.#findCachedUserRecord(username);

if (existingRecord) {
// Trigger a fetch anyway to refresh the data
this.#fetchUserRecord(username);

// Return existing record, otherwise the page will blink after loading
return existingRecord;
}

return users[0];
// If the record doesn't exist - fetch it and return
return this.#fetchUserRecord(username);
}
}
2 changes: 2 additions & 0 deletions app/templates/user.hbs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
{{page-title (concat this.model.username "'s Profile")}}

{{! Looks like a tailwind 3.3.0 bug, from-0% and to-100% are required to make the gradient work }}
<div class="bg-gradient-to-b from-0% to-100% from-gray-600 to-gray-800 h-56 w-full">
</div>
Expand Down
1 change: 1 addition & 0 deletions config/environment.js
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ module.exports = function (environment) {
helpscoutBeaconId: process.env.HELPSCOUT_BEACON_ID || 'bb089ae9-a4ae-4114-8f7a-b660f6310158',
isCI: false, // Overridden in test environment
metaTagImagesBaseURL: 'https://codecrafters.io/images/app_og/',
metaTagUserProfilePictureBaseURL: 'https://og.codecrafters.io/api/user_profile/',
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
vercelAnalyticsId: process.env.VERCEL_ANALYTICS_ID,

Expand Down
1 change: 1 addition & 0 deletions config/fastboot.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ module.exports = function (/* environment */) {
buildSandboxGlobals(defaultGlobals) {
return Object.assign({}, defaultGlobals, {
AbortController,
URLSearchParams,
});
},
};
Expand Down
82 changes: 18 additions & 64 deletions middleware.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
/**
* This is a Vercel Middleware, which:
* - is triggered for all `/users/:username` routes
* - extracts `username` from the URL
* - generates a proper Profile OG Image URL
* - is triggered for contest routes
* - extracts contest slug from the URL
* - determines a proper OG Image URL and other meta tags
* - reads the contents of `dist/_empty.html`
* - replaces OG meta tags with user-profile specific ones: <meta property="og:image" content="...">
* - replaces OG meta tags with correct ones
* - serves the result as an HTML response
* - passes request down the stack and returns if unable to extract `username`
* - passes request down the stack and returns if unable to extract parameters
*
* Related Docs:
* - https://vercel.com/docs/functions/edge-middleware
Expand All @@ -19,9 +19,9 @@ import { next } from '@vercel/edge';
import { replaceAllMetaTags } from './app/utils/replace-meta-tag';

export const config = {
// Limit the middleware to run only for user profile and concept routes
// Limit the middleware to run only for contest routes
// RegExp syntax uses rules from pillarjs/path-to-regexp
matcher: ['/users/:path*', '/concepts/:path*', '/contests/:path*'],
matcher: ['/contests/:path*'],
};

const contestDetailsMap = {
Expand Down Expand Up @@ -56,73 +56,27 @@ function getContestDetails(slug) {
}

export default async function middleware(request) {
// Parse the users or concepts path match result from the request URL
const usersPathMatchResult = request.url.match(/\/users\/([^/?]+)/);
const conceptsPathMatchResult = request.url.match(/\/concepts\/([^/?]+)/);
// Parse the contest path match result from the request URL
const contestsPathMatchResult = request.url.match(/\/contests\/([^/?]+)/);

// Skip the request if username or concept slug is missing
if (!usersPathMatchResult && !conceptsPathMatchResult && !contestsPathMatchResult) {
// Skip the request if contest slug is missing
if (!contestsPathMatchResult) {
// Log an error to the console
console.error('Unable to parse username or concept slug from the URL:', request.url);
console.error('Unable to parse contest slug from the URL:', request.url);

// Pass the request down the stack for processing and return
return next(request);
}

let pageImageUrl;
let pageTitle;
let pageDescription;
const contestSlug = contestsPathMatchResult[1];

if (usersPathMatchResult) {
const username = usersPathMatchResult[1];
// Fetch contest details from the hashmap
const contestDetails = getContestDetails(contestSlug);

// Generate a proper OG Image URL for the username's Profile
pageImageUrl = `https://og.codecrafters.io/api/user_profile/${username}`;
pageTitle = `${username}'s CodeCrafters Profile`;
pageDescription = `View ${username}'s profile on CodeCrafters`;
} else if (conceptsPathMatchResult) {
const conceptSlug = conceptsPathMatchResult[1];

// Convert the slug('network-protocols') to title('Network Protocols')
// Use this as a fallback
let conceptTitle = conceptSlug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');

// Use this as a fallback
let conceptDescription = `View the ${conceptTitle} concept on CodeCrafters`;

// Get concept data from the backend
let conceptData;

try {
conceptData = await fetch(`https://backend.codecrafters.io/services/dynamic_og_images/concept_data?id_or_slug=${conceptSlug}`).then((res) =>
res.json(),
);

conceptTitle = conceptData.title;
conceptDescription = conceptData.description_markdown;
} catch (e) {
console.error(e);
console.log('ignoring error for now');
}

// Generate a proper OG Image URL for the concept
pageImageUrl = `https://og.codecrafters.io/api/concept/${conceptSlug}`;
pageTitle = conceptTitle;
pageDescription = conceptDescription;
} else if (contestsPathMatchResult) {
const contestSlug = contestsPathMatchResult[1];

// Fetch contest details from the hashmap
const contestDetails = getContestDetails(contestSlug);

pageImageUrl = contestDetails.imageUrl;
pageTitle = contestDetails.title;
pageDescription = contestDetails.description;
}
// Override OG tag values for the contest
const pageImageUrl = contestDetails.imageUrl;
const pageTitle = contestDetails.title;
const pageDescription = contestDetails.description;

// Determine URL for reading local `/dist/_empty.html`
const indexFileURL = new URL('./dist/_empty.html', import.meta.url);
Expand Down
6 changes: 5 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@
"scripts": {
"tailwind:build": "npx tailwindcss -i ./app/tailwind-input.css -o ./app/styles/tailwind-compiled.css",
"tailwind:watch": "npx tailwindcss -i ./app/tailwind-input.css -o ./app/styles/tailwind-compiled.css --watch",
"build": "npm --version && npm run tailwind:build && ember build --environment=production",
"build": "npm run build:versions && npm run tailwind:build && npm run build:ember",
"build:versions": "echo \"npm: $(npm --version)\" && ember --version",
"build:ember": "ember build --environment=production",
"build:vercel:dist": "./scripts/build-vercel-dist.sh",
"build:vercel:functions": "./scripts/build-vercel-functions.sh",
"lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"",
"lint:css": "stylelint \"**/*.css\"",
"lint:css:fix": "concurrently \"npm:lint:css -- --fix\"",
Expand Down
21 changes: 21 additions & 0 deletions scripts/build-vercel-dist.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#! /bin/bash

LOG_FILENAME="build-output.log"
ERROR_STRING="error"

# Run `npm run build` and capture the output into `build-output.log`
npm run build > >(tee -a "${LOG_FILENAME}") 2> >(tee -a "${LOG_FILENAME}" >&2)
if [ $? -ne 0 ]; then
echo "Running npm run build failed"
exit 1
fi

# If there are errors in build output log - exit with error status code
if cat "${LOG_FILENAME}" | grep -qi "${ERROR_STRING}"; then
echo "Errors found in the build output"
exit 1
fi

# Exit with success status code
echo "No errors found in the build output"
exit 0
29 changes: 29 additions & 0 deletions scripts/build-vercel-functions.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
#!/bin/bash

VERCEL_FUNCTIONS_SOURCE="vercel-functions"
VERCEL_FUNCTIONS_DESTINATION="/vercel/output/functions"
VERCEL_FUNCTION_GLOB="*.func"

DIST_FOLDER_SOURCE="dist"
DIST_FOLDER_DESTINATION="dist"

REPLACE_META_TAG_UTIL_PATH="app/utils/replace-meta-tag.js"

# Copy vercel functions to output directory
echo "Copying vercel functions to ${VERCEL_FUNCTIONS_DESTINATION}"
cp -a "${VERCEL_FUNCTIONS_SOURCE}/" "${VERCEL_FUNCTIONS_DESTINATION}/" || exit 1

# Run post-copying tasks for each function
find "${VERCEL_FUNCTIONS_DESTINATION}" -type d -name "${VERCEL_FUNCTION_GLOB}" | while read -r FUNCTION_DIR; do
echo "Copying dist folder to ${FUNCTION_DIR}"
cp -a "${DIST_FOLDER_SOURCE}/" "${FUNCTION_DIR}/${DIST_FOLDER_DESTINATION}/" || exit 1

echo "Copying replace-meta-tag.js to ${FUNCTION_DIR}"
cp -a "${REPLACE_META_TAG_UTIL_PATH}" "${FUNCTION_DIR}/" || exit 1

echo "Running npm install in ${FUNCTION_DIR}"
(cd "${FUNCTION_DIR}" && npm install --no-fund --no-audit) || exit 1
done

# Exit with success status code
exit 0
2 changes: 2 additions & 0 deletions vercel-functions/prerender-concept-page.func/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
/node_modules
/dist
9 changes: 9 additions & 0 deletions vercel-functions/prerender-concept-page.func/.vc-config.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"handler": "index.js",
"runtime": "nodejs22.x",
"memory": 3009,
"maxDuration": 15,
"launcherType": "Nodejs",
"shouldAddHelpers": true,
"shouldAddSourcemapSupport": true
}
71 changes: 71 additions & 0 deletions vercel-functions/prerender-concept-page.func/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import FastBoot from 'fastboot';
import { replaceAllMetaTags } from './replace-meta-tag.js';

export default async function (request, response) {
const { conceptSlug } = request.query;

// Check if conceptSlug query parameter is provided
if (!conceptSlug) {
console.error('Missing "conceptSlug" query parameter');
response.redirect('/404');

return;
}

// Initialize a FastBoot instance
const app = new FastBoot({
distPath: 'dist',
resilient: false,

// Customize the sandbox globals
buildSandboxGlobals(defaultGlobals) {
return Object.assign({}, defaultGlobals, {
AbortController,
URLSearchParams,
});
},

maxSandboxQueueSize: 1,
});

// Visit the concept page
const result = await app.visit(`/concepts/${conceptSlug}`);
const statusCode = result._fastbootInfo.response.statusCode;

// Redirect to 404 page if the status code is not 200
if (statusCode !== 200) {
console.warn('Error parsing FastBoot response, statusCode was:', statusCode);
response.redirect('/404');

return;
}

// Get the HTML content of the FastBoot response
const html = await result['html']();

// Define meta tag values
let pageTitle;
let pageDescription;
const pageImageUrl = `https://og.codecrafters.io/api/concept/${conceptSlug}`; // TODO: use `metaTagConceptImageBaseURL` from config

// Get concept data from the backend
try {
const conceptDataRaw = await fetch(`https://backend.codecrafters.io/services/dynamic_og_images/concept_data?id_or_slug=${conceptSlug}`);
const conceptData = await conceptDataRaw.json();
pageTitle = conceptData.title;
pageDescription = conceptData.description_markdown;
} catch (e) {
console.error('Failed to fetch concept data:', e);
pageTitle = conceptSlug
.split('-')
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
pageDescription = `View the ${pageTitle} concept on CodeCrafters`;
}

// Replace meta tags in the HTML content
const responseText = replaceAllMetaTags(html, pageTitle, pageDescription, pageImageUrl);

// Send the modified HTML content as the response
response.send(responseText);
}
Loading
Loading