@tapflowio/relay 0.20.1 → 0.21.0
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/RelayServer.d.ts +31 -0
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +159 -22
- package/dist/RelayServer.js.map +1 -1
- package/dist/lib/buildTickets.d.ts +50 -0
- package/dist/lib/buildTickets.d.ts.map +1 -0
- package/dist/lib/buildTickets.js +47 -0
- package/dist/lib/buildTickets.js.map +1 -0
- package/package.json +4 -4
- package/public/assets/{AppCenter-DtF_g4F7.js → AppCenter-BkSQFKRv.js} +5 -5
- package/public/assets/AppCenter-BkSQFKRv.js.br +0 -0
- package/public/assets/AppCenter-BkSQFKRv.js.gz +0 -0
- package/public/assets/{Default-C_p-ZWS6.js → Default-D1inA8sU.js} +1 -1
- package/public/assets/Default-D1inA8sU.js.br +0 -0
- package/public/assets/Default-D1inA8sU.js.gz +0 -0
- package/public/assets/{Invite-DKT4qT6M.js → Invite-ChaVEWvP.js} +1 -1
- package/public/assets/Invite-ChaVEWvP.js.br +0 -0
- package/public/assets/Invite-ChaVEWvP.js.gz +0 -0
- package/public/assets/{MacResources-D8T_KpPV.js → MacResources-CLChFyJW.js} +1 -1
- package/public/assets/MacResources-CLChFyJW.js.br +0 -0
- package/public/assets/MacResources-CLChFyJW.js.gz +0 -0
- package/public/assets/NotFound-BoDDivK5.js.gz +0 -0
- package/public/assets/QASession-DICynZa0.js +140 -0
- package/public/assets/QASession-DICynZa0.js.br +0 -0
- package/public/assets/QASession-DICynZa0.js.gz +0 -0
- package/public/assets/{ResetPassword-BnmE9MfY.js → ResetPassword-Ut9BFums.js} +1 -1
- package/public/assets/ResetPassword-Ut9BFums.js.br +0 -0
- package/public/assets/ResetPassword-Ut9BFums.js.gz +0 -0
- package/public/assets/{Setup-I6nFcwNZ.js → Setup-CoHwaxo7.js} +1 -1
- package/public/assets/Setup-CoHwaxo7.js.br +0 -0
- package/public/assets/Setup-CoHwaxo7.js.gz +0 -0
- package/public/assets/{Team-JhXhGt1H.js → Team-DaVY1DGm.js} +2 -2
- package/public/assets/Team-DaVY1DGm.js.br +0 -0
- package/public/assets/Team-DaVY1DGm.js.gz +0 -0
- package/public/assets/Tokens-_KZJ7AZ9.js +1 -0
- package/public/assets/Tokens-_KZJ7AZ9.js.br +0 -0
- package/public/assets/Tokens-_KZJ7AZ9.js.gz +0 -0
- package/public/assets/{alert-dialog-CLhPOSdB.js → alert-dialog-Cc_pQpe6.js} +1 -1
- package/public/assets/alert-dialog-Cc_pQpe6.js.br +0 -0
- package/public/assets/alert-dialog-Cc_pQpe6.js.gz +0 -0
- package/public/assets/{badge-CP-xbjo3.js → badge-CikXk90N.js} +1 -1
- package/public/assets/badge-CikXk90N.js.br +0 -0
- package/public/assets/badge-CikXk90N.js.gz +0 -0
- package/public/assets/{build-format-DO1zN4vW.js → build-format-DsApFPHy.js} +3 -3
- package/public/assets/build-format-DsApFPHy.js.br +0 -0
- package/public/assets/build-format-DsApFPHy.js.gz +0 -0
- package/public/assets/{dialog-BGH9MPcH.js → dialog-B4Wg7TCg.js} +3 -3
- package/public/assets/dialog-B4Wg7TCg.js.br +0 -0
- package/public/assets/dialog-B4Wg7TCg.js.gz +0 -0
- package/public/assets/{index-O-Temo_p.js → index-CBfjqtE-.js} +23 -23
- package/public/assets/index-CBfjqtE-.js.br +0 -0
- package/public/assets/index-CBfjqtE-.js.gz +0 -0
- package/public/assets/index-ClHUQm-F.css.gz +0 -0
- package/public/assets/{pencil-0UYCdq7e.js → pencil-DMswV31e.js} +2 -2
- package/public/assets/pencil-DMswV31e.js.br +0 -0
- package/public/assets/pencil-DMswV31e.js.gz +0 -0
- package/public/assets/{table-4DnBixRm.js → table-dbLmBL_w.js} +1 -1
- package/public/assets/table-dbLmBL_w.js.br +0 -0
- package/public/assets/table-dbLmBL_w.js.gz +0 -0
- package/public/assets/{tabs-DwiCx3dV.js → tabs-pPYCRfmT.js} +1 -1
- package/public/assets/tabs-pPYCRfmT.js.br +0 -0
- package/public/assets/tabs-pPYCRfmT.js.gz +0 -0
- package/public/assets/tinyh264.worker-DiJ50Hai.js.gz +0 -0
- package/public/assets/{trash-2-CAhMuWgg.js → trash-VgeHmI8N.js} +4 -4
- package/public/assets/trash-VgeHmI8N.js.br +0 -0
- package/public/assets/trash-VgeHmI8N.js.gz +0 -0
- package/public/assets/vendor-charts-DyX5CBcf.js.gz +0 -0
- package/public/assets/vendor-react-AJpf4YZ0.js.gz +0 -0
- package/public/assets/vendor-router-BFibuab1.js.gz +0 -0
- package/public/favicon.svg.gz +0 -0
- package/public/index.html +1 -1
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/logo-dark.svg.gz +0 -0
- package/public/logo.svg.gz +0 -0
- package/public/assets/AppCenter-DtF_g4F7.js.br +0 -0
- package/public/assets/Default-C_p-ZWS6.js.br +0 -0
- package/public/assets/Invite-DKT4qT6M.js.br +0 -0
- package/public/assets/MacResources-D8T_KpPV.js.br +0 -0
- package/public/assets/QASession-Daup7RGP.js +0 -140
- package/public/assets/QASession-Daup7RGP.js.br +0 -0
- package/public/assets/ResetPassword-BnmE9MfY.js.br +0 -0
- package/public/assets/Setup-I6nFcwNZ.js.br +0 -0
- package/public/assets/Team-JhXhGt1H.js.br +0 -0
- package/public/assets/Tokens-KAKIDZ0V.js +0 -1
- package/public/assets/Tokens-KAKIDZ0V.js.br +0 -0
- package/public/assets/alert-dialog-CLhPOSdB.js.br +0 -0
- package/public/assets/badge-CP-xbjo3.js.br +0 -0
- package/public/assets/build-format-DO1zN4vW.js.br +0 -0
- package/public/assets/dialog-BGH9MPcH.js.br +0 -0
- package/public/assets/index-O-Temo_p.js.br +0 -0
- package/public/assets/pencil-0UYCdq7e.js.br +0 -0
- package/public/assets/table-4DnBixRm.js.br +0 -0
- package/public/assets/tabs-DwiCx3dV.js.br +0 -0
- package/public/assets/trash-2-CAhMuWgg.js.br +0 -0
package/dist/RelayServer.d.ts
CHANGED
|
@@ -91,6 +91,9 @@ export declare class RelayServer {
|
|
|
91
91
|
private readonly agentGraceMs;
|
|
92
92
|
private readonly corsAllowed;
|
|
93
93
|
private warnedProxyMisconfig;
|
|
94
|
+
/** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
|
|
95
|
+
private readonly warnedLegacyInstaller;
|
|
96
|
+
private readonly buildTickets;
|
|
94
97
|
private pendingScreenshots;
|
|
95
98
|
private readonly uiTreeTimeoutMs;
|
|
96
99
|
private pendingUITrees;
|
|
@@ -410,6 +413,20 @@ export declare class RelayServer {
|
|
|
410
413
|
* from silence and it waits out the deadline (#445). `Session not found` is app-specific for the
|
|
411
414
|
* same reason: a generic `error` cannot be correlated by construction. */
|
|
412
415
|
private handleBrowserAppInstall;
|
|
416
|
+
/**
|
|
417
|
+
* Says once, per agent, that this agent cannot fetch builds over HTTP.
|
|
418
|
+
*
|
|
419
|
+
* **The wording is conditional on purpose.** Such an agent installs perfectly well when it shares
|
|
420
|
+
* the relay's filesystem, which is every `tapflow start`, so "installs will fail" would be false
|
|
421
|
+
* for most of the people who see it. What it cannot do is install from a relay that is somewhere
|
|
422
|
+
* else — and the relay cannot tell which case this is: the dashboard hands agents a **LAN**
|
|
423
|
+
* address even for the same machine (`pickLanAddress`), so the socket's own address answers a
|
|
424
|
+
* different question than the one that matters.
|
|
425
|
+
*
|
|
426
|
+
* Once per identity, because `agent:register` runs again on every reconnect — a Wi-Fi blip or a
|
|
427
|
+
* laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
|
|
428
|
+
*/
|
|
429
|
+
private warnLegacyInstaller;
|
|
413
430
|
/** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
|
|
414
431
|
private handleBrowserAppLaunch;
|
|
415
432
|
private handleGetScreenshot;
|
|
@@ -420,6 +437,20 @@ export declare class RelayServer {
|
|
|
420
437
|
private handleScreenshotError;
|
|
421
438
|
private flushResourceBuffers;
|
|
422
439
|
private handleRequest;
|
|
440
|
+
/**
|
|
441
|
+
* Serves one build to the agent that was just told to install it.
|
|
442
|
+
*
|
|
443
|
+
* **The ticket is the whole credential, and it travels in a header.** No PAT is asked for —
|
|
444
|
+
* requiring one would break `tapflow start`, whose agent connects on loopback with no token at
|
|
445
|
+
* all. In a URL the ticket would reach the request log the first time this handler threw
|
|
446
|
+
* (`router.ts` logs `${method} ${url}`, and its redaction only knows PAT and JWT shapes) and every
|
|
447
|
+
* reverse proxy's access log besides.
|
|
448
|
+
*
|
|
449
|
+
* The three failures are answered separately because a user does something different about each:
|
|
450
|
+
* a ticket nobody minted, a ticket that sat too long, and a file that went away between the mint
|
|
451
|
+
* and the fetch. Collapsing them is the defect this change exists to stop repeating.
|
|
452
|
+
*/
|
|
453
|
+
private handleBuildDownload;
|
|
423
454
|
private serveUpload;
|
|
424
455
|
private serveStatic;
|
|
425
456
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
|
package/dist/RelayServer.js
CHANGED
|
@@ -11,6 +11,7 @@ import { Router, json } from './router.js';
|
|
|
11
11
|
import { requireViewAuth, requireAuth, getAuth, verifyPat } from './middleware/auth.js';
|
|
12
12
|
import { classifyConnection } from './lib/connectionAuth.js';
|
|
13
13
|
import { resolveClientAddress } from './lib/clientAddress.js';
|
|
14
|
+
import { BuildTicketStore } from './lib/buildTickets.js';
|
|
14
15
|
import { resolveCorsHeaders } from './lib/cors.js';
|
|
15
16
|
import { isCsrfBlocked } from './lib/csrf.js';
|
|
16
17
|
import { pickLanAddress } from './lib/lanAddress.js';
|
|
@@ -229,6 +230,9 @@ export class RelayServer {
|
|
|
229
230
|
corsAllowed;
|
|
230
231
|
// One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
|
|
231
232
|
warnedProxyMisconfig = false;
|
|
233
|
+
/** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
|
|
234
|
+
warnedLegacyInstaller = new Set();
|
|
235
|
+
buildTickets = new BuildTicketStore();
|
|
232
236
|
pendingScreenshots = new Map();
|
|
233
237
|
uiTreeTimeoutMs;
|
|
234
238
|
pendingUITrees = new Map();
|
|
@@ -301,6 +305,12 @@ export class RelayServer {
|
|
|
301
305
|
this.router.post('/api/v1/builds/:id/schedule-deletion', handleScheduleBuildDeletion);
|
|
302
306
|
this.router.delete('/api/v1/builds/:id/schedule-deletion', handleCancelBuildDeletion);
|
|
303
307
|
this.router.post('/api/v1/builds', (req, res) => handleUploadBuild(req, res, u));
|
|
308
|
+
// **Deliberately not under `/api/v1/builds/`.** `:id` compiles to `([^/]+)` and the router
|
|
309
|
+
// returns on the first registered match, so `/api/v1/builds/download` is captured by
|
|
310
|
+
// `/api/v1/builds/:id` as `id="download"` — `requireBuildAuth` then answers 401 to a ticketed
|
|
311
|
+
// agent carrying no PAT. Registration order could avoid it, which is exactly the objection:
|
|
312
|
+
// that makes the order load-bearing with nothing to say so.
|
|
313
|
+
this.router.get('/api/v1/build-download', (req, res) => this.handleBuildDownload(req, res));
|
|
304
314
|
// webhooks (outbound build-status notifications)
|
|
305
315
|
this.router.get('/api/v1/webhooks', handleListWebhooks);
|
|
306
316
|
this.router.post('/api/v1/webhooks', handleCreateWebhook);
|
|
@@ -1418,6 +1428,7 @@ export class RelayServer {
|
|
|
1418
1428
|
// its socket before creating the new ones. Identity is agentId (unique per Mac) when present,
|
|
1419
1429
|
// else agentName. (Heartbeat backstop for never-reconnecting agents: #313.)
|
|
1420
1430
|
const identity = msg.agentId ?? msg.agentName;
|
|
1431
|
+
this.warnLegacyInstaller(identity, msg.capabilities ?? []);
|
|
1421
1432
|
// Deduplicate first. Everything below is keyed by device id, so a payload naming one device
|
|
1422
1433
|
// twice would collapse to a single entry in `registeredSessions` while `create()` had already
|
|
1423
1434
|
// made two sessions — leaving one the agent is never told about. That is the same orphan the
|
|
@@ -1956,13 +1967,56 @@ export class RelayServer {
|
|
|
1956
1967
|
// agent's reply is forwarded back generically without the relay looking at it. So if the id does not
|
|
1957
1968
|
// reach the agent, nothing downstream can attribute the reply. Nothing type-checks that the value is
|
|
1958
1969
|
// the *request's*: a brand cannot express provenance, so a test carries it.
|
|
1970
|
+
// **`statSync`, and this handler stays synchronous.** Its caller does not await it
|
|
1971
|
+
// (`case 'app:install'` above), and the message loop's `catch` is synchronous — so making this
|
|
1972
|
+
// `async` would turn the `getDb()` throw named in the comment at the top of this function
|
|
1973
|
+
// (SQLITE_BUSY, a closed db, I/O) from "logged, one request hangs" into an unhandled rejection,
|
|
1974
|
+
// and the CLI exits the process on those. The relay would die with every session on it.
|
|
1975
|
+
//
|
|
1976
|
+
// A missing file is answered rather than thrown for the same reason the lookup above is: the
|
|
1977
|
+
// build row outlives its file whenever a bind mount goes away or a purge half-ran, and an
|
|
1978
|
+
// unanswered install is a spinner that never stops and a caller that times out with no cause.
|
|
1979
|
+
let bytes;
|
|
1980
|
+
try {
|
|
1981
|
+
bytes = fs.statSync(build.file_path).size;
|
|
1982
|
+
}
|
|
1983
|
+
catch {
|
|
1984
|
+
return fail('The relay cannot read this build file. It may have been deleted — re-upload the build.');
|
|
1985
|
+
}
|
|
1959
1986
|
this.sendTo(session.agentSocket, {
|
|
1960
1987
|
type: 'app:install',
|
|
1961
1988
|
sessionId,
|
|
1962
1989
|
requestId,
|
|
1963
|
-
payload: {
|
|
1990
|
+
payload: {
|
|
1991
|
+
// Still sent, and sent first: an agent that predates `build-download` reads only this.
|
|
1992
|
+
filePath: build.file_path,
|
|
1993
|
+
bundleId: build.bundle_id,
|
|
1994
|
+
buildTicket: this.buildTickets.mint(msg.buildId, build.file_path),
|
|
1995
|
+
buildName: path.basename(build.file_path),
|
|
1996
|
+
buildBytes: bytes,
|
|
1997
|
+
},
|
|
1964
1998
|
});
|
|
1965
1999
|
}
|
|
2000
|
+
/**
|
|
2001
|
+
* Says once, per agent, that this agent cannot fetch builds over HTTP.
|
|
2002
|
+
*
|
|
2003
|
+
* **The wording is conditional on purpose.** Such an agent installs perfectly well when it shares
|
|
2004
|
+
* the relay's filesystem, which is every `tapflow start`, so "installs will fail" would be false
|
|
2005
|
+
* for most of the people who see it. What it cannot do is install from a relay that is somewhere
|
|
2006
|
+
* else — and the relay cannot tell which case this is: the dashboard hands agents a **LAN**
|
|
2007
|
+
* address even for the same machine (`pickLanAddress`), so the socket's own address answers a
|
|
2008
|
+
* different question than the one that matters.
|
|
2009
|
+
*
|
|
2010
|
+
* Once per identity, because `agent:register` runs again on every reconnect — a Wi-Fi blip or a
|
|
2011
|
+
* laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
|
|
2012
|
+
*/
|
|
2013
|
+
warnLegacyInstaller(identity, capabilities) {
|
|
2014
|
+
if (capabilities.includes('build-download') || this.warnedLegacyInstaller.has(identity))
|
|
2015
|
+
return;
|
|
2016
|
+
this.warnedLegacyInstaller.add(identity);
|
|
2017
|
+
logger.warn(`[relay] agent ${identity} predates build downloading. If it is not on the same machine as ` +
|
|
2018
|
+
'this relay, installing a build will fail — update the agent to match the relay.');
|
|
2019
|
+
}
|
|
1966
2020
|
/** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
|
|
1967
2021
|
handleBrowserAppLaunch(ws, msg) {
|
|
1968
2022
|
const sessionId = msg.sessionId;
|
|
@@ -2180,6 +2234,52 @@ export class RelayServer {
|
|
|
2180
2234
|
// SPA static fallback
|
|
2181
2235
|
this.serveStatic(req, res);
|
|
2182
2236
|
}
|
|
2237
|
+
/**
|
|
2238
|
+
* Serves one build to the agent that was just told to install it.
|
|
2239
|
+
*
|
|
2240
|
+
* **The ticket is the whole credential, and it travels in a header.** No PAT is asked for —
|
|
2241
|
+
* requiring one would break `tapflow start`, whose agent connects on loopback with no token at
|
|
2242
|
+
* all. In a URL the ticket would reach the request log the first time this handler threw
|
|
2243
|
+
* (`router.ts` logs `${method} ${url}`, and its redaction only knows PAT and JWT shapes) and every
|
|
2244
|
+
* reverse proxy's access log besides.
|
|
2245
|
+
*
|
|
2246
|
+
* The three failures are answered separately because a user does something different about each:
|
|
2247
|
+
* a ticket nobody minted, a ticket that sat too long, and a file that went away between the mint
|
|
2248
|
+
* and the fetch. Collapsing them is the defect this change exists to stop repeating.
|
|
2249
|
+
*/
|
|
2250
|
+
handleBuildDownload(req, res) {
|
|
2251
|
+
const ticket = req.headers['x-tapflow-build-ticket'];
|
|
2252
|
+
if (typeof ticket !== 'string' || ticket === '') {
|
|
2253
|
+
return json(res, 401, { error: 'Missing X-Tapflow-Build-Ticket' });
|
|
2254
|
+
}
|
|
2255
|
+
const found = this.buildTickets.redeem(ticket);
|
|
2256
|
+
if (found === 'unknown') {
|
|
2257
|
+
return json(res, 404, { error: 'This build ticket is not valid. It may already have been used.' });
|
|
2258
|
+
}
|
|
2259
|
+
if (found === 'expired') {
|
|
2260
|
+
return json(res, 410, { error: 'This build ticket expired before it was used.' });
|
|
2261
|
+
}
|
|
2262
|
+
let size;
|
|
2263
|
+
try {
|
|
2264
|
+
size = fs.statSync(found.filePath).size;
|
|
2265
|
+
}
|
|
2266
|
+
catch {
|
|
2267
|
+
return json(res, 404, { error: 'The build file is no longer on the relay.' });
|
|
2268
|
+
}
|
|
2269
|
+
// Set even though the agent compares against `buildBytes` instead: a correct header costs
|
|
2270
|
+
// nothing and helps anything else that looks. It is not the check — a proxy may drop it.
|
|
2271
|
+
res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': size });
|
|
2272
|
+
// **`pipe` does not destroy its source when the destination goes away** — it only unpipes — so an
|
|
2273
|
+
// aborted download leaves the read stream and its file descriptor open for the life of the
|
|
2274
|
+
// process. Measured: after a client abort, `rs.destroyed` is still false and the fd still stats.
|
|
2275
|
+
// On this route an abort is the *ordinary* failure, not an edge case: the agent's own idle
|
|
2276
|
+
// timeout destroys the request, and so does an agent that exits or a link that drops. Enough of
|
|
2277
|
+
// them and the relay hits its descriptor limit and stops accepting connections at all.
|
|
2278
|
+
const rs = fs.createReadStream(found.filePath);
|
|
2279
|
+
res.on('close', () => { rs.destroy(); });
|
|
2280
|
+
rs.on('error', () => { res.destroy(); });
|
|
2281
|
+
rs.pipe(res);
|
|
2282
|
+
}
|
|
2183
2283
|
serveUpload(req, res) {
|
|
2184
2284
|
const urlPath = (req.url ?? '/').split('?')[0];
|
|
2185
2285
|
const filePath = path.join(this.uploadsDir, urlPath.replace('/uploads/', ''));
|
|
@@ -2196,9 +2296,11 @@ export class RelayServer {
|
|
|
2196
2296
|
}
|
|
2197
2297
|
const contentType = MIME_TYPES[path.extname(filePath)] ?? 'application/octet-stream';
|
|
2198
2298
|
res.writeHead(200, { 'Content-Type': contentType });
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2299
|
+
// Same leak as `handleBuildDownload` above, and the same one line closes it.
|
|
2300
|
+
const rs = fs.createReadStream(filePath);
|
|
2301
|
+
res.on('close', () => { rs.destroy(); });
|
|
2302
|
+
rs.on('error', () => { res.destroy(); });
|
|
2303
|
+
rs.pipe(res);
|
|
2202
2304
|
}
|
|
2203
2305
|
serveStatic(req, res) {
|
|
2204
2306
|
const urlPath = (req.url ?? '/').split('?')[0];
|
|
@@ -2222,34 +2324,69 @@ export class RelayServer {
|
|
|
2222
2324
|
const contentType = MIME_TYPES[path.extname(filePath)] ?? 'text/html';
|
|
2223
2325
|
const headers = { 'Content-Type': contentType };
|
|
2224
2326
|
// Content-hashed build assets never change → cache them forever.
|
|
2327
|
+
// HTML entry points must revalidate on every load so updates are picked up immediately.
|
|
2225
2328
|
if (urlPath.startsWith('/assets/')) {
|
|
2226
2329
|
headers['Cache-Control'] = 'public, max-age=31536000, immutable';
|
|
2227
2330
|
}
|
|
2228
|
-
|
|
2331
|
+
else if (filePath.endsWith('index.html')) {
|
|
2332
|
+
headers['Cache-Control'] = 'no-cache';
|
|
2333
|
+
}
|
|
2334
|
+
// Serve precompressed siblings (.br or .gz) when accepted (precompressed → no runtime CPU on the stream path).
|
|
2229
2335
|
const acceptHeader = req.headers['accept-encoding'];
|
|
2230
2336
|
const accept = Array.isArray(acceptHeader) ? acceptHeader.join(',') : acceptHeader ?? '';
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
|
|
2337
|
+
// **An explicit coding outranks `*`, rather than being maxed with it.** RFC 9110 §12.5.3 gives
|
|
2338
|
+
// the more specific match precedence, and taking the maximum across both meant
|
|
2339
|
+
// `Accept-Encoding: br;q=0, *` answered 1 for `br` — serving brotli to a client that named it
|
|
2340
|
+
// and refused it. The two are tracked apart and the wildcard is consulted only when the coding
|
|
2341
|
+
// was never named.
|
|
2342
|
+
const parseQuality = (codingName) => {
|
|
2343
|
+
let named = -1;
|
|
2344
|
+
let wildcard = -1;
|
|
2345
|
+
for (const token of accept.split(',')) {
|
|
2346
|
+
const [name, ...params] = token.trim().split(';');
|
|
2347
|
+
const coding = name.trim().toLowerCase();
|
|
2348
|
+
if (coding !== codingName && coding !== '*')
|
|
2349
|
+
continue;
|
|
2350
|
+
// Lowercased because RFC 9110 §5.6.6 makes parameter names case-insensitive: `br;Q=0` was
|
|
2351
|
+
// missed, fell through to the default weight of 1, and served brotli to a client that had
|
|
2352
|
+
// refused it. The qvalue itself is numeric, so folding the whole token is safe.
|
|
2353
|
+
const qParam = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith('q='));
|
|
2354
|
+
const q = qParam ? Number(qParam.slice(2)) : 1;
|
|
2355
|
+
if (Number.isNaN(q))
|
|
2356
|
+
continue;
|
|
2357
|
+
if (coding === '*')
|
|
2358
|
+
wildcard = Math.max(wildcard, q);
|
|
2359
|
+
else
|
|
2360
|
+
named = Math.max(named, q);
|
|
2361
|
+
}
|
|
2362
|
+
return named >= 0 ? named : wildcard;
|
|
2363
|
+
};
|
|
2364
|
+
const brQ = parseQuality('br');
|
|
2365
|
+
const gzQ = Math.max(parseQuality('gzip'), parseQuality('x-gzip'));
|
|
2366
|
+
// **`Vary` unconditionally, and that is what lets the probes below be skipped.** It declares a
|
|
2367
|
+
// cache key rather than describing this response: a shared cache that stored the uncompressed
|
|
2368
|
+
// body without it would hand that body to the next client, which may accept brotli. Deriving it
|
|
2369
|
+
// from "does a sibling exist" is what forced a `stat` on every static request even when the
|
|
2370
|
+
// client asked for no encoding at all — a syscall per asset per request, on the path this exists
|
|
2371
|
+
// to make cheaper. Set it always, and ask the filesystem only about codings that could be served.
|
|
2372
|
+
headers['Vary'] = 'Accept-Encoding';
|
|
2373
|
+
const hasBr = brQ > 0 && fs.existsSync(filePath + '.br');
|
|
2374
|
+
const hasGz = gzQ > 0 && fs.existsSync(filePath + '.gz');
|
|
2240
2375
|
let servePath = filePath;
|
|
2241
|
-
|
|
2242
|
-
// Vary whenever a compressed variant exists, even if raw is served, so caches don't cross-serve.
|
|
2243
|
-
if (hasBr)
|
|
2244
|
-
headers['Vary'] = 'Accept-Encoding';
|
|
2245
|
-
if (brAccepted && hasBr) {
|
|
2376
|
+
if (brQ > 0 && hasBr && (gzQ <= 0 || !hasGz || brQ >= gzQ)) {
|
|
2246
2377
|
servePath = filePath + '.br';
|
|
2247
2378
|
headers['Content-Encoding'] = 'br';
|
|
2248
2379
|
}
|
|
2380
|
+
else if (gzQ > 0 && hasGz) {
|
|
2381
|
+
servePath = filePath + '.gz';
|
|
2382
|
+
headers['Content-Encoding'] = 'gzip';
|
|
2383
|
+
}
|
|
2249
2384
|
res.writeHead(200, headers);
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2385
|
+
// Same leak as `handleBuildDownload` above, and the same one line closes it.
|
|
2386
|
+
const rs = fs.createReadStream(servePath);
|
|
2387
|
+
res.on('close', () => { rs.destroy(); });
|
|
2388
|
+
rs.on('error', () => { res.destroy(); });
|
|
2389
|
+
rs.pipe(res);
|
|
2253
2390
|
}
|
|
2254
2391
|
}
|
|
2255
2392
|
//# sourceMappingURL=RelayServer.js.map
|