Skip to content
Draft
Show file tree
Hide file tree
Changes from 1 commit
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
1 change: 1 addition & 0 deletions export/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
.vercel
18 changes: 4 additions & 14 deletions export/README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,13 @@
# NUSMods Timetable Export Service

Uses [Puppeteer][puppeteer] to render a copy of the user's timetable on the server before taking a screenshot and sending it back to them.
Renders timetables server-side using Satori and sends them back as images or PDFs.

## Getting started

### Local development

```bash
# Install dependencies
# To skip Puppeteer installing Chromium, set PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=1
# See https://github.com/GoogleChrome/puppeteer/blob/v0.13.0/docs/api.md#environment-variables
$ pnpm install

# Configure - the defaults are sufficient for development, but for
Expand All @@ -23,8 +21,7 @@ $ cd ../website
$ pnpm start
$ pnpm start:export

# Start export server - use "pnpm devtools" if need to see the graphical browser with
# developer tools. Note that PDF export does not work in devtools mode.
# Start export server
$ cd ../export
$ pnpm dev
```
Expand All @@ -42,9 +39,8 @@ $ pnpm dev
1. The serialized state is added as query params to the 'Download as' link the user sees when they open the dropdown menu
1. When the user clicks on the link, the browser makes a GET request to the chosen export endpoint
1. The endpoint parses and validates the incoming data
1. The export service uses the Puppeteer instance to create a new `Page` object
1. The page loads a special version of the app that only has the `<TimetableContent>` component, and injects the data into the Redux store
1. The screenshot or PDF is taken of the page, and sent back to the user
1. The export service renders the timetable using Satori (SVG) and converts it to the requested format
1. The screenshot or PDF is sent back to the user

## API

Expand All @@ -69,10 +65,6 @@ Download PNG image of the timetable.
- `data` - JSON encoded timetable data
- `pixelRatio = 1` (Number: 1 to 3 inclusive) - the device pixel ratio as reported in [`window.devicePixelRatio`](https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio). This scales the entire image by that amount so it will appear correctly when downloaded to the user's device.

### GET `/debug`

Returns the HTML content of the page that Puppeteer renders.

## Deployment

The export service is deployed on Vercel for production.
Expand Down Expand Up @@ -107,5 +99,3 @@ $ pnpm deploy
# Uses port 3300 for production and 3301 for staging
$ pnpm start
```

[puppeteer]: https://github.com/GoogleChrome/puppeteer
29 changes: 6 additions & 23 deletions export/api/export/image.ts
Original file line number Diff line number Diff line change
@@ -1,43 +1,26 @@
import _ from 'lodash';

import { validateExportData } from '../../src/data';
import { makeExportHandler } from '../../src/handler';
import * as render from '../../src/render-serverless';
import { parseViewportOptions } from '../../src/image-options';
import { renderImage } from '../../src/render-image';
import type { ExportData } from '../../src/types';

type Data = {
exportData: ExportData;
options: render.ViewportOptions;
options: import('../../src/types').ViewportOptions;
};

const handler = makeExportHandler<Data>(
(request) => {
const exportData = JSON.parse(request.query.data as never);
validateExportData(exportData);

let options: render.ViewportOptions = {
pixelRatio: _.clamp(Number(request.query.pixelRatio) || 1, 1, 3),
};
const height = Number(request.query.height);
const width = Number(request.query.width);
if (
height !== undefined &&
width !== undefined &&
!Number.isNaN(height) && // accept floats
!Number.isNaN(width) && // accept floats
height > 0 &&
width > 0
) {
options = { ...options, height, width };
}

return {
exportData,
options,
options: parseViewportOptions(request.query),
};
},
async (response, page, { exportData, options }) => {
const body = await render.image(page, exportData, options);
async (response, { exportData, options }) => {
const body = await renderImage(exportData, options);
response.setHeader('Content-Disposition', 'attachment; filename="My Timetable.png"');
response.setHeader('Content-Type', 'image/png');
response.status(200).send(body);
Expand Down
6 changes: 3 additions & 3 deletions export/api/export/pdf.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { validateExportData } from '../../src/data';
import { makeExportHandler } from '../../src/handler';
import * as render from '../../src/render-serverless';
import { renderPdf } from '../../src/render-pdf';
import type { ExportData } from '../../src/types';

const handler = makeExportHandler<ExportData>(
Expand All @@ -9,8 +9,8 @@ const handler = makeExportHandler<ExportData>(
validateExportData(exportData);
return exportData;
},
async (response, page, exportData) => {
const body = await render.pdf(page, exportData);
async (response, exportData) => {
const body = await renderPdf(exportData);
response.setHeader('Content-Disposition', 'attachment; filename="My Timetable.pdf"');
response.setHeader('Content-Type', 'application/pdf');
response.status(200).send(body);
Expand Down
18 changes: 12 additions & 6 deletions export/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,19 @@
"start": "pm2 start ecosystem.config.js",
"build": "tsc",
"nodemon": "nodemon --no-update-notifier -r dotenv/config ./build/src/index.js",
"test": "tsx --test src/**/*.test.ts",
Comment thread
darentanrw marked this conversation as resolved.
Outdated
"watch": "tsc --watch",
"dev": "run-p nodemon watch",
"devtools": "cross-env DEVTOOLS=1 pnpm dev",
"check": "run-s lint typecheck",
Comment thread
darentanrw marked this conversation as resolved.
"deploy": "rsync -avu --delete-after . ../../nusmods-export && pm2 restart ecosystem.config.js",
"devtools": "cross-env DEVTOOLS=1 pnpm dev",
"lint": "oxlint -c oxlint.config.mjs src",
"typecheck": "tsc --noEmit",
"check": "run-s lint typecheck"
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@resvg/resvg-js": "^2.6.2",
"@sentry/node": "5.30.0",
"@sparticuz/chromium": "^123.0.0",
"axios": "0.30.0",
"bunyan": "1.8.15",
"fs-extra": "9.1.0",
Expand All @@ -31,8 +33,10 @@
"koa-views": "6.3.1",
"lodash": "4.17.23",
"nodemon": "2.0.22",
"pdfkit": "^0.17.2",
"pug": "3.0.3",
"puppeteer-core": "22.15.0"
"react": "18.3.1",
Comment thread
darentanrw marked this conversation as resolved.
Outdated
"satori": "^0.25.0"
},
"devDependencies": {
"@nkzw/eslint-plugin": "^2.0.0",
Expand All @@ -43,15 +47,17 @@
"@types/koa-views": "2.0.4",
"@types/lodash": "4.17.14",
"@types/node": "22.13.5",
"@types/pdfkit": "^0.17.5",
"@types/pug": "2.0.10",
"@types/react": "18.3.18",
Comment thread
darentanrw marked this conversation as resolved.
Outdated
"@vercel/node": "1.15.4",
"cross-env": "7.0.3",
"dotenv": "8.6.0",
"eslint-plugin-no-only-tests": "^3.3.0",
"eslint-plugin-perfectionist": "^5.6.0",
"eslint-plugin-unused-imports": "^4.4.1",
"npm-run-all": "4.1.5",
"oxlint": "^1.51.0",
"tsx": "^4.21.0",
Comment thread
darentanrw marked this conversation as resolved.
Outdated
"typescript": "5.9.3"
}
}
43 changes: 13 additions & 30 deletions export/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,53 +3,37 @@ import Router from 'koa-router';
import views from 'koa-views';
import * as Sentry from '@sentry/node';

import _ from 'lodash';

import * as render from './render';
import * as data from './data';
import config from './config';
import { parseViewportOptions } from './image-options';
import { renderImage } from './render-image';
import { renderPdf } from './render-pdf';
import type { State } from './types';

// Start router
const app = new Koa<State>();
const router = new Router();
const router = new Router({ prefix: '/api/export' });

router
.get('/image', async (ctx) => {
const { data, page } = ctx.state;
const { height, width } = ctx.query;

// Validate options
let options: render.ViewportOptions = {
pixelRatio: _.clamp(Number(ctx.query.pixelRatio) || 1, 1, 3),
};
const { data } = ctx.state;
const options = parseViewportOptions(ctx.query);

if (
height !== undefined &&
width !== undefined &&
!Number.isNaN(height) && // accept floats
!Number.isNaN(width) && // accept floats
Number(height) > 0 &&
Number(width) > 0
) {
options = {
...options,
height: Number(height),
width: Number(width),
};
}
ctx.body = await renderImage(data, options);

ctx.body = await render.image(page, data, options);
ctx.attachment('My Timetable.png');
})
.get('/pdf', async (ctx) => {
const { data, page } = ctx.state;
const { data } = ctx.state;

ctx.body = await renderPdf(data);

ctx.body = await render.pdf(page, data);
ctx.set('Content-Type', 'application/pdf');
ctx.attachment('My Timetable.pdf');
})
.get('/debug', async (ctx) => {
ctx.body = await ctx.state.page.content();
ctx.status = 501;
ctx.body = 'Debug HTML is unavailable as browser renderer is disabled.';
});

// Error handling
Expand Down Expand Up @@ -89,7 +73,6 @@ app
)
.use(errorHandler)
.use(data.parseExportData)
.use(render.openPage)
.use(router.routes())
.use(router.allowedMethods());

Expand Down
11 changes: 0 additions & 11 deletions export/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,6 @@ export default {
// Width of the page in pixels
pageWidth: Number(process.env.PAGE_WIDTH) || 1024,

// Path to the Chrome executable - for Puppeteer 0.13, use Chrome 64.
// If left blank this will use the version of Chromium that comes with Puppeteer
// instead
chromeExecutable: process.env.CHROME_EXECUTABLE,

// If set to a local path, the page will be loaded using setContent - use this
// for production
// If set to a URL, the page will be loaded instead - use localhost:8081 in
// development with Webpack hot reload server
page: process.env.PAGE!,

// Path to a folder containing module data. If null, during development the
// NUSMods API will be used instead. In production leaving this as null will
// throw an error.
Expand Down
6 changes: 6 additions & 0 deletions export/src/data.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,13 @@ export async function getModules(moduleCodes: Array<string>) {
return modules.filter(Boolean);
}

const EXPORT_ROUTES = /\/api\/export\/(image|pdf)$/;

export const parseExportData: Middleware<State> = (ctx, next) => {
if (EXPORT_ROUTES.test(ctx.path) && !ctx.query.data) {
ctx.throw(422, 'Missing timetable data');
}

if (ctx.query.data) {
try {
if (typeof ctx.query.data !== 'string') {
Expand Down
28 changes: 3 additions & 25 deletions export/src/handler.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,6 @@
import * as Sentry from '@sentry/node';
import type { VercelApiHandler, VercelRequest, VercelResponse } from '@vercel/node';
import type { Page } from 'puppeteer-core';

import * as render from './render-serverless';
import config from './config';
import { render422, render500 } from './views';
import { HttpError } from './HttpError';
Expand Down Expand Up @@ -39,42 +37,22 @@ function setUpSentry() {
*/
export function makeExportHandler<T>(
parseExportData: (request: VercelRequest) => T,
performExport: (response: VercelResponse, page: Page, data: T) => void | Promise<void>,
performExport: (response: VercelResponse, data: T) => void | Promise<void>,
): VercelApiHandler {
return async function handler(request, response) {
try {
throwIfAcademicYearNotSet();
setUpSentry();

// Validate input before starting the browser (which is expensive)
// Validate input before rendering
let data = undefined;
try {
data = parseExportData(request);
} catch (error) {
throw new HttpError(422, 'Invalid timetable data', error);
}

// Prepare browser for export
const url = config.page;
let page: Page;
try {
page = await render.open(url);
} catch (error) {
if (error.message.includes('ERR_CONNECTION_REFUSED')) {
throw new HttpError(
500,
`Could not open the page located at process.env.PAGE (${url}). Try opening it in your browser?`,
error,
);
}
throw new HttpError(500, 'Cannot start browser', error);
}

// Export
await performExport(response, page, data);

// Cleanup
await page.close();
await performExport(response, data);
} catch (error) {
const eventId = Sentry.captureException(error.original || error);

Expand Down
31 changes: 31 additions & 0 deletions export/src/image-options.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import _ from 'lodash';

import type { ViewportOptions } from './types';

type SizeSource = {
height?: unknown;
pixelRatio?: unknown;
width?: unknown;
};

export function parseViewportOptions(source: SizeSource): ViewportOptions {
let options: ViewportOptions = {
pixelRatio: _.clamp(Number(source.pixelRatio) || 1, 1, 3),
};

const height = Number(source.height);
const width = Number(source.width);

if (
typeof source.height !== 'undefined' &&
typeof source.width !== 'undefined' &&
!Number.isNaN(height) &&
!Number.isNaN(width) &&
height > 0 &&
width > 0
) {
options = { ...options, height, width };
}

return options;
}
Loading