Skip to content
Draft
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
1 change: 1 addition & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ jobs:
- run: pnpm install --frozen-lockfile
- run: pnpm lint
- run: pnpm typecheck
- run: pnpm test
- run:
name: Build export service
environment:
Expand Down
19 changes: 12 additions & 7 deletions MAINTENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,16 +39,19 @@ Reference PRs: [PR #3286](https://github.com/nusmodifications/nusmods/pull/3286)
## CPEx

### Before CPEx Testing
- [ ] Update `ACADEMIC_YEAR` in `scrapers/cpex-scraper/src/index.ts`. It should be for the **next** semester
- [ ] Create PR and merge to production
- [ ] Run scraper

- [ ] Update `ACADEMIC_YEAR` in `scrapers/cpex-scraper/src/index.ts`. It should be for the **next** semester
- [ ] Create PR and merge to production
- [ ] Run scraper

### During CPEx Testing

For `cpex-staging` deployment
- [ ] Update `MPE_SEMESTER` in `website/src/views/mpe/constants.ts` to be the semester you're configuring CPEx for (usually the next semester)
- [ ] Update dates in the ModReg schedule in `website/src/data/modreg-schedule.json`
- [ ] Enable the `enableCPExforProd` and `showCPExTab` flags in `website/src/featureFlags.ts`
- [ ] Push onto `cpex-staging` branch (Ensure synced with `master` branch first), then visit https://cpex-staging.nusmods.com/cpex and verify that NUS authentication is working

- [ ] Update `MPE_SEMESTER` in `website/src/views/mpe/constants.ts` to be the semester you're configuring CPEx for (usually the next semester)
- [ ] Update dates in the ModReg schedule in `website/src/data/modreg-schedule.json`
- [ ] Enable the `enableCPExforProd` and `showCPExTab` flags in `website/src/featureFlags.ts`
- [ ] Push onto `cpex-staging` branch (Ensure synced with `master` branch first), then visit https://cpex-staging.nusmods.com/cpex and verify that NUS authentication is working

```bash
git checkout master
Expand All @@ -59,10 +62,12 @@ git push
```

### During CPEx

- [ ] Merge `cpex-staging` into `master` via PR
- [ ] Deploy latest `master` to `production`

### After CPEx

- [ ] Disable the `enableCPExforProd` and `showCPExTab` flags in `website/src/featureFlags.ts`
- [ ] Merge into `master`
- [ ] Deploy latest `master` to `production`
2 changes: 2 additions & 0 deletions export/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.vercel
test-output
25 changes: 4 additions & 21 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,17 +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.

## Testing

After running locally, you can test with the following URLs on `localhost:3000`:

- [Example image export](http://localhost:3000/api/export/image?data=%7B%22semester%22%3A2%2C%22timetable%22%3A%7B%22CS3217%22%3A%7B%22Lecture%22%3A%5B0%2C3%5D%2C%22Tutorial%22%3A%5B1%5D%7D%2C%22CS1010%22%3A%7B%22Tutorial%22%3A%5B4%5D%2C%22Sectional%20Teaching%22%3A%5B3%5D%7D%2C%22CS1231S%22%3A%7B%22Tutorial%22%3A%5B2%5D%2C%22Lecture%22%3A%5B6%2C7%5D%7D%2C%22CS4218%22%3A%7B%22Lecture%22%3A%5B0%5D%2C%22Laboratory%22%3A%5B4%5D%7D%7D%2C%22colors%22%3A%7B%22CS3217%22%3A4%2C%22CS1010%22%3A6%2C%22CS1231S%22%3A5%2C%22CS4218%22%3A0%7D%2C%22hidden%22%3A%5B%5D%2C%22ta%22%3A%5B%5D%2C%22theme%22%3A%7B%22id%22%3A%22mocha%22%2C%22timetableOrientation%22%3A%22HORIZONTAL%22%2C%22showTitle%22%3Afalse%2C%22_persist%22%3A%7B%22version%22%3A-1%2C%22rehydrated%22%3Atrue%7D%7D%2C%22settings%22%3A%7B%22colorScheme%22%3A%22LIGHT_COLOR_SCHEME%22%7D%7D&pixelRatio=2)
- [Example PDF export](http://localhost:3000/api/export/pdf?data=%7B%22semester%22%3A2%2C%22timetable%22%3A%7B%22CS3217%22%3A%7B%22Lecture%22%3A%5B0%2C3%5D%2C%22Tutorial%22%3A%5B1%5D%7D%2C%22CS1010%22%3A%7B%22Tutorial%22%3A%5B4%5D%2C%22Sectional%20Teaching%22%3A%5B3%5D%7D%2C%22CS1231S%22%3A%7B%22Tutorial%22%3A%5B2%5D%2C%22Lecture%22%3A%5B6%2C7%5D%7D%2C%22CS4218%22%3A%7B%22Lecture%22%3A%5B0%5D%2C%22Laboratory%22%3A%5B4%5D%7D%7D%2C%22colors%22%3A%7B%22CS3217%22%3A4%2C%22CS1010%22%3A6%2C%22CS1231S%22%3A5%2C%22CS4218%22%3A0%7D%2C%22hidden%22%3A%5B%5D%2C%22ta%22%3A%5B%5D%2C%22theme%22%3A%7B%22id%22%3A%22mocha%22%2C%22timetableOrientation%22%3A%22HORIZONTAL%22%2C%22showTitle%22%3Afalse%2C%22_persist%22%3A%7B%22version%22%3A-1%2C%22rehydrated%22%3Atrue%7D%7D%2C%22settings%22%3A%7B%22colorScheme%22%3A%22LIGHT_COLOR_SCHEME%22%7D%7D&pixelRatio=2)

## Deployment

The export service is deployed on Vercel for production.
Expand Down Expand Up @@ -108,5 +93,3 @@ $ pnpm build
# Start the app
$ 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
20 changes: 14 additions & 6 deletions export/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,20 @@
"start": "node -r dotenv/config ./build/src/index.js",
"build": "tsc",
"nodemon": "nodemon --no-update-notifier -r dotenv/config ./build/src/index.js",
"test": "vitest run",
"test:watch": "vitest",
"watch": "tsc --watch",
"dev": "run-p nodemon watch",
"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": "^143.0.4",
"axios": "0.30.0",
"bunyan": "1.8.15",
"fs-extra": "9.1.0",
Expand All @@ -31,8 +35,10 @@
"koa-views": "6.3.1",
"lodash": "4.17.23",
"nodemon": "2.0.22",
"pdfkit": "^0.17.2",
"pug": "3.0.3",
"puppeteer-core": "^24.38.0"
"react": "^19.0.0",
"satori": "^0.25.0"
},
"devDependencies": {
"@nkzw/eslint-plugin": "^2.0.0",
Expand All @@ -44,15 +50,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": "^19.0.0",
"@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",
"typescript": "5.9.3"
"typescript": "5.9.3",
"vitest": "^4.0.18"
}
}
47 changes: 15 additions & 32 deletions export/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,53 +5,37 @@ import serve from 'koa-static';
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('/api/export/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),
};
.get('/image', async (ctx) => {
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('/api/export/pdf', async (ctx) => {
const { data, page } = ctx.state;
.get('/pdf', async (ctx) => {
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 @@ -92,7 +76,6 @@ app
.use(serve(path.join(__dirname, '..', '..', 'public')))
.use(errorHandler)
.use(data.parseExportData)
.use(render.openPage)
.use(router.routes())
.use(router.allowedMethods());

Expand Down
33 changes: 0 additions & 33 deletions export/src/chrome-executable.ts

This file was deleted.

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

// 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
Loading