@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.
- package/.env.template.json +6 -0
- package/.eslintrc.js +61 -0
- package/README.md +1 -0
- package/index.ts +87 -0
- package/package.json +34 -0
- package/src/endpoints/HtmlToPdfEndpoint.ts +214 -0
- package/src/endpoints/PdfCacheEndpoint.ts +52 -0
- package/src/helpers/FileCache.ts +67 -0
- package/stamhoofd.d.ts +15 -0
- package/tsconfig.json +35 -0
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
|
+
}
|