@camstack/server 1.2.47 → 1.2.48
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/dist/api/static/precompressed-asset.js +176 -0
- package/dist/main.js +67 -3
- package/package.json +5 -5
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Brotli-compressed copies of static SPA assets, cached on disk.
|
|
4
|
+
*
|
|
5
|
+
* The hub re-compressed the viewer's 6.7 MB bundle from disk on EVERY uncached
|
|
6
|
+
* load: measured `time_starttransfer` 0.622 s with brotli against 0.017 s
|
|
7
|
+
* without. That is 0.6 s of hub CPU before the first byte, paid again by every
|
|
8
|
+
* client, for a file whose content is content-hashed and therefore never
|
|
9
|
+
* changes.
|
|
10
|
+
*
|
|
11
|
+
* Populated lazily on the first request rather than at build time, and that is
|
|
12
|
+
* the important choice. The two SPAs reach this hub down DIFFERENT ship chains
|
|
13
|
+
* — the admin-ui through a deployed addon dist, the viewer through its own
|
|
14
|
+
* export — and a build step wired into one of them silently stops covering the
|
|
15
|
+
* other. A cache built from whatever is on disk cannot fall behind either.
|
|
16
|
+
*
|
|
17
|
+
* The first request for a given file still pays the compression, exactly as
|
|
18
|
+
* every request does today. Every request after it pays nothing.
|
|
19
|
+
*/
|
|
20
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
21
|
+
if (k2 === undefined) k2 = k;
|
|
22
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
23
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
24
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
25
|
+
}
|
|
26
|
+
Object.defineProperty(o, k2, desc);
|
|
27
|
+
}) : (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
o[k2] = m[k];
|
|
30
|
+
}));
|
|
31
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
32
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
33
|
+
}) : function(o, v) {
|
|
34
|
+
o["default"] = v;
|
|
35
|
+
});
|
|
36
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
37
|
+
var ownKeys = function(o) {
|
|
38
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
39
|
+
var ar = [];
|
|
40
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
41
|
+
return ar;
|
|
42
|
+
};
|
|
43
|
+
return ownKeys(o);
|
|
44
|
+
};
|
|
45
|
+
return function (mod) {
|
|
46
|
+
if (mod && mod.__esModule) return mod;
|
|
47
|
+
var result = {};
|
|
48
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
49
|
+
__setModuleDefault(result, mod);
|
|
50
|
+
return result;
|
|
51
|
+
};
|
|
52
|
+
})();
|
|
53
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
54
|
+
exports.acceptsBrotli = acceptsBrotli;
|
|
55
|
+
exports.isCompressibleAsset = isCompressibleAsset;
|
|
56
|
+
exports.resolvePrecompressedAsset = resolvePrecompressedAsset;
|
|
57
|
+
exports.resetPrecompressedWarning = resetPrecompressedWarning;
|
|
58
|
+
exports.precompressedSiblingExists = precompressedSiblingExists;
|
|
59
|
+
const fs = __importStar(require("node:fs"));
|
|
60
|
+
const fsp = __importStar(require("node:fs/promises"));
|
|
61
|
+
const path = __importStar(require("node:path"));
|
|
62
|
+
const node_zlib_1 = require("node:zlib");
|
|
63
|
+
const node_util_1 = require("node:util");
|
|
64
|
+
const brotli = (0, node_util_1.promisify)(node_zlib_1.brotliCompress);
|
|
65
|
+
/** Below this, the framing costs more than the compression saves — the same
|
|
66
|
+
* threshold `@fastify/compress` is registered with. */
|
|
67
|
+
const MIN_BYTES = 1024;
|
|
68
|
+
/** Types worth compressing. Images and fonts are already compressed; running
|
|
69
|
+
* brotli over a JPEG burns CPU to make it marginally bigger. */
|
|
70
|
+
const COMPRESSIBLE = /\.(js|mjs|cjs|css|html|json|svg|map|txt|webmanifest|wasm)$/i;
|
|
71
|
+
/** One in-flight compression per path, so a cold cache hit by twelve parallel
|
|
72
|
+
* asset requests compresses once instead of twelve times. */
|
|
73
|
+
const inFlight = new Map();
|
|
74
|
+
/** Logged once per process — a read-only dist is a deployment fact, not an
|
|
75
|
+
* error to repeat on every asset. */
|
|
76
|
+
let warnedUnwritable = false;
|
|
77
|
+
/** Does the client accept brotli? */
|
|
78
|
+
function acceptsBrotli(acceptEncoding) {
|
|
79
|
+
if (acceptEncoding === undefined)
|
|
80
|
+
return false;
|
|
81
|
+
return /\bbr\b/.test(acceptEncoding);
|
|
82
|
+
}
|
|
83
|
+
function isCompressibleAsset(relOrAbs) {
|
|
84
|
+
return COMPRESSIBLE.test(relOrAbs);
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* A `.br` sibling is only valid while it is NEWER than its source.
|
|
88
|
+
*
|
|
89
|
+
* A redeploy rewrites an asset in place under an unchanged name (the SPA shell
|
|
90
|
+
* and the service worker both do), and a stale sibling would serve the previous
|
|
91
|
+
* build forever — the exact failure mode of every cache that skips this check.
|
|
92
|
+
*/
|
|
93
|
+
async function freshSibling(filePath, brPath) {
|
|
94
|
+
try {
|
|
95
|
+
const [src, br] = await Promise.all([fsp.stat(filePath), fsp.stat(brPath)]);
|
|
96
|
+
return br.mtimeMs >= src.mtimeMs && br.size > 0;
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
async function compressToDisk(filePath, brPath, log) {
|
|
103
|
+
try {
|
|
104
|
+
const raw = await fsp.readFile(filePath);
|
|
105
|
+
const out = await brotli(raw, {
|
|
106
|
+
params: {
|
|
107
|
+
// Quality 11 is worth it BECAUSE this runs once. The per-request path
|
|
108
|
+
// could never afford it, which is why it was set to 4.
|
|
109
|
+
[node_zlib_1.constants.BROTLI_PARAM_QUALITY]: 11,
|
|
110
|
+
[node_zlib_1.constants.BROTLI_PARAM_SIZE_HINT]: raw.byteLength,
|
|
111
|
+
},
|
|
112
|
+
});
|
|
113
|
+
// Write via a temp file + rename so a concurrent reader never sees a
|
|
114
|
+
// half-written body.
|
|
115
|
+
const tmp = `${brPath}.${String(process.pid)}.tmp`;
|
|
116
|
+
await fsp.writeFile(tmp, out);
|
|
117
|
+
await fsp.rename(tmp, brPath);
|
|
118
|
+
log?.('precompressed a static asset', {
|
|
119
|
+
file: path.basename(filePath),
|
|
120
|
+
rawBytes: raw.byteLength,
|
|
121
|
+
brBytes: out.byteLength,
|
|
122
|
+
});
|
|
123
|
+
return brPath;
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
if (!warnedUnwritable) {
|
|
127
|
+
warnedUnwritable = true;
|
|
128
|
+
log?.('cannot cache precompressed assets — serving compressed per request', {
|
|
129
|
+
error: err instanceof Error ? err.message : String(err),
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Resolve what to actually send for one static asset.
|
|
137
|
+
*
|
|
138
|
+
* Returns the original with `encoding: null` whenever anything is off — the
|
|
139
|
+
* client does not accept brotli, the file is not worth compressing, or the
|
|
140
|
+
* cache could not be written. The caller then serves exactly what it served
|
|
141
|
+
* before, so this can only ever be an optimisation.
|
|
142
|
+
*/
|
|
143
|
+
async function resolvePrecompressedAsset(filePath, acceptEncoding, log) {
|
|
144
|
+
const plain = { filePath, encoding: null };
|
|
145
|
+
if (!acceptsBrotli(acceptEncoding) || !isCompressibleAsset(filePath))
|
|
146
|
+
return plain;
|
|
147
|
+
let size = 0;
|
|
148
|
+
try {
|
|
149
|
+
size = (await fsp.stat(filePath)).size;
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return plain;
|
|
153
|
+
}
|
|
154
|
+
if (size < MIN_BYTES)
|
|
155
|
+
return plain;
|
|
156
|
+
const brPath = `${filePath}.br`;
|
|
157
|
+
if (await freshSibling(filePath, brPath))
|
|
158
|
+
return { filePath: brPath, encoding: 'br' };
|
|
159
|
+
const pending = inFlight.get(filePath) ?? compressToDisk(filePath, brPath, log);
|
|
160
|
+
inFlight.set(filePath, pending);
|
|
161
|
+
try {
|
|
162
|
+
const produced = await pending;
|
|
163
|
+
return produced === null ? plain : { filePath: produced, encoding: 'br' };
|
|
164
|
+
}
|
|
165
|
+
finally {
|
|
166
|
+
inFlight.delete(filePath);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
/** Test seam — the "read-only dist" warning is once per process. */
|
|
170
|
+
function resetPrecompressedWarning() {
|
|
171
|
+
warnedUnwritable = false;
|
|
172
|
+
}
|
|
173
|
+
/** Synchronous existence probe used by the callers' own guards. */
|
|
174
|
+
function precompressedSiblingExists(filePath) {
|
|
175
|
+
return fs.existsSync(`${filePath}.br`);
|
|
176
|
+
}
|
package/dist/main.js
CHANGED
|
@@ -86,6 +86,7 @@ const system_2 = require("@camstack/system");
|
|
|
86
86
|
const session_cookie_js_1 = require("./auth/session-cookie.js");
|
|
87
87
|
const health_routes_1 = require("./api/health/health.routes");
|
|
88
88
|
const spa_static_1 = require("./api/static/spa-static");
|
|
89
|
+
const precompressed_asset_js_1 = require("./api/static/precompressed-asset.js");
|
|
89
90
|
const oauth2_routes_js_1 = require("./api/oauth2/oauth2-routes.js");
|
|
90
91
|
const system_3 = require("@camstack/system");
|
|
91
92
|
const boot_config_1 = require("./boot/boot-config");
|
|
@@ -177,6 +178,43 @@ function cleanupOrphanProcesses() {
|
|
|
177
178
|
console.log(`[cleanup] Killed ${killed} orphaned camstack process(es) from a previous run`);
|
|
178
179
|
}
|
|
179
180
|
}
|
|
181
|
+
/**
|
|
182
|
+
* Send a static SPA asset, preferring a cached brotli copy.
|
|
183
|
+
*
|
|
184
|
+
* Setting `content-encoding` is what stops `@fastify/compress` from running:
|
|
185
|
+
* its `onSend` hook bails out when the reply already declares an encoding
|
|
186
|
+
* other than `identity` (`@fastify/compress/index.js:250`). Verified in the
|
|
187
|
+
* plugin, not assumed — a second pass over an already-compressed body would
|
|
188
|
+
* reach the client as garbage.
|
|
189
|
+
*
|
|
190
|
+
* The content type is set from the ORIGINAL path. The file being streamed is
|
|
191
|
+
* `<name>.br`, and inferring from that would label a JavaScript bundle
|
|
192
|
+
* `application/brotli`, which no browser will execute.
|
|
193
|
+
*
|
|
194
|
+
* Every failure path serves the original file unchanged, so this can only ever
|
|
195
|
+
* be an optimisation: a client that does not accept brotli, an image, a
|
|
196
|
+
* read-only dist and a missing file all end up on exactly the previous
|
|
197
|
+
* behaviour.
|
|
198
|
+
*/
|
|
199
|
+
async function sendMaybePrecompressed(reply, absolutePath, acceptEncoding) {
|
|
200
|
+
if (reply.getHeader('content-type') === undefined) {
|
|
201
|
+
reply.type((0, spa_static_1.contentTypeForPath)(absolutePath));
|
|
202
|
+
}
|
|
203
|
+
const resolved = await (0, precompressed_asset_js_1.resolvePrecompressedAsset)(absolutePath, acceptEncoding, (msg, meta) => console.log(`[static] ${msg} ${JSON.stringify(meta)}`));
|
|
204
|
+
if (resolved.encoding === 'br') {
|
|
205
|
+
reply.header('content-encoding', 'br');
|
|
206
|
+
reply.header('vary', 'accept-encoding');
|
|
207
|
+
}
|
|
208
|
+
// `sendFile` used to set this; a raw stream would go out chunked, which
|
|
209
|
+
// costs the client its download progress and any length-based preload.
|
|
210
|
+
try {
|
|
211
|
+
reply.header('content-length', String(fs.statSync(resolved.filePath).size));
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
// Racing a redeploy that replaced the file — chunked is a fine fallback.
|
|
215
|
+
}
|
|
216
|
+
return reply.send(fs.createReadStream(resolved.filePath));
|
|
217
|
+
}
|
|
180
218
|
// ---- Bootstrap ----
|
|
181
219
|
async function bootstrap() {
|
|
182
220
|
// hub-main's own memory, on the record. Deliberately started HERE and not in
|
|
@@ -904,7 +942,33 @@ async function bootstrap() {
|
|
|
904
942
|
console.log('[bootstrap] OAuth2 routes registered at /api/oauth2/*');
|
|
905
943
|
// Attach tRPC WebSocket handler using noServer mode to avoid
|
|
906
944
|
// Fastify intercepting the upgrade request with a 400 response.
|
|
907
|
-
|
|
945
|
+
//
|
|
946
|
+
// `perMessageDeflate` is NOT the default — `ws` ships it off — and the
|
|
947
|
+
// viewer talks to us over `wsLink` EXCLUSIVELY, so without this every
|
|
948
|
+
// boot response travelled uncompressed while the HTTP routes were getting
|
|
949
|
+
// brotli. Measured on the live hub: `deviceManager.listAll` is 271 KB raw
|
|
950
|
+
// and 22 KB brotli, and the whole viewer boot is ~1.8 MB of tRPC JSON.
|
|
951
|
+
//
|
|
952
|
+
// The zlib settings are deliberately conservative rather than maximal.
|
|
953
|
+
// `serverNoContextTakeover` + a 1024-byte window mean each connection
|
|
954
|
+
// keeps NO persistent deflate context between messages: it costs a few
|
|
955
|
+
// points of ratio and it bounds per-socket memory, which is the trade this
|
|
956
|
+
// hub wants — it has hit a heap ceiling before, and a compression context
|
|
957
|
+
// per viewer is exactly the kind of growth that is invisible until it is
|
|
958
|
+
// not. `threshold` leaves small frames (subscription ticks) alone, where
|
|
959
|
+
// framing overhead would exceed the saving.
|
|
960
|
+
const wss = new ws_2.WebSocketServer({
|
|
961
|
+
noServer: true,
|
|
962
|
+
perMessageDeflate: {
|
|
963
|
+
threshold: 1024,
|
|
964
|
+
zlibDeflateOptions: { level: 3, memLevel: 7, chunkSize: 16 * 1024 },
|
|
965
|
+
zlibInflateOptions: { chunkSize: 16 * 1024 },
|
|
966
|
+
serverNoContextTakeover: true,
|
|
967
|
+
clientNoContextTakeover: true,
|
|
968
|
+
serverMaxWindowBits: 10,
|
|
969
|
+
concurrencyLimit: 10,
|
|
970
|
+
},
|
|
971
|
+
});
|
|
908
972
|
(0, ws_1.applyWSSHandler)({
|
|
909
973
|
wss,
|
|
910
974
|
router: appRouter,
|
|
@@ -1063,7 +1127,7 @@ async function bootstrap() {
|
|
|
1063
1127
|
// build assets (assets/index-<hash>.js) are immutable. Everything
|
|
1064
1128
|
// else gets a short cache.
|
|
1065
1129
|
reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
|
|
1066
|
-
return reply.
|
|
1130
|
+
return sendMaybePrecompressed(reply, abs, request.headers['accept-encoding']);
|
|
1067
1131
|
}
|
|
1068
1132
|
return reply.callNotFound();
|
|
1069
1133
|
}
|
|
@@ -1143,7 +1207,7 @@ async function bootstrap() {
|
|
|
1143
1207
|
if ((abs === staticDir || abs.startsWith(staticDir + path.sep)) && fs.existsSync(abs)) {
|
|
1144
1208
|
reply.header('cache-control', (0, spa_static_1.spaAssetCacheControl)(rel));
|
|
1145
1209
|
reply.type((0, spa_static_1.contentTypeForPath)(rel));
|
|
1146
|
-
return reply.
|
|
1210
|
+
return sendMaybePrecompressed(reply, abs, request.headers['accept-encoding']);
|
|
1147
1211
|
}
|
|
1148
1212
|
return reply.callNotFound();
|
|
1149
1213
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/server",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.48",
|
|
4
4
|
"private": false,
|
|
5
5
|
"files": [
|
|
6
6
|
"dist",
|
|
@@ -40,12 +40,12 @@
|
|
|
40
40
|
"@camstack/addon-notifiers": "1.2.12",
|
|
41
41
|
"@camstack/addon-pipeline": "1.2.40",
|
|
42
42
|
"@camstack/addon-pipeline-orchestrator": "1.2.21",
|
|
43
|
-
"@camstack/addon-post-analysis": "1.2.
|
|
43
|
+
"@camstack/addon-post-analysis": "1.2.31",
|
|
44
44
|
"@camstack/sdk": "1.2.9",
|
|
45
45
|
"@camstack/shm-ring": "1.1.9",
|
|
46
|
-
"@camstack/system": "1.2.
|
|
47
|
-
"@camstack/types": "1.2.
|
|
48
|
-
"@camstack/ui-library": "1.2.
|
|
46
|
+
"@camstack/system": "1.2.40",
|
|
47
|
+
"@camstack/types": "1.2.29",
|
|
48
|
+
"@camstack/ui-library": "1.2.23",
|
|
49
49
|
"@fastify/compress": "^9.0.0",
|
|
50
50
|
"@fastify/cookie": "^11.0.2",
|
|
51
51
|
"@fastify/cors": "^11.2.0",
|