@stamhoofd/backend-renderer 2.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,6 @@
1
+ {
2
+ "environment": "development",
3
+ "PORT": 9093,
4
+ "CACHE_PATH": "/etc/stamhoofd/renderer/cache",
5
+ "INTERNAL_SECRET_KEY": ""
6
+ }
package/.eslintrc.js ADDED
@@ -0,0 +1,61 @@
1
+ module.exports = {
2
+ root: true,
3
+ ignorePatterns: ["dist/", "node_modules/"],
4
+ parserOptions: {
5
+ "ecmaVersion": 2017
6
+ },
7
+ env: {
8
+ "es6": true,
9
+ "node": true,
10
+ },
11
+ extends: [
12
+ "eslint:recommended",
13
+ ],
14
+ plugins: [],
15
+ rules: {
16
+ "no-console": "off",
17
+ "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",
18
+ "sort-imports": "off",
19
+ "import/order": "off"
20
+ },
21
+ overrides: [
22
+ {
23
+ // Rules for TypeScript and vue
24
+ files: ["*.ts"],
25
+ parser: "@typescript-eslint/parser",
26
+ parserOptions: {
27
+ project: ["./tsconfig.json"]
28
+ },
29
+ plugins: ["@typescript-eslint", "jest"],
30
+ extends: [
31
+ "eslint:recommended",
32
+ "plugin:@typescript-eslint/eslint-recommended",
33
+ "plugin:@typescript-eslint/recommended",
34
+ "plugin:@typescript-eslint/recommended-requiring-type-checking",
35
+ "plugin:jest/recommended",
36
+ ],
37
+ rules: {
38
+ "no-console": "off",
39
+ "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off",
40
+ "sort-imports": "off",
41
+ "import/order": "off",
42
+ "@typescript-eslint/explicit-function-return-type": "off",
43
+ "@typescript-eslint/no-explicit-any": "off",
44
+ "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
45
+ "@typescript-eslint/no-namespace": "off",
46
+ "@typescript-eslint/no-floating-promises": "error",
47
+ "@typescript-eslint/no-misused-promises": "error",
48
+ "@typescript-eslint/prefer-for-of": "warn",
49
+ "@typescript-eslint/no-empty-interface": "off", // It is convenient to have placeholder interfaces
50
+ "@typescript-eslint/no-this-alias": "off", // No idea why we need this. This breaks code that is just fine. Prohibit the use of function() instead of this rule
51
+ "@typescript-eslint/unbound-method": "off", // Methods are automatically bound in vue, it would break removeEventListeners if we bound it every time unless we save every method in variables again...
52
+ "@typescript-eslint/no-unsafe-assignment": "off", // This is impossible to use with dependencies that don't have types yet, such as tiptap
53
+ "@typescript-eslint/no-unsafe-return": "off", // This is impossible to use with dependencies that don't have types yet, such as tiptap
54
+ "@typescript-eslint/no-unsafe-call": "off", // This is impossible to use with dependencies that don't have types yet, such as tiptap
55
+ "@typescript-eslint/no-unsafe-member-access": "off", // This is impossible to use with dependencies that don't have types yet, such as tiptap
56
+ "@typescript-eslint/restrict-plus-operands": "off", // bullshit one
57
+ "@typescript-eslint/explicit-module-boundary-types": "off",
58
+ },
59
+ }
60
+ ]
61
+ };
package/README.md ADDED
@@ -0,0 +1 @@
1
+ # Stamhoofd Renderer service
package/index.ts ADDED
@@ -0,0 +1,87 @@
1
+ require('@stamhoofd/backend-env').load({service: 'renderer'});
2
+ import { CORSPreflightEndpoint, Router, RouterServer } from "@simonbackx/simple-endpoints";
3
+ import { I18n } from "@stamhoofd/backend-i18n";
4
+ import { CORSMiddleware, LogMiddleware } from "@stamhoofd/backend-middleware";
5
+ import { loadLogger } from "@stamhoofd/logging";
6
+
7
+ process.on("unhandledRejection", (error: Error) => {
8
+ console.error("unhandledRejection");
9
+ console.error(error.message, error.stack);
10
+ process.exit(1);
11
+ });
12
+
13
+ // Set timezone!
14
+ process.env.TZ = "UTC";
15
+
16
+ // Quick check
17
+ if (new Date().getTimezoneOffset() != 0) {
18
+ throw new Error("Process should always run in UTC timezone");
19
+ }
20
+
21
+ const start = async () => {
22
+ console.log('Started Renderer.')
23
+ loadLogger();
24
+ await I18n.load()
25
+ const router = new Router();
26
+ await router.loadEndpoints(__dirname + "/src/endpoints");
27
+ router.endpoints.push(new CORSPreflightEndpoint())
28
+
29
+ const routerServer = new RouterServer(router);
30
+ routerServer.verbose = false
31
+
32
+ // Send the app version along
33
+ routerServer.addRequestMiddleware(LogMiddleware)
34
+ routerServer.addResponseMiddleware(LogMiddleware)
35
+
36
+ // Add CORS headers
37
+ routerServer.addResponseMiddleware(CORSMiddleware)
38
+
39
+ routerServer.listen(STAMHOOFD.PORT ?? 9090);
40
+
41
+ if (routerServer.server) {
42
+ // Default timeout is a bit too short
43
+ routerServer.server.timeout = 15000;
44
+ }
45
+
46
+ const shutdown = async () => {
47
+ console.log("Shutting down...")
48
+ // Disable keep alive
49
+ routerServer.defaultHeaders = Object.assign(routerServer.defaultHeaders, { 'Connection': 'close' })
50
+ if (routerServer.server) {
51
+ routerServer.server.headersTimeout = 5000;
52
+ routerServer.server.keepAliveTimeout = 1;
53
+ }
54
+
55
+ try {
56
+ await routerServer.close()
57
+ console.log("HTTP server stopped");
58
+ } catch (err) {
59
+ console.error("Failed to stop HTTP server:");
60
+ console.error(err);
61
+ }
62
+
63
+ // Should not be needed, but added for security as sometimes a promise hangs somewhere
64
+ process.exit(0);
65
+ };
66
+
67
+ process.on("SIGTERM", () => {
68
+ console.info("SIGTERM signal received.");
69
+ shutdown().catch((e) => {
70
+ console.error(e)
71
+ process.exit(1);
72
+ });
73
+ });
74
+
75
+ process.on("SIGINT", () => {
76
+ console.info("SIGINT signal received.");
77
+ shutdown().catch((e) => {
78
+ console.error(e)
79
+ process.exit(1);
80
+ });
81
+ });
82
+ };
83
+
84
+ start().catch(error => {
85
+ console.error("unhandledRejection", error);
86
+ process.exit(1);
87
+ });
package/package.json ADDED
@@ -0,0 +1,34 @@
1
+ {
2
+ "name": "@stamhoofd/backend-renderer",
3
+ "version": "2.1.1",
4
+ "main": "./dist/index.js",
5
+ "exports": {
6
+ ".": {
7
+ "require": "./dist/index.js"
8
+ }
9
+ },
10
+ "license": "UNLICENCED",
11
+ "scripts": {
12
+ "dev": "concurrently -r 'rm -rf ./dist && wait-on ./dist/index.js && nodemon --quiet --inspect=5860 --watch dist --exec node --enable-source-maps ./dist/index.js --signal SIGTERM' 'yarn build --watch --preserveWatchOutput > /dev/null'",
13
+ "build": "tsc -b",
14
+ "build:full": "yarn clear && yarn build",
15
+ "clear": "rm -rf ./dist",
16
+ "start": "yarn build && node --enable-source-maps ./dist/index.js",
17
+ "lint": "eslint . --ext .js,.jsx,.ts,.tsx"
18
+ },
19
+ "devDependencies": {
20
+ "@types/formidable": "3.4.5",
21
+ "@types/luxon": "^2.0.8",
22
+ "@types/mysql": "^2.15.20",
23
+ "@types/node": "^18.11.17"
24
+ },
25
+ "dependencies": {
26
+ "@simonbackx/simple-endpoints": "1.13.0",
27
+ "@simonbackx/simple-logging": "^1.0.1",
28
+ "formidable": "3.5.1",
29
+ "luxon": "^2.2.0",
30
+ "mockdate": "^3.0.2",
31
+ "mysql": "^2.18.1",
32
+ "puppeteer": "22.12.0"
33
+ }
34
+ }
@@ -0,0 +1,214 @@
1
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
2
+ import { SimpleError } from "@simonbackx/simple-errors";
3
+ import { verifyInternalSignature } from "@stamhoofd/backend-env";
4
+ import { QueueHandler } from "@stamhoofd/queues";
5
+ import formidable from 'formidable';
6
+ import { firstValues } from 'formidable/src/helpers/firstValues.js';
7
+ import { promises as fs } from "fs";
8
+ import puppeteer, { Browser } from "puppeteer";
9
+
10
+ import { FileCache } from "../helpers/FileCache";
11
+
12
+ type Params = Record<string, never>;
13
+ type Body = undefined;
14
+ type Query = undefined
15
+ type ResponseBody = Buffer
16
+
17
+ /**
18
+ * One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
19
+ */
20
+
21
+ export class HtmlToPdfEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
22
+ protected doesMatch(request: Request): [true, Params] | [false] {
23
+ if (request.method != "POST") {
24
+ return [false];
25
+ }
26
+
27
+ const params = Endpoint.parseParameters(request.url, "/html-to-pdf", {});
28
+
29
+ if (params) {
30
+ return [true, params as Params];
31
+ }
32
+ return [false];
33
+ }
34
+
35
+ async handle(request: DecodedRequest<Params, Query, Body>) {
36
+ const form = formidable({
37
+ maxTotalFileSize: 20 * 1024 * 1024,
38
+ keepExtensions: false,
39
+ maxFiles: 1
40
+ });
41
+
42
+ const {html, cacheId, timestamp, signature} = await new Promise<{html: string, cacheId: string, timestamp: Date, signature: string}>((resolve, reject) => {
43
+ if (!request.request.request) {
44
+ reject(new SimpleError({
45
+ code: "invalid_request",
46
+ message: "Invalid request",
47
+ statusCode: 500
48
+ }));
49
+ return;
50
+ }
51
+
52
+ // eslint-disable-next-line @typescript-eslint/no-misused-promises
53
+ form.parse(request.request.request, async (err, fieldsMultiple, files) => {
54
+ if (err) {
55
+ reject(err);
56
+ return;
57
+ }
58
+ if (!files.html || files.html.length !== 1) {
59
+ reject(new SimpleError({
60
+ code: "missing_field",
61
+ message: "Field html is required",
62
+ field: "html"
63
+ }))
64
+ return
65
+ }
66
+ const fields = firstValues(form, fieldsMultiple);
67
+
68
+ if (!fields.signature || typeof fields.signature !== "string") {
69
+ reject(new SimpleError({
70
+ code: "missing_field",
71
+ message: "Field signature is required",
72
+ field: "signature"
73
+ }))
74
+ return
75
+ }
76
+ if (!fields.cacheId || typeof fields.cacheId !== "string") {
77
+ reject(new SimpleError({
78
+ code: "missing_field",
79
+ message: "Field cacheId is required",
80
+ field: "cacheId"
81
+ }))
82
+ return
83
+ }
84
+ if (!fields.timestamp || typeof fields.timestamp !== "string") {
85
+ reject(new SimpleError({
86
+ code: "missing_field",
87
+ message: "Field timestamp is required",
88
+ field: "timestamp"
89
+ }))
90
+ return
91
+ }
92
+
93
+ let html;
94
+ try {
95
+ html = await fs.readFile(files.html[0].filepath , 'utf8')
96
+ } catch (e) {
97
+ reject(new SimpleError({
98
+ code: "invalid_field",
99
+ message: "Could not read html",
100
+ field: "html"
101
+ }))
102
+ return
103
+ }
104
+
105
+ resolve({
106
+ html,
107
+ signature: fields.signature,
108
+ cacheId: fields.cacheId,
109
+ timestamp: new Date(parseInt(fields.timestamp as string))
110
+ })
111
+ return
112
+ });
113
+ });
114
+
115
+ // Verify signature first
116
+ if (!verifyInternalSignature(signature, cacheId, timestamp.getTime().toString(), html)) {
117
+ throw new SimpleError({
118
+ code: "invalid_signature",
119
+ message: "Invalid signature"
120
+ })
121
+ }
122
+
123
+ let pdf: Buffer | null = null
124
+ try {
125
+ pdf = await this.htmlToPdf(html, {retryCount: 2, startDate: new Date()})
126
+ } catch (e) {
127
+ console.error(e)
128
+ }
129
+ if (!pdf) {
130
+ throw new SimpleError({
131
+ code: "internal_error",
132
+ message: "Could not generate pdf"
133
+ })
134
+ }
135
+ await FileCache.write(cacheId, timestamp, pdf)
136
+ const response = new Response(pdf)
137
+ response.headers["Content-Type"] = "application/pdf"
138
+ response.headers["Content-Length"] = pdf.byteLength.toString()
139
+ return response;
140
+ }
141
+
142
+ browsers: ({browser: Browser, count: number}|null)[] = [null, null, null, null]
143
+ nextBrowserIndex = 0
144
+
145
+ async useBrowser<T>(callback: (browser: Browser) => Promise<T>): Promise<T> {
146
+ this.nextBrowserIndex++;
147
+ if (this.nextBrowserIndex >= this.browsers.length) {
148
+ this.nextBrowserIndex = 0;
149
+ }
150
+ return await QueueHandler.schedule("getBrowser" + this.nextBrowserIndex, async () => {
151
+ if (!this.browsers[this.nextBrowserIndex]) {
152
+ this.browsers[this.nextBrowserIndex] = { browser: await puppeteer.launch({ pipe: true }), count: 0 }
153
+ }
154
+ const browser = this.browsers[this.nextBrowserIndex]!
155
+ if (browser.count > 50 || !browser.browser.isConnected()) {
156
+ try {
157
+ await browser.browser.close();
158
+ } catch (e) {
159
+ console.error(e)
160
+ }
161
+ this.browsers[this.nextBrowserIndex] = { browser: await puppeteer.launch({ pipe: true }), count: 0 }
162
+ }
163
+
164
+ return await callback(browser.browser)
165
+ });
166
+ }
167
+
168
+ async clearBrowser(browser: Browser) {
169
+ try {
170
+ await browser.close();
171
+ } catch (e) {
172
+ console.error(e)
173
+ }
174
+ const i = this.browsers.findIndex(b => b?.browser === browser)
175
+ if (i >= 0) {
176
+ this.browsers[i] = null
177
+ }
178
+ }
179
+
180
+ /**
181
+ * This will move to a different external service
182
+ */
183
+ async htmlToPdf(html: string, options: {retryCount: number, startDate: Date}): Promise<Buffer | null> {
184
+ const response = await this.useBrowser(async (browser) => {
185
+ try {
186
+ // Create a new page
187
+ const page = await browser.newPage();
188
+ await page.setJavaScriptEnabled(false);
189
+ await page.emulateMediaType('screen');
190
+ await page.setContent(html, { waitUntil: 'load' })
191
+
192
+ // Downlaod the PDF
193
+ const pdf = await page.pdf({
194
+ // path: directory + this.id + '.pdf',
195
+ margin: { top: '50px', right: '50px', bottom: '50px', left: '50px' },
196
+ printBackground: true,
197
+ format: 'A4',
198
+ preferCSSPageSize: true,
199
+ displayHeaderFooter: false
200
+ });
201
+ await page.close();
202
+ return pdf;
203
+ } catch (e) {
204
+ console.error('Failed to render document pdf', e)
205
+ return null;
206
+ }
207
+ })
208
+ if (response == null && options.retryCount > 0 && new Date().getTime() - options.startDate.getTime() < 15000) {
209
+ // Retry
210
+ return await this.htmlToPdf(html, {...options, retryCount: options.retryCount - 1})
211
+ }
212
+ return response;
213
+ }
214
+ }
@@ -0,0 +1,52 @@
1
+ import { AutoEncoder, DateDecoder, Decoder, field, StringDecoder } from "@simonbackx/simple-encoding";
2
+ import { DecodedRequest, Endpoint, Request, Response } from "@simonbackx/simple-endpoints";
3
+ import { SimpleError } from "@simonbackx/simple-errors";
4
+
5
+ import { FileCache } from "../helpers/FileCache";
6
+
7
+ type Params = Record<string, never>;
8
+ class Query extends AutoEncoder {
9
+ @field({ decoder: StringDecoder })
10
+ cacheId: string
11
+
12
+ @field({ decoder: DateDecoder })
13
+ timestamp: Date
14
+ }
15
+ type Body = undefined
16
+ type ResponseBody = Buffer
17
+
18
+ /**
19
+ * One endpoint to create, patch and delete groups. Usefull because on organization setup, we need to create multiple groups at once. Also, sometimes we need to link values and update multiple groups at once
20
+ */
21
+
22
+ export class PdfCacheEndpoint extends Endpoint<Params, Query, Body, ResponseBody> {
23
+ queryDecoder = Query as Decoder<Query>
24
+
25
+ protected doesMatch(request: Request): [true, Params] | [false] {
26
+ if (request.method != "GET") {
27
+ return [false];
28
+ }
29
+
30
+ const params = Endpoint.parseParameters(request.url, "/pdf-cache", {});
31
+
32
+ if (params) {
33
+ return [true, params as Params];
34
+ }
35
+ return [false];
36
+ }
37
+
38
+ async handle(request: DecodedRequest<Params, Query, Body>) {
39
+ const pdf = await FileCache.read(request.query.cacheId, request.query.timestamp)
40
+ if (!pdf) {
41
+ throw new SimpleError({
42
+ code: "cache_not_found",
43
+ message: "Not cached",
44
+ statusCode: 404
45
+ })
46
+ }
47
+ const response = new Response(pdf)
48
+ response.headers["Content-Type"] = "application/pdf"
49
+ response.headers["Content-Length"] = pdf.byteLength.toString()
50
+ return response;
51
+ }
52
+ }
@@ -0,0 +1,67 @@
1
+ import { SimpleError } from '@simonbackx/simple-errors';
2
+ import { promises as fs } from 'fs';
3
+
4
+ export class FileCache {
5
+ static async write(cacheId: string, timestamp: Date, data: Buffer) {
6
+ if (cacheId.includes("/")) {
7
+ throw new SimpleError({
8
+ code: "invalid_field",
9
+ message: "Invalid cache id",
10
+ field: "cacheId"
11
+ })
12
+ }
13
+
14
+ const folder = STAMHOOFD.CACHE_PATH + "/" + cacheId;
15
+ await fs.mkdir(folder, { recursive: true })
16
+
17
+ // Emtpy folder
18
+ const files = await fs.readdir(folder);
19
+ for (const file of files) {
20
+ const fileTimestamp = parseInt(file.substring(0, file.length - 4));
21
+ if (fileTimestamp <= timestamp.getTime()) {
22
+ await fs.unlink(folder + "/" + file);
23
+ }
24
+ }
25
+
26
+ const path = folder + "/" + timestamp.getTime() + ".pdf";
27
+ await fs.writeFile(path, data);
28
+ }
29
+
30
+ static async read(cacheId: string, timestamp: Date): Promise<Buffer | null> {
31
+ if (cacheId.includes("/")) {
32
+ throw new SimpleError({
33
+ code: "invalid_field",
34
+ message: "Invalid cache id",
35
+ field: "cacheId"
36
+ })
37
+ }
38
+
39
+ const folder = STAMHOOFD.CACHE_PATH + "/" + cacheId;
40
+ const path = folder + "/" + timestamp.getTime() + ".pdf";
41
+ try {
42
+ const data = await fs.readFile(path)
43
+ return data;
44
+ } catch {
45
+ // ignore
46
+ }
47
+
48
+ try {
49
+ const files = await fs.readdir(folder);
50
+ for (const file of files) {
51
+ const fileTimestamp = parseInt(file.substring(0, file.length - 4));
52
+ if (fileTimestamp >= timestamp.getTime()) {
53
+ try {
54
+ const data = await fs.readFile(folder + "/" + file)
55
+ return data;
56
+ } catch {
57
+ // ignore
58
+ }
59
+ }
60
+ }
61
+ } catch {
62
+ // ignore
63
+ }
64
+
65
+ return null;
66
+ }
67
+ }
package/stamhoofd.d.ts ADDED
@@ -0,0 +1,15 @@
1
+
2
+ export {};
3
+
4
+ /**
5
+ * Stamhoofd uses a global variable to store some configurations. We don't use process.env because we can only store
6
+ * strings into those files. And we need objects for our localized domains (different domains for each locale).
7
+ * Having to encode and decode those values would be inefficient.
8
+ *
9
+ * So we use our own global configuration variable: STAMHOOFD. Available everywhere and contains
10
+ * other information depending on the environment (frontend/backend/shared). TypeScript will
11
+ * always suggest the possible keys.
12
+ */
13
+ declare global {
14
+ const STAMHOOFD: RendererEnvironment
15
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,35 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "es2019", // needs to be es2019 to support optional chaining. Node.js doesn't support optional chaining yet, so we need the transpiling
4
+ "module": "commonjs",
5
+ "jsx": "preserve",
6
+ "importHelpers": true,
7
+ "moduleResolution": "node",
8
+ "experimentalDecorators": true,
9
+ "esModuleInterop": true,
10
+ "allowSyntheticDefaultImports": true,
11
+ "sourceMap": true,
12
+ "strictNullChecks": true,
13
+ "declaration": true,
14
+ "declarationMap": true,
15
+ "outDir": "dist",
16
+ "lib": [
17
+ "es2019",
18
+ "ES2021.String",
19
+ "dom" // for puppeteer
20
+ ],
21
+ "types": [
22
+ "node",
23
+ "jest",
24
+ "@stamhoofd/backend-i18n",
25
+ ]
26
+ },
27
+ "include": [
28
+ "**/*.ts",
29
+ "../../../*.d.ts"
30
+ ],
31
+ "exclude": [
32
+ "node_modules",
33
+ "dist"
34
+ ]
35
+ }