From 456d6647185063ca37321fe9399c73d424006dff Mon Sep 17 00:00:00 2001 From: Ramon Date: Thu, 16 Jul 2026 19:01:25 +0200 Subject: [PATCH] perf: compressie, cache-headers, cache-busting en parallel ladende scripts (v0.3.81-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - @fastify/compress (brotli quality 4 + gzip, global): alle statische assets én API-JSON (zoals /me/data) gaan gecomprimeerd over de lijn - eerste lading van ~715 KB naar grofweg een derde - Nieuw src/frontend.js: index.html wordt bij boot één keer gelezen, ?v=__V__ wordt vervangen door de app-versie en de pagina wordt uit het geheugen geserveerd met no-cache - Cache-beleid per prefix: js/css een jaar immutable (veilig dankzij de geversioneerde URL's), fonts 7 dagen, afbeeldingen 1 dag met zwakke etag als 304-vangnet - herhaalbezoek kost vrijwel geen bytes meer - Alle 31 scripts staan nu met defer in de head: parallel downloaden, uitvoeren in dezelfde dwingende volgorde (core → widgets → board → app); font-preload bewust ongeversioneerd (moet byte-gelijk blijven aan de url() in teach.css) - Compress geregistreerd vóór de api/frontend-registraties (onSend-hook geldt alleen voor latere routes) - geborgd met een teksttest - 6 nieuwe tests (test/frontend.test.js): versie-injectie, cache-headers per prefix, gzip+brotli round-trip, 304 op etag, defer/?v= op elk script Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF --- VERSION | 2 +- package-lock.json | 400 ++++++++++++++++++++++++++++++++++++++++++ package.json | 1 + public/index.html | 73 ++++---- public/js/core.js | 2 +- src/frontend.js | 44 +++++ src/server.js | 24 ++- test/frontend.test.js | 109 ++++++++++++ 8 files changed, 615 insertions(+), 40 deletions(-) create mode 100644 src/frontend.js create mode 100644 test/frontend.test.js diff --git a/VERSION b/VERSION index cc81902..b58b3df 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.80-beta +0.3.81-beta diff --git a/package-lock.json b/package-lock.json index 9424e41..8b5a7b8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "teach", "version": "0.1.0", "dependencies": { + "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.1.1", "@fastify/helmet": "^13.1.0", "@fastify/rate-limit": "^11.1.0", @@ -56,6 +57,48 @@ "fast-uri": "^3.0.0" } }, + "node_modules/@fastify/compress": { + "version": "8.3.1", + "resolved": "https://registry.npmjs.org/@fastify/compress/-/compress-8.3.1.tgz", + "integrity": "sha512-BUpItLr6MUX9e9ukg5Y6xekyA/7pBFG8QWtFCrUDm9ctoBc3R2/nA16yOaOWtVoccpXGjdDEYA/MxAb5+8cxag==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT", + "dependencies": { + "@fastify/accept-negotiator": "^2.0.0", + "fastify-plugin": "^5.0.0", + "mime-db": "^1.52.0", + "minipass": "^7.0.4", + "peek-stream": "^1.1.3", + "pump": "^3.0.0", + "pumpify": "^2.0.1", + "readable-stream": "^4.5.2" + } + }, + "node_modules/@fastify/compress/node_modules/fastify-plugin": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz", + "integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/@fastify/cookie": { "version": "11.1.1", "resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.1.1.tgz", @@ -283,6 +326,18 @@ "integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==", "license": "MIT" }, + "node_modules/abort-controller": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", + "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "license": "MIT", + "dependencies": { + "event-target-shim": "^5.0.0" + }, + "engines": { + "node": ">=6.5" + } + }, "node_modules/abstract-logging": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz", @@ -360,6 +415,26 @@ "node": "18 || 20 || >=22" } }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/brace-expansion": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", @@ -372,6 +447,36 @@ "node": "18 || 20 || >=22" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", + "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", + "license": "MIT" + }, "node_modules/content-disposition": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-2.0.1.tgz", @@ -398,6 +503,12 @@ "url": "https://opencollective.com/express" } }, + "node_modules/core-util-is": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==", + "license": "MIT" + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -416,12 +527,81 @@ "node": ">=6" } }, + "node_modules/duplexify": { + "version": "3.7.1", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-3.7.1.tgz", + "integrity": "sha512-07z8uv2wMyS51kKhD1KsdXJg5WQ6t93RneqRxUHnskXVtlYYkLqM0gqStQZ3pj073g687jPCHrqNfCzawLYh5g==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.0.0", + "stream-shift": "^1.0.0" + } + }, + "node_modules/duplexify/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/duplexify/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/duplexify/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, + "node_modules/end-of-stream": { + "version": "1.4.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz", + "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==", + "license": "MIT", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/event-target-shim": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", + "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/fast-decode-uri-component": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz", @@ -604,6 +784,26 @@ "url": "https://opencollective.com/express" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -619,6 +819,12 @@ "node": ">= 10" } }, + "node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/json-schema-ref-resolver": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz", @@ -702,6 +908,15 @@ "node": ">=10.0.0" } }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -735,6 +950,15 @@ "node": ">=14.0.0" } }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/path-scurry": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz", @@ -751,6 +975,17 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/peek-stream": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/peek-stream/-/peek-stream-1.1.3.tgz", + "integrity": "sha512-FhJ+YbOSBb9/rIl2ZeE/QHEsWn7PqNYt8ARAY3kIgNGOk13g9FGyIY6JIl/xB/3TFRVoTv5as0l11weORrTekA==", + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "duplexify": "^3.5.0", + "through2": "^2.0.3" + } + }, "node_modules/pg": { "version": "8.22.0", "resolved": "https://registry.npmjs.org/pg/-/pg-8.22.0.tgz", @@ -916,6 +1151,21 @@ "node": ">=0.10.0" } }, + "node_modules/process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==", + "license": "MIT", + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/process-nextick-args": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", + "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==", + "license": "MIT" + }, "node_modules/process-warning": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", @@ -932,12 +1182,75 @@ ], "license": "MIT" }, + "node_modules/pump": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", + "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, + "node_modules/pumpify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pumpify/-/pumpify-2.0.1.tgz", + "integrity": "sha512-m7KOje7jZxrmutanlkS1daj1dS6z6BgslzOXmcSEpIlCxM3VJH7lG5QLeck/6hgF6F4crFf01UtQmNsJfweTAw==", + "license": "MIT", + "dependencies": { + "duplexify": "^4.1.1", + "inherits": "^2.0.3", + "pump": "^3.0.0" + } + }, + "node_modules/pumpify/node_modules/duplexify": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", + "integrity": "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA==", + "license": "MIT", + "dependencies": { + "end-of-stream": "^1.4.1", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1", + "stream-shift": "^1.0.2" + } + }, + "node_modules/pumpify/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/quick-format-unescaped": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==", "license": "MIT" }, + "node_modules/readable-stream": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz", + "integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==", + "license": "MIT", + "dependencies": { + "abort-controller": "^3.0.0", + "buffer": "^6.0.3", + "events": "^3.3.0", + "process": "^0.11.10", + "string_decoder": "^1.3.0" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + } + }, "node_modules/real-require": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", @@ -981,6 +1294,26 @@ "integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==", "license": "MIT" }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/safe-regex2": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz", @@ -1079,6 +1412,21 @@ "node": ">= 0.8" } }, + "node_modules/stream-shift": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/stream-shift/-/stream-shift-1.0.3.tgz", + "integrity": "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ==", + "license": "MIT" + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/thread-stream": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.2.0.tgz", @@ -1097,6 +1445,46 @@ "integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==", "license": "MIT" }, + "node_modules/through2": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.5.tgz", + "integrity": "sha512-/mrRod8xqpA+IHSLyGCQ2s8SPHiCDEeQJSep1jqLYeEUClOFG2Qsh+4FU6G9VeqpZnGW/Su8LQGc4YKni5rYSQ==", + "license": "MIT", + "dependencies": { + "readable-stream": "~2.3.6", + "xtend": "~4.0.1" + } + }, + "node_modules/through2/node_modules/readable-stream": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz", + "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==", + "license": "MIT", + "dependencies": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "node_modules/through2/node_modules/safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "license": "MIT" + }, + "node_modules/through2/node_modules/string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.1.0" + } + }, "node_modules/toad-cache": { "version": "3.7.4", "resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.4.tgz", @@ -1115,6 +1503,18 @@ "node": ">=0.6" } }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 4bfb185..7b984ea 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "test": "node --test" }, "dependencies": { + "@fastify/compress": "^8.3.1", "@fastify/cookie": "^11.1.1", "@fastify/helmet": "^13.1.0", "@fastify/rate-limit": "^11.1.0", diff --git a/public/index.html b/public/index.html index b62e2f3..5b3aae4 100644 --- a/public/index.html +++ b/public/index.html @@ -4,10 +4,46 @@ teachus.today - + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -260,36 +296,5 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/js/core.js b/public/js/core.js index 432c1ce..565ba61 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.80-beta"; +const VERSION = "0.3.81-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; diff --git a/src/frontend.js b/src/frontend.js new file mode 100644 index 0000000..6260365 --- /dev/null +++ b/src/frontend.js @@ -0,0 +1,44 @@ +// teach - statische frontend met cache-beleid en cache-busting. +// +// index.html wordt bij boot één keer gelezen; elke __V__-placeholder in de +// asset-URL's (?v=__V__) wordt vervangen door de app-versie en de pagina wordt +// daarna uit het geheugen geserveerd met "no-cache" (altijd hervalideren). +// De js/css-bestanden zelf dragen daardoor een geversioneerde URL en mogen +// een jaar lang "immutable" gecachet worden: een nieuwe release geeft nieuwe +// URL's, herhaalbezoek binnen dezelfde release kost vrijwel geen bytes. +// Fonts en afbeeldingen worden zonder versie geladen (CSS url()/JS-paden) en +// krijgen dus een kortere maxAge mét de zwakke etag als 304-vangnet. +import { readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import fastifyStatic from '@fastify/static'; + +export function resolveAppVersion(env = process.env) { + const v = env.APP_VERSION; + // zonder echte versie (lokaal ontwikkelen) nooit een stale cache riskeren + return v && v !== 'dev' ? v : `dev-${Date.now()}`; +} + +export async function registerFrontend(app, { root, version }) { + const html = (await readFile(join(root, 'index.html'), 'utf8')) + .replaceAll('__V__', encodeURIComponent(version)); + const sendIndex = (_req, reply) => reply + .type('text/html; charset=utf-8') + .header('cache-control', 'no-cache') + .send(html); + app.get('/', sendIndex); + app.get('/index.html', sendIndex); + + await app.register(fastifyStatic, { + root, + index: false, // index.html gaat via de in-memory route hierboven + setHeaders(reply, path) { + if (/[\\/](js|css)[\\/]/.test(path)) { + reply.header('cache-control', 'public, max-age=31536000, immutable'); + } else if (/[\\/]fonts[\\/]/.test(path)) { + reply.header('cache-control', 'public, max-age=604800'); + } else { + reply.header('cache-control', 'public, max-age=86400'); + } + }, + }); +} diff --git a/src/server.js b/src/server.js index 67be936..5eda97f 100644 --- a/src/server.js +++ b/src/server.js @@ -1,13 +1,15 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; +import zlib from 'node:zlib'; import Fastify from 'fastify'; -import fastifyStatic from '@fastify/static'; import fastifyCookie from '@fastify/cookie'; +import fastifyCompress from '@fastify/compress'; import fastifyHelmet from '@fastify/helmet'; import fastifyRateLimit from '@fastify/rate-limit'; import pg from 'pg'; import api, { bootstrapSuper } from './api.js'; import imageApi from './images.js'; +import { registerFrontend, resolveAppVersion } from './frontend.js'; import { runMigrations } from './migrate.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -78,16 +80,30 @@ app.addHook('onSend', async (_req, reply) => { reply.header('Permissions-Policy', 'camera=(), microphone=(), geolocation=(), payment=(), usb=(), fullscreen=(self)'); }); +// --- Compressie --------------------------------------------------------------- +// Vóór de API- en frontend-registraties: de onSend-hook van compress geldt +// alleen voor routes die daarna worden toegevoegd. Brotli op quality 4: +// vergelijkbare ratio als gzip-9 tegen een fractie van de CPU (belangrijk voor +// grote dynamische antwoorden zoals /me/data). +await app.register(fastifyCompress, { + global: true, + encodings: ['br', 'gzip'], + threshold: 1024, + brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } }, + zlibOptions: { level: 6 }, +}); + // --- API --------------------------------------------------------------------- // Inloggen, rollen, gebruikersbeheer en per-gebruiker data. Zie src/api.js. app.register(api, { prefix: '/api' }); app.register(imageApi, { prefix: '/api' }); // --- Static frontend --------------------------------------------------------- -// Serveert public/index.html (de digibord-app) op / -app.register(fastifyStatic, { +// index.html met versie-injectie (cache-busting) + cache-headers per prefix; +// zie src/frontend.js voor het beleid. +await registerFrontend(app, { root: join(__dirname, '..', 'public'), - index: ['index.html'], + version: resolveAppVersion(), }); // --- Start ------------------------------------------------------------------- diff --git a/test/frontend.test.js b/test/frontend.test.js new file mode 100644 index 0000000..a9ca698 --- /dev/null +++ b/test/frontend.test.js @@ -0,0 +1,109 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import zlib from 'node:zlib'; +import { readFile } from 'node:fs/promises'; +import { fileURLToPath } from 'node:url'; +import { dirname, join } from 'node:path'; +import Fastify from 'fastify'; +import fastifyCompress from '@fastify/compress'; +import { registerFrontend, resolveAppVersion } from '../src/frontend.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const publicRoot = join(__dirname, '..', 'public'); + +// Zelfde volgorde als src/server.js: compress vóór de frontend-registratie. +async function makeApp(version = '1.2.3-test') { + const app = Fastify(); + await app.register(fastifyCompress, { + global: true, + encodings: ['br', 'gzip'], + threshold: 1024, + brotliOptions: { params: { [zlib.constants.BROTLI_PARAM_QUALITY]: 4 } }, + zlibOptions: { level: 6 }, + }); + await registerFrontend(app, { root: publicRoot, version }); + await app.ready(); + return app; +} + +test('index.html wordt geserveerd met versie-injectie en no-cache', async () => { + const app = await makeApp(); + const res = await app.inject({ url: '/' }); + assert.equal(res.statusCode, 200); + assert.match(res.headers['content-type'], /text\/html/); + assert.equal(res.headers['cache-control'], 'no-cache'); + assert.ok(res.payload.includes('?v=1.2.3-test'), 'versie hoort geïnjecteerd te zijn'); + assert.ok(!res.payload.includes('__V__'), 'geen placeholder meer in de uitvoer'); + const alias = await app.inject({ url: '/index.html' }); + assert.equal(alias.headers['cache-control'], 'no-cache'); + await app.close(); +}); + +test('js en css zijn een jaar immutable; fonts en img korter zonder immutable', async () => { + const app = await makeApp(); + for (const url of ['/js/core.js', '/css/teach.css']) { + const res = await app.inject({ url }); + assert.equal(res.statusCode, 200, url); + assert.equal(res.headers['cache-control'], 'public, max-age=31536000, immutable', url); + } + const font = await app.inject({ url: '/fonts/quicksand.woff2' }); + assert.equal(font.headers['cache-control'], 'public, max-age=604800'); + assert.ok(font.headers.etag, 'etag als 304-vangnet voor ongeversioneerde assets'); + const img = await app.inject({ url: '/img/logo.svg' }); + assert.equal(img.headers['cache-control'], 'public, max-age=86400'); + await app.close(); +}); + +test('assets gaan gecomprimeerd over de lijn (gzip én brotli)', async () => { + const app = await makeApp(); + const gz = await app.inject({ url: '/css/teach.css', headers: { 'accept-encoding': 'gzip' } }); + assert.equal(gz.headers['content-encoding'], 'gzip'); + assert.match(gz.headers.vary ?? '', /accept-encoding/i); + const cssRaw = zlib.gunzipSync(gz.rawPayload).toString('utf8'); + assert.ok(cssRaw.includes('toolbar'), 'gzip-inhoud is geldige css'); + const br = await app.inject({ url: '/js/core.js', headers: { 'accept-encoding': 'br' } }); + assert.equal(br.headers['content-encoding'], 'br'); + const jsRaw = zlib.brotliDecompressSync(br.rawPayload).toString('utf8'); + assert.ok(jsRaw.includes('const VERSION'), 'brotli-inhoud is geldige js'); + assert.ok(br.rawPayload.length < jsRaw.length / 2, 'compressie levert echt winst op'); + await app.close(); +}); + +test('conditional GET levert 304 zonder body', async () => { + const app = await makeApp(); + const first = await app.inject({ url: '/fonts/quicksand.woff2' }); + const res = await app.inject({ url: '/fonts/quicksand.woff2', headers: { 'if-none-match': first.headers.etag } }); + assert.equal(res.statusCode, 304); + assert.equal(res.rawPayload.length, 0); + await app.close(); +}); + +test('resolveAppVersion geeft de echte versie door en valt lokaal nooit stale terug', () => { + assert.equal(resolveAppVersion({ APP_VERSION: '0.4.0-beta' }), '0.4.0-beta'); + const a = resolveAppVersion({}); + const b = resolveAppVersion({ APP_VERSION: 'dev' }); + assert.match(a, /^dev-\d+$/); + assert.match(b, /^dev-\d+$/); +}); + +test('index.html: elk script heeft defer + cache-busting, font-preload blijft ongeversioneerd', async () => { + const [html, server] = await Promise.all([ + readFile(join(publicRoot, 'index.html'), 'utf8'), + readFile(join(__dirname, '..', 'src', 'server.js'), 'utf8'), + ]); + const scripts = [...html.matchAll(/