Skip to content

Commit 8ceaf2e

Browse files
committed
[WIP] Prerender user pages with Vercel prerender functions
1 parent f08576e commit 8ceaf2e

15 files changed

+1023
-19
lines changed

.gitignore

+4
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
# dependencies
99
/node_modules/
1010

11+
# dependencies & dist inside vercel-functions
12+
/vercel-functions/**/node_modules/
13+
/vercel-functions/**/dist/
14+
1115
# misc
1216
/.env
1317
/.pnp*

app/config/environment.d.ts

+1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare const config: {
1414
helpscoutBeaconId: string;
1515
isCI: boolean;
1616
metaTagImagesBaseURL: string;
17+
metaTagUserProfilePictureBaseURL: string;
1718
stripePublishableKey: string;
1819
version: string;
1920
};

app/routes/user.ts

+50-6
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ import type RouterService from '@ember/routing/router-service';
33
import { inject as service } from '@ember/service';
44
import type UserModel from 'codecrafters-frontend/models/user';
55
import type AuthenticatorService from 'codecrafters-frontend/services/authenticator';
6+
import type MetaDataService from 'codecrafters-frontend/services/meta-data';
67
import BaseRoute from 'codecrafters-frontend/utils/base-route';
78
import RouteInfoMetadata, { HelpscoutBeaconVisibility } from 'codecrafters-frontend/utils/route-info-metadata';
9+
import config from 'codecrafters-frontend/config/environment';
810

911
export type ModelType = UserModel | undefined;
1012

@@ -13,23 +15,65 @@ export default class UserRoute extends BaseRoute {
1315
@service declare authenticator: AuthenticatorService;
1416
@service declare router: RouterService;
1517
@service declare store: Store;
18+
@service declare metaData: MetaDataService;
19+
20+
previousMetaImageUrl: string | undefined;
21+
previousMetaTitle: string | undefined;
22+
previousMetaDescription: string | undefined;
1623

1724
afterModel(model: ModelType): void {
1825
if (!model) {
1926
this.router.transitionTo('not-found');
27+
28+
return;
2029
}
30+
31+
this.previousMetaImageUrl = this.metaData.imageUrl;
32+
this.previousMetaTitle = this.metaData.title;
33+
this.previousMetaDescription = this.metaData.description;
34+
35+
this.metaData.imageUrl = `${config.x.metaTagUserProfilePictureBaseURL}${model.username}`;
36+
this.metaData.title = `${model.username}'s CodeCrafters Profile`;
37+
this.metaData.description = `View ${model.username}'s profile on CodeCrafters`;
2138
}
2239

2340
buildRouteInfoMetadata() {
2441
return new RouteInfoMetadata({ beaconVisibility: HelpscoutBeaconVisibility.Hidden });
2542
}
2643

27-
async model(params: { username: string }): Promise<ModelType> {
28-
const users = (await this.store.query('user', {
29-
username: params.username,
30-
include: 'course-participations.language,course-participations.course.stages,course-participations.current-stage,profile-events',
31-
})) as unknown as UserModel[];
44+
deactivate() {
45+
this.metaData.imageUrl = this.previousMetaImageUrl;
46+
this.metaData.title = this.previousMetaTitle;
47+
this.metaData.description = this.previousMetaDescription;
48+
}
49+
50+
async #fetchUserRecord(username: string) {
51+
return (
52+
(await this.store.query('user', {
53+
username,
54+
include: 'course-participations.language,course-participations.course.stages,course-participations.current-stage,profile-events',
55+
})) as unknown as UserModel[]
56+
)[0];
57+
}
58+
59+
#findCachedUserRecord(username: string) {
60+
return this.store.peekAll('user').find((u) => u.username === username);
61+
}
62+
63+
async model({ username }: { username: string }): Promise<ModelType> {
64+
// Look up the record in the store, in case it's already there,
65+
// for example, inserted via FastBoot Shoebox
66+
const existingRecord = this.#findCachedUserRecord(username);
67+
68+
if (existingRecord) {
69+
// Trigger a fetch anyway to refresh the data
70+
this.#fetchUserRecord(username);
71+
72+
// Return existing record, otherwise the page will blink after loading
73+
return existingRecord;
74+
}
3275

33-
return users[0];
76+
// If the record doesn't exist - fetch it and return
77+
return this.#fetchUserRecord(username);
3478
}
3579
}

app/templates/user.hbs

+2
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
{{page-title (concat this.model.username "'s Profile")}}
2+
13
{{! Looks like a tailwind 3.3.0 bug, from-0% and to-100% are required to make the gradient work }}
24
<div class="bg-gradient-to-b from-0% to-100% from-gray-600 to-gray-800 h-56 w-full">
35
</div>

config/environment.js

+1
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ module.exports = function (environment) {
4444
helpscoutBeaconId: process.env.HELPSCOUT_BEACON_ID || 'bb089ae9-a4ae-4114-8f7a-b660f6310158',
4545
isCI: false, // Overridden in test environment
4646
metaTagImagesBaseURL: 'https://codecrafters.io/images/app_og/',
47+
metaTagUserProfilePictureBaseURL: 'https://og.codecrafters.io/api/user_profile/',
4748
stripePublishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
4849
vercelAnalyticsId: process.env.VERCEL_ANALYTICS_ID,
4950

middleware.js

+3-11
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { replaceAllMetaTags } from './app/utils/replace-meta-tag';
2121
export const config = {
2222
// Limit the middleware to run only for user profile and concept routes
2323
// RegExp syntax uses rules from pillarjs/path-to-regexp
24-
matcher: ['/users/:path*', '/concepts/:path*', '/contests/:path*'],
24+
matcher: ['/concepts/:path*', '/contests/:path*'],
2525
};
2626

2727
const contestDetailsMap = {
@@ -57,12 +57,11 @@ function getContestDetails(slug) {
5757

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

6463
// Skip the request if username or concept slug is missing
65-
if (!usersPathMatchResult && !conceptsPathMatchResult && !contestsPathMatchResult) {
64+
if (!conceptsPathMatchResult && !contestsPathMatchResult) {
6665
// Log an error to the console
6766
console.error('Unable to parse username or concept slug from the URL:', request.url);
6867

@@ -74,14 +73,7 @@ export default async function middleware(request) {
7473
let pageTitle;
7574
let pageDescription;
7675

77-
if (usersPathMatchResult) {
78-
const username = usersPathMatchResult[1];
79-
80-
// Generate a proper OG Image URL for the username's Profile
81-
pageImageUrl = `https://og.codecrafters.io/api/user_profile/${username}`;
82-
pageTitle = `${username}'s CodeCrafters Profile`;
83-
pageDescription = `View ${username}'s profile on CodeCrafters`;
84-
} else if (conceptsPathMatchResult) {
76+
if (conceptsPathMatchResult) {
8577
const conceptSlug = conceptsPathMatchResult[1];
8678

8779
// Convert the slug('network-protocols') to title('Network Protocols')

package.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
"scripts": {
1414
"tailwind:build": "npx tailwindcss -i ./app/tailwind-input.css -o ./app/styles/tailwind-compiled.css",
1515
"tailwind:watch": "npx tailwindcss -i ./app/tailwind-input.css -o ./app/styles/tailwind-compiled.css --watch",
16-
"build": "npm --version && npm run tailwind:build && ember build --environment=production",
16+
"build": "npm run build:versions && npm run tailwind:build && npm run build:ember",
17+
"build:versions": "echo \"npm: $(npm --version)\" && ember --version",
18+
"build:ember": "ember build --environment=production",
19+
"build:vercel:dist": "./scripts/build-vercel-dist.sh",
20+
"build:vercel:functions": "./scripts/build-vercel-functions.sh",
1721
"lint": "concurrently \"npm:lint:*(!fix)\" --names \"lint:\"",
1822
"lint:css": "stylelint \"**/*.css\"",
1923
"lint:css:fix": "concurrently \"npm:lint:css -- --fix\"",

scripts/build-vercel-dist.sh

+21
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#! /bin/bash
2+
3+
LOG_FILENAME="build-output.log"
4+
ERROR_STRING="error"
5+
6+
# Run `npm run build` and capture the output into `build-output.log`
7+
npm run build > >(tee -a "${LOG_FILENAME}") 2> >(tee -a "${LOG_FILENAME}" >&2)
8+
if [ $? -ne 0 ]; then
9+
echo "Running npm run build failed"
10+
exit 1
11+
fi
12+
13+
# If there are errors in build output log - exit with error status code
14+
if cat "${LOG_FILENAME}" | grep -qi "${ERROR_STRING}"; then
15+
echo "Errors found in the build output"
16+
exit 1
17+
fi
18+
19+
# Exit with success status code
20+
echo "No errors found in the build output"
21+
exit 0

scripts/build-vercel-functions.sh

+29
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
#!/bin/bash
2+
3+
VERCEL_FUNCTIONS_SOURCE="vercel-functions"
4+
VERCEL_FUNCTIONS_DESTINATION="/vercel/output/functions"
5+
VERCEL_FUNCTION_GLOB="*.func"
6+
7+
DIST_FOLDER_SOURCE="dist"
8+
DIST_FOLDER_DESTINATION="dist"
9+
10+
REPLACE_META_TAG_UTIL_PATH="app/utils/replace-meta-tag.js"
11+
12+
# Copy vercel functions to output directory
13+
echo "Copying vercel functions to ${VERCEL_FUNCTIONS_DESTINATION}"
14+
cp -a "${VERCEL_FUNCTIONS_SOURCE}/" "${VERCEL_FUNCTIONS_DESTINATION}/" || exit 1
15+
16+
# Run post-copying tasks for each function
17+
find "${VERCEL_FUNCTIONS_DESTINATION}" -type d -name "${VERCEL_FUNCTION_GLOB}" | while read -r FUNCTION_DIR; do
18+
echo "Copying dist folder to ${FUNCTION_DIR}"
19+
cp -a "${DIST_FOLDER_SOURCE}/" "${FUNCTION_DIR}/${DIST_FOLDER_DESTINATION}/" || exit 1
20+
21+
echo "Copying replace-meta-tag.js to ${FUNCTION_DIR}"
22+
cp -a "${REPLACE_META_TAG_UTIL_PATH}" "${FUNCTION_DIR}/" || exit 1
23+
24+
echo "Running npm install in ${FUNCTION_DIR}"
25+
(cd "${FUNCTION_DIR}" && npm install --no-fund --no-audit) || exit 1
26+
done
27+
28+
# Exit with success status code
29+
exit 0
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
{
2+
"handler": "index.js",
3+
"runtime": "nodejs22.x",
4+
"maxDuration": 30,
5+
"launcherType": "Nodejs",
6+
"shouldAddHelpers": true,
7+
"shouldAddSourcemapSupport": true
8+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import FastBoot from 'fastboot';
2+
import { replaceAllMetaTags } from './replace-meta-tag.js';
3+
4+
export default async function (request, response) {
5+
const { userName } = request.query;
6+
7+
// Check if userName query parameter is provided
8+
if (!userName) {
9+
throw new Error('missing "userName" query parameter');
10+
}
11+
12+
// Initialize a FastBoot instance
13+
const app = new FastBoot({
14+
distPath: 'dist',
15+
resilient: false,
16+
17+
// Customize the sandbox globals
18+
buildSandboxGlobals(defaultGlobals) {
19+
return Object.assign({}, defaultGlobals, {
20+
AbortController,
21+
});
22+
},
23+
24+
maxSandboxQueueSize: 1,
25+
});
26+
27+
// Visit the user profile page
28+
const result = await app.visit(`/users/${userName}`);
29+
30+
// Redirect to 404 page if the status code is not 200
31+
if (result._fastbootInfo.response.statusCode !== 200) {
32+
response.redirect('/404');
33+
34+
return;
35+
}
36+
37+
// Get the HTML content of the FastBoot response
38+
const html = await result['html'](); // Weird VSCode syntax highlighting issue if written as result.html()
39+
40+
// Define meta tag values
41+
const pageTitle = `${userName}'s CodeCrafters Profile`;
42+
const pageImageUrl = `https://og.codecrafters.io/api/user_profile/${userName}`; // TODO: Read `metaTagUserProfilePictureBaseURL` from page config
43+
const pageDescription = `View ${userName}'s profile on CodeCrafters`;
44+
45+
// Replace meta tags in the HTML content
46+
const responseText = replaceAllMetaTags(html, pageTitle, pageDescription, pageImageUrl);
47+
48+
// Send the modified HTML content as the response
49+
response.send(responseText);
50+
}

0 commit comments

Comments
 (0)