@camstack/server 1.2.46 → 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.
@@ -710,6 +710,18 @@ function buildIntegrationsProvider(ar, eb, loggingService, capabilityRegistry) {
710
710
  logger.warn('not found', { tags: { integrationId: input.id }, meta: { phase: 'delete' } });
711
711
  throw new Error(`Integration "${input.id}" not found`);
712
712
  }
713
+ // REFUSE BEFORE MUTATING ANYTHING.
714
+ //
715
+ // The registry enforces this too, but it is the LAST step here — and the
716
+ // device cascade below is deliberately best-effort, so a refusal that
717
+ // arrives afterwards has already destroyed every device the integration
718
+ // owned. That is not theoretical: it deleted the Notification Center's
719
+ // alarm panel (835) one second before the registry answered "is fixed",
720
+ // and the panel came back with a new id and no state, silently breaking
721
+ // every rule that named it.
722
+ if (system_1.IntegrationRegistry.isFixed(integration)) {
723
+ throw new Error(`integration "${input.id}" is fixed — it belongs to the addon that declared it and cannot be deleted`);
724
+ }
713
725
  logger.info('removing', {
714
726
  tags: { integrationId: input.id, addonId: integration.addonId },
715
727
  meta: { phase: 'delete', name: integration.name },
@@ -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
- const wss = new ws_2.WebSocketServer({ noServer: true });
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.sendFile(rel);
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.send(fs.createReadStream(abs));
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.46",
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.29",
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.38",
47
- "@camstack/types": "1.2.28",
48
- "@camstack/ui-library": "1.2.21",
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",