Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 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
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
30 changes: 30 additions & 0 deletions export/api/export/beta/image.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { validateExportData } from '../../../src/data';
import { makeExportHandler } from '../../../src/handler';
import { parseViewportOptions } from '../../../src/image-options';
import { renderImage } from '../../../src/render-image';
import type { ExportData } from '../../../src/types';

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

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

return {
exportData,
options: parseViewportOptions(request.query),
};
},
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);
},
);

export default handler;
20 changes: 20 additions & 0 deletions export/api/export/beta/pdf.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { validateExportData } from '../../../src/data';
import { makeExportHandler } from '../../../src/handler';
import { renderPdf } from '../../../src/render-pdf';
import type { ExportData } from '../../../src/types';

const handler = makeExportHandler<ExportData>(
(request) => {
const exportData = JSON.parse(request.query.data as never);
validateExportData(exportData);
return 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);
},
);

export default handler;
4 changes: 2 additions & 2 deletions export/api/export/image.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import _ from 'lodash';

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

Expand All @@ -10,7 +10,7 @@ type Data = {
options: render.ViewportOptions;
};

const handler = makeExportHandler<Data>(
const handler = makeBrowserExportHandler<Data>(
(request) => {
const exportData = JSON.parse(request.query.data as never);
validateExportData(exportData);
Expand Down
4 changes: 2 additions & 2 deletions export/api/export/pdf.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { validateExportData } from '../../src/data';
import { makeExportHandler } from '../../src/handler';
import { makeBrowserExportHandler } from '../../src/handler';
import * as render from '../../src/render-serverless';
import type { ExportData } from '../../src/types';

const handler = makeExportHandler<ExportData>(
const handler = makeBrowserExportHandler<ExportData>(
(request) => {
const exportData = JSON.parse(request.query.data as never);
validateExportData(exportData);
Expand Down
11 changes: 8 additions & 3 deletions export/oxlint.config.mjs
Original file line number Diff line number Diff line change
@@ -1,15 +1,20 @@
import nkzw from '@nkzw/oxlint-config';
import { defineConfig } from 'oxlint';

// Filter out react-hooks JS plugin since this is not a React project
// Filter out unsupported JS plugins for this service.
const config = { ...nkzw };
config.jsPlugins = config.jsPlugins?.filter(
(p) =>
!(typeof p === 'object' && p.name === 'react-hooks-js') && p !== 'eslint-plugin-react-hooks',
!(typeof p === 'object' && p.name === 'react-hooks-js') &&
p !== 'eslint-plugin-react-hooks' &&
p !== 'eslint-plugin-unused-imports',
);
config.rules = Object.fromEntries(
Object.entries(config.rules ?? {}).filter(
([key]) => !key.startsWith('react-hooks-js/') && !key.startsWith('react-hooks/'),
([key]) =>
!key.startsWith('react-hooks-js/') &&
!key.startsWith('react-hooks/') &&
!key.startsWith('unused-imports/'),
),
);

Expand Down
18 changes: 14 additions & 4 deletions export/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,18 @@
"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.
"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",
Expand All @@ -31,8 +35,11 @@
"koa-views": "6.3.1",
"lodash": "4.18.1",
"nodemon": "2.0.22",
"pdfkit": "^0.17.2",
"pug": "3.0.3",
"puppeteer-core": "^24.38.0"
"puppeteer-core": "^24.38.0",
"react": "18.3.1",
Comment thread
darentanrw marked this conversation as resolved.
"satori": "^0.25.0"
},
"devDependencies": {
"@nkzw/eslint-plugin": "catalog:",
Expand All @@ -44,7 +51,9 @@
"@types/koa-views": "2.0.4",
"@types/lodash": "4.17.24",
"@types/node": "catalog:",
"@types/pdfkit": "^0.17.5",
"@types/pug": "2.0.10",
"@types/react": "18.3.18",
Comment thread
darentanrw marked this conversation as resolved.
"@vercel/node": "1.15.4",
"cross-env": "catalog:",
"dotenv": "8.6.0",
Expand All @@ -53,6 +62,7 @@
"eslint-plugin-unused-imports": "catalog:",
"npm-run-all": "catalog:",
"oxlint": "catalog:",
"typescript": "catalog:"
"typescript": "catalog:",
"vitest": "catalog:"
}
}
53 changes: 25 additions & 28 deletions export/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,52 +5,50 @@ 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) => {
.get('/image', render.openPage, 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),
};

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),
};
}
const options = parseViewportOptions(ctx.query);

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

ctx.body = await render.pdf(page, data);
ctx.set('Content-Type', 'application/pdf');
ctx.attachment('My Timetable.pdf');
})
.get('/beta/image', async (ctx) => {
const { data } = ctx.state;
const options = parseViewportOptions(ctx.query);

ctx.body = await renderImage(data, options);
ctx.set('Content-Type', 'image/png');
ctx.attachment('My Timetable.png');
})
.get('/beta/pdf', async (ctx) => {
const { data } = ctx.state;

ctx.body = await renderPdf(data);

ctx.set('Content-Type', 'application/pdf');
ctx.attachment('My Timetable.pdf');
})
.get('/debug', async (ctx) => {
.get('/debug', render.openPage, async (ctx) => {
ctx.body = await ctx.state.page.content();
});

Expand Down Expand Up @@ -92,7 +90,6 @@ app
.use(serve(path.join(__dirname, '..', '..', 'public')))
.use(errorHandler)
.use(data.parseExportData)
.use(render.openPage)
.use(router.routes())
.use(router.allowedMethods());

Expand Down
2 changes: 1 addition & 1 deletion export/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ export default {
// 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!,
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
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