@tapflowio/relay 0.20.1 → 0.22.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.
Files changed (130) hide show
  1. package/dist/RelayServer.d.ts +33 -0
  2. package/dist/RelayServer.d.ts.map +1 -1
  3. package/dist/RelayServer.js +173 -26
  4. package/dist/RelayServer.js.map +1 -1
  5. package/dist/api/team.d.ts +2 -1
  6. package/dist/api/team.d.ts.map +1 -1
  7. package/dist/api/team.js +7 -5
  8. package/dist/api/team.js.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/lib/buildTickets.d.ts +50 -0
  13. package/dist/lib/buildTickets.d.ts.map +1 -0
  14. package/dist/lib/buildTickets.js +47 -0
  15. package/dist/lib/buildTickets.js.map +1 -0
  16. package/dist/lib/lanAddress.d.ts +10 -0
  17. package/dist/lib/lanAddress.d.ts.map +1 -1
  18. package/dist/lib/lanAddress.js +13 -0
  19. package/dist/lib/lanAddress.js.map +1 -1
  20. package/dist/lib/proxyConfig.d.ts +4 -2
  21. package/dist/lib/proxyConfig.d.ts.map +1 -1
  22. package/dist/lib/proxyConfig.js +22 -13
  23. package/dist/lib/proxyConfig.js.map +1 -1
  24. package/dist/lib/publicUrl.d.ts +31 -1
  25. package/dist/lib/publicUrl.d.ts.map +1 -1
  26. package/dist/lib/publicUrl.js +51 -9
  27. package/dist/lib/publicUrl.js.map +1 -1
  28. package/dist/server.js +7 -1
  29. package/dist/server.js.map +1 -1
  30. package/package.json +4 -4
  31. package/public/assets/{AppCenter-DtF_g4F7.js → AppCenter-gbsJscjW.js} +5 -5
  32. package/public/assets/AppCenter-gbsJscjW.js.br +0 -0
  33. package/public/assets/AppCenter-gbsJscjW.js.gz +0 -0
  34. package/public/assets/{Default-C_p-ZWS6.js → Default-2rFTkbo9.js} +1 -1
  35. package/public/assets/Default-2rFTkbo9.js.br +0 -0
  36. package/public/assets/Default-2rFTkbo9.js.gz +0 -0
  37. package/public/assets/{Invite-DKT4qT6M.js → Invite-aN4DXJyM.js} +1 -1
  38. package/public/assets/Invite-aN4DXJyM.js.br +0 -0
  39. package/public/assets/Invite-aN4DXJyM.js.gz +0 -0
  40. package/public/assets/MacResources-D3IlesXU.js +3 -0
  41. package/public/assets/MacResources-D3IlesXU.js.br +0 -0
  42. package/public/assets/MacResources-D3IlesXU.js.gz +0 -0
  43. package/public/assets/NotFound-BoDDivK5.js.gz +0 -0
  44. package/public/assets/QASession-U29t9j0E.js +140 -0
  45. package/public/assets/QASession-U29t9j0E.js.br +0 -0
  46. package/public/assets/QASession-U29t9j0E.js.gz +0 -0
  47. package/public/assets/{ResetPassword-BnmE9MfY.js → ResetPassword-DPc5kwQU.js} +1 -1
  48. package/public/assets/ResetPassword-DPc5kwQU.js.br +0 -0
  49. package/public/assets/ResetPassword-DPc5kwQU.js.gz +0 -0
  50. package/public/assets/{Setup-I6nFcwNZ.js → Setup-CwPx_e_O.js} +1 -1
  51. package/public/assets/Setup-CwPx_e_O.js.br +0 -0
  52. package/public/assets/Setup-CwPx_e_O.js.gz +0 -0
  53. package/public/assets/Team-DD9li-7M.js +6 -0
  54. package/public/assets/Team-DD9li-7M.js.br +0 -0
  55. package/public/assets/Team-DD9li-7M.js.gz +0 -0
  56. package/public/assets/Tokens-DdMMke73.js +1 -0
  57. package/public/assets/Tokens-DdMMke73.js.br +0 -0
  58. package/public/assets/Tokens-DdMMke73.js.gz +0 -0
  59. package/public/assets/{alert-dialog-CLhPOSdB.js → alert-dialog-DADd1jHN.js} +1 -1
  60. package/public/assets/alert-dialog-DADd1jHN.js.br +0 -0
  61. package/public/assets/alert-dialog-DADd1jHN.js.gz +0 -0
  62. package/public/assets/{badge-CP-xbjo3.js → badge-CMf8d5RJ.js} +1 -1
  63. package/public/assets/badge-CMf8d5RJ.js.br +0 -0
  64. package/public/assets/badge-CMf8d5RJ.js.gz +0 -0
  65. package/public/assets/{build-format-DO1zN4vW.js → build-format-lp_8kSvq.js} +3 -3
  66. package/public/assets/build-format-lp_8kSvq.js.br +0 -0
  67. package/public/assets/build-format-lp_8kSvq.js.gz +0 -0
  68. package/public/assets/{dialog-BGH9MPcH.js → dialog-pS8vHXXv.js} +3 -3
  69. package/public/assets/dialog-pS8vHXXv.js.br +0 -0
  70. package/public/assets/dialog-pS8vHXXv.js.gz +0 -0
  71. package/public/assets/index-Bxs7CdEi.css +1 -0
  72. package/public/assets/index-Bxs7CdEi.css.br +0 -0
  73. package/public/assets/index-Bxs7CdEi.css.gz +0 -0
  74. package/public/assets/{index-O-Temo_p.js → index-Cajf0T-5.js} +23 -23
  75. package/public/assets/index-Cajf0T-5.js.br +0 -0
  76. package/public/assets/index-Cajf0T-5.js.gz +0 -0
  77. package/public/assets/{pencil-0UYCdq7e.js → pencil-LD1ga-Kk.js} +2 -2
  78. package/public/assets/pencil-LD1ga-Kk.js.br +0 -0
  79. package/public/assets/pencil-LD1ga-Kk.js.gz +0 -0
  80. package/public/assets/publicLink-D6zKS_wu.js +1 -0
  81. package/public/assets/publicLink-D6zKS_wu.js.br +0 -0
  82. package/public/assets/publicLink-D6zKS_wu.js.gz +0 -0
  83. package/public/assets/resource-health-BScurILF.js +1 -0
  84. package/public/assets/resource-health-BScurILF.js.br +0 -0
  85. package/public/assets/resource-health-BScurILF.js.gz +0 -0
  86. package/public/assets/{table-4DnBixRm.js → table-CL9AvoTp.js} +1 -1
  87. package/public/assets/table-CL9AvoTp.js.br +0 -0
  88. package/public/assets/table-CL9AvoTp.js.gz +0 -0
  89. package/public/assets/tinyh264.worker-DiJ50Hai.js.gz +0 -0
  90. package/public/assets/{trash-2-CAhMuWgg.js → trash-mvVp4nwq.js} +4 -4
  91. package/public/assets/trash-mvVp4nwq.js.br +0 -0
  92. package/public/assets/trash-mvVp4nwq.js.gz +0 -0
  93. package/public/assets/vendor-charts-8kVeHLwY.js +5 -0
  94. package/public/assets/vendor-charts-8kVeHLwY.js.br +0 -0
  95. package/public/assets/vendor-charts-8kVeHLwY.js.gz +0 -0
  96. package/public/assets/vendor-react-AJpf4YZ0.js.gz +0 -0
  97. package/public/assets/vendor-router-BFibuab1.js.gz +0 -0
  98. package/public/favicon.svg.gz +0 -0
  99. package/public/index.html +2 -2
  100. package/public/index.html.br +0 -0
  101. package/public/index.html.gz +0 -0
  102. package/public/logo-dark.svg.gz +0 -0
  103. package/public/logo.svg.gz +0 -0
  104. package/public/assets/AppCenter-DtF_g4F7.js.br +0 -0
  105. package/public/assets/Default-C_p-ZWS6.js.br +0 -0
  106. package/public/assets/Invite-DKT4qT6M.js.br +0 -0
  107. package/public/assets/MacResources-D8T_KpPV.js +0 -1
  108. package/public/assets/MacResources-D8T_KpPV.js.br +0 -0
  109. package/public/assets/QASession-Daup7RGP.js +0 -140
  110. package/public/assets/QASession-Daup7RGP.js.br +0 -0
  111. package/public/assets/ResetPassword-BnmE9MfY.js.br +0 -0
  112. package/public/assets/Setup-I6nFcwNZ.js.br +0 -0
  113. package/public/assets/Team-JhXhGt1H.js +0 -6
  114. package/public/assets/Team-JhXhGt1H.js.br +0 -0
  115. package/public/assets/Tokens-KAKIDZ0V.js +0 -1
  116. package/public/assets/Tokens-KAKIDZ0V.js.br +0 -0
  117. package/public/assets/alert-dialog-CLhPOSdB.js.br +0 -0
  118. package/public/assets/badge-CP-xbjo3.js.br +0 -0
  119. package/public/assets/build-format-DO1zN4vW.js.br +0 -0
  120. package/public/assets/dialog-BGH9MPcH.js.br +0 -0
  121. package/public/assets/index-ClHUQm-F.css +0 -1
  122. package/public/assets/index-ClHUQm-F.css.br +0 -0
  123. package/public/assets/index-O-Temo_p.js.br +0 -0
  124. package/public/assets/pencil-0UYCdq7e.js.br +0 -0
  125. package/public/assets/table-4DnBixRm.js.br +0 -0
  126. package/public/assets/tabs-DwiCx3dV.js +0 -1
  127. package/public/assets/tabs-DwiCx3dV.js.br +0 -0
  128. package/public/assets/trash-2-CAhMuWgg.js.br +0 -0
  129. package/public/assets/vendor-charts-DyX5CBcf.js +0 -5
  130. package/public/assets/vendor-charts-DyX5CBcf.js.br +0 -0
@@ -1,3 +1,4 @@
1
+ import { type TunnelRuntime } from './lib/publicUrl.js';
1
2
  export declare function isExternalAddress(addr: string): boolean;
2
3
  /** Exported for the re-join test, which pins both edges of this window rather than hardcoding 500 —
3
4
  * a test carrying its own copy of the number passes when the constant moves, which is the drift the
@@ -91,6 +92,9 @@ export declare class RelayServer {
91
92
  private readonly agentGraceMs;
92
93
  private readonly corsAllowed;
93
94
  private warnedProxyMisconfig;
95
+ /** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
96
+ private readonly warnedLegacyInstaller;
97
+ private readonly buildTickets;
94
98
  private pendingScreenshots;
95
99
  private readonly uiTreeTimeoutMs;
96
100
  private pendingUITrees;
@@ -109,6 +113,7 @@ export declare class RelayServer {
109
113
  key: string;
110
114
  };
111
115
  agentGraceMs?: number;
116
+ tunnel?: TunnelRuntime;
112
117
  });
113
118
  private registerRoutes;
114
119
  pushLog(msg: string): void;
@@ -410,6 +415,20 @@ export declare class RelayServer {
410
415
  * from silence and it waits out the deadline (#445). `Session not found` is app-specific for the
411
416
  * same reason: a generic `error` cannot be correlated by construction. */
412
417
  private handleBrowserAppInstall;
418
+ /**
419
+ * Says once, per agent, that this agent cannot fetch builds over HTTP.
420
+ *
421
+ * **The wording is conditional on purpose.** Such an agent installs perfectly well when it shares
422
+ * the relay's filesystem, which is every `tapflow start`, so "installs will fail" would be false
423
+ * for most of the people who see it. What it cannot do is install from a relay that is somewhere
424
+ * else — and the relay cannot tell which case this is: the dashboard hands agents a **LAN**
425
+ * address even for the same machine (`pickLanAddress`), so the socket's own address answers a
426
+ * different question than the one that matters.
427
+ *
428
+ * Once per identity, because `agent:register` runs again on every reconnect — a Wi-Fi blip or a
429
+ * laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
430
+ */
431
+ private warnLegacyInstaller;
413
432
  /** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
414
433
  private handleBrowserAppLaunch;
415
434
  private handleGetScreenshot;
@@ -420,6 +439,20 @@ export declare class RelayServer {
420
439
  private handleScreenshotError;
421
440
  private flushResourceBuffers;
422
441
  private handleRequest;
442
+ /**
443
+ * Serves one build to the agent that was just told to install it.
444
+ *
445
+ * **The ticket is the whole credential, and it travels in a header.** No PAT is asked for —
446
+ * requiring one would break `tapflow start`, whose agent connects on loopback with no token at
447
+ * all. In a URL the ticket would reach the request log the first time this handler threw
448
+ * (`router.ts` logs `${method} ${url}`, and its redaction only knows PAT and JWT shapes) and every
449
+ * reverse proxy's access log besides.
450
+ *
451
+ * The three failures are answered separately because a user does something different about each:
452
+ * a ticket nobody minted, a ticket that sat too long, and a file that went away between the mint
453
+ * and the fetch. Collapsing them is the defect this change exists to stop repeating.
454
+ */
455
+ private handleBuildDownload;
423
456
  private serveUpload;
424
457
  private serveStatic;
425
458
  }
@@ -1 +1 @@
1
- {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"RelayServer.d.ts","sourceRoot":"","sources":["../src/RelayServer.ts"],"names":[],"mappings":"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"}
@@ -11,9 +11,12 @@ 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
- import { pickLanAddress } from './lib/lanAddress.js';
17
+ import { pickLanAddress, runningInContainer } from './lib/lanAddress.js';
18
+ import { config } from './lib/config.js';
19
+ import { forTeammates, resolveAgentRelayUrl, resolvePublicBaseUrl } from './lib/publicUrl.js';
17
20
  import { createTrailingRequester, systemTimerScheduler } from './lib/trailingRequester.js';
18
21
  import { getDb } from './db.js';
19
22
  import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit, handleAuthStatus } from './api/auth.js';
@@ -229,6 +232,9 @@ export class RelayServer {
229
232
  corsAllowed;
230
233
  // One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
231
234
  warnedProxyMisconfig = false;
235
+ /** Agent identities already warned about a missing `build-download`. See `warnLegacyInstaller`. */
236
+ warnedLegacyInstaller = new Set();
237
+ buildTickets = new BuildTicketStore();
232
238
  pendingScreenshots = new Map();
233
239
  uiTreeTimeoutMs;
234
240
  pendingUITrees = new Map();
@@ -301,6 +307,12 @@ export class RelayServer {
301
307
  this.router.post('/api/v1/builds/:id/schedule-deletion', handleScheduleBuildDeletion);
302
308
  this.router.delete('/api/v1/builds/:id/schedule-deletion', handleCancelBuildDeletion);
303
309
  this.router.post('/api/v1/builds', (req, res) => handleUploadBuild(req, res, u));
310
+ // **Deliberately not under `/api/v1/builds/`.** `:id` compiles to `([^/]+)` and the router
311
+ // returns on the first registered match, so `/api/v1/builds/download` is captured by
312
+ // `/api/v1/builds/:id` as `id="download"` — `requireBuildAuth` then answers 401 to a ticketed
313
+ // agent carrying no PAT. Registration order could avoid it, which is exactly the objection:
314
+ // that makes the order load-bearing with nothing to say so.
315
+ this.router.get('/api/v1/build-download', (req, res) => this.handleBuildDownload(req, res));
304
316
  // webhooks (outbound build-status notifications)
305
317
  this.router.get('/api/v1/webhooks', handleListWebhooks);
306
318
  this.router.post('/api/v1/webhooks', handleCreateWebhook);
@@ -312,7 +324,7 @@ export class RelayServer {
312
324
  this.router.delete('/api/v1/comments/:id', handleDeleteComment);
313
325
  // team
314
326
  this.router.get('/api/v1/team/members', handleListMembers);
315
- this.router.post('/api/v1/team/invite', handleInvite);
327
+ this.router.post('/api/v1/team/invite', (req, res) => handleInvite(req, res, this.options.tunnel));
316
328
  this.router.patch('/api/v1/team/members/:id', handleUpdateMember);
317
329
  this.router.delete('/api/v1/team/members/:id', handleDeleteMember);
318
330
  this.router.post('/api/v1/team/members/:id/send-reset', handleSendMemberReset);
@@ -336,14 +348,22 @@ export class RelayServer {
336
348
  res.writeHead(200, { 'Content-Type': 'application/json' });
337
349
  res.end(JSON.stringify(this.logBuffer.slice(-lines)));
338
350
  });
339
- // relay host — 대시보드가 agent 실행 커맨드에 박을 LAN 주소 (뷰어가 localhost로 접속한 경우의 치환용, #271)
351
+ // What the dashboard needs to build an address for someone else (#271, #788): a link base for a
352
+ // teammate's browser and an agent address, reported separately because they come from different
353
+ // settings (see lib/publicUrl.ts). Config comes from the singleton, as in the team handlers; whether a
354
+ // tunnel actually came up is known only to the entry point that started it, hence the `tunnel` option.
340
355
  this.router.get('/api/v1/relay/host', (req, res) => {
341
356
  if (!requireAuth(req, res))
342
357
  return;
343
358
  const addr = this.httpServer.address();
344
359
  const port = typeof addr === 'object' && addr !== null ? addr.port : this.options.port;
345
360
  res.writeHead(200, { 'Content-Type': 'application/json' });
346
- res.end(JSON.stringify({ lanHost: pickLanAddress(os.networkInterfaces()), port }));
361
+ res.end(JSON.stringify({
362
+ lanHost: runningInContainer() ? null : pickLanAddress(os.networkInterfaces()),
363
+ port,
364
+ publicBaseUrl: forTeammates(resolvePublicBaseUrl(config, this.options.tunnel)),
365
+ agentRelayUrl: forTeammates(resolveAgentRelayUrl(config)),
366
+ }));
347
367
  });
348
368
  // agent resources
349
369
  this.router.get('/api/v1/agents', handleListAgents);
@@ -1418,6 +1438,7 @@ export class RelayServer {
1418
1438
  // its socket before creating the new ones. Identity is agentId (unique per Mac) when present,
1419
1439
  // else agentName. (Heartbeat backstop for never-reconnecting agents: #313.)
1420
1440
  const identity = msg.agentId ?? msg.agentName;
1441
+ this.warnLegacyInstaller(identity, msg.capabilities ?? []);
1421
1442
  // Deduplicate first. Everything below is keyed by device id, so a payload naming one device
1422
1443
  // twice would collapse to a single entry in `registeredSessions` while `create()` had already
1423
1444
  // made two sessions — leaving one the agent is never told about. That is the same orphan the
@@ -1956,13 +1977,56 @@ export class RelayServer {
1956
1977
  // agent's reply is forwarded back generically without the relay looking at it. So if the id does not
1957
1978
  // reach the agent, nothing downstream can attribute the reply. Nothing type-checks that the value is
1958
1979
  // the *request's*: a brand cannot express provenance, so a test carries it.
1980
+ // **`statSync`, and this handler stays synchronous.** Its caller does not await it
1981
+ // (`case 'app:install'` above), and the message loop's `catch` is synchronous — so making this
1982
+ // `async` would turn the `getDb()` throw named in the comment at the top of this function
1983
+ // (SQLITE_BUSY, a closed db, I/O) from "logged, one request hangs" into an unhandled rejection,
1984
+ // and the CLI exits the process on those. The relay would die with every session on it.
1985
+ //
1986
+ // A missing file is answered rather than thrown for the same reason the lookup above is: the
1987
+ // build row outlives its file whenever a bind mount goes away or a purge half-ran, and an
1988
+ // unanswered install is a spinner that never stops and a caller that times out with no cause.
1989
+ let bytes;
1990
+ try {
1991
+ bytes = fs.statSync(build.file_path).size;
1992
+ }
1993
+ catch {
1994
+ return fail('The relay cannot read this build file. It may have been deleted — re-upload the build.');
1995
+ }
1959
1996
  this.sendTo(session.agentSocket, {
1960
1997
  type: 'app:install',
1961
1998
  sessionId,
1962
1999
  requestId,
1963
- payload: { filePath: build.file_path, bundleId: build.bundle_id },
2000
+ payload: {
2001
+ // Still sent, and sent first: an agent that predates `build-download` reads only this.
2002
+ filePath: build.file_path,
2003
+ bundleId: build.bundle_id,
2004
+ buildTicket: this.buildTickets.mint(msg.buildId, build.file_path),
2005
+ buildName: path.basename(build.file_path),
2006
+ buildBytes: bytes,
2007
+ },
1964
2008
  });
1965
2009
  }
2010
+ /**
2011
+ * Says once, per agent, that this agent cannot fetch builds over HTTP.
2012
+ *
2013
+ * **The wording is conditional on purpose.** Such an agent installs perfectly well when it shares
2014
+ * the relay's filesystem, which is every `tapflow start`, so "installs will fail" would be false
2015
+ * for most of the people who see it. What it cannot do is install from a relay that is somewhere
2016
+ * else — and the relay cannot tell which case this is: the dashboard hands agents a **LAN**
2017
+ * address even for the same machine (`pickLanAddress`), so the socket's own address answers a
2018
+ * different question than the one that matters.
2019
+ *
2020
+ * Once per identity, because `agent:register` runs again on every reconnect — a Wi-Fi blip or a
2021
+ * laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
2022
+ */
2023
+ warnLegacyInstaller(identity, capabilities) {
2024
+ if (capabilities.includes('build-download') || this.warnedLegacyInstaller.has(identity))
2025
+ return;
2026
+ this.warnedLegacyInstaller.add(identity);
2027
+ logger.warn(`[relay] agent ${identity} predates build downloading. If it is not on the same machine as ` +
2028
+ 'this relay, installing a build will fail — update the agent to match the relay.');
2029
+ }
1966
2030
  /** Relay looks up bundle_id from DB. Same correlation rules as `handleBrowserAppInstall`. */
1967
2031
  handleBrowserAppLaunch(ws, msg) {
1968
2032
  const sessionId = msg.sessionId;
@@ -2180,6 +2244,52 @@ export class RelayServer {
2180
2244
  // SPA static fallback
2181
2245
  this.serveStatic(req, res);
2182
2246
  }
2247
+ /**
2248
+ * Serves one build to the agent that was just told to install it.
2249
+ *
2250
+ * **The ticket is the whole credential, and it travels in a header.** No PAT is asked for —
2251
+ * requiring one would break `tapflow start`, whose agent connects on loopback with no token at
2252
+ * all. In a URL the ticket would reach the request log the first time this handler threw
2253
+ * (`router.ts` logs `${method} ${url}`, and its redaction only knows PAT and JWT shapes) and every
2254
+ * reverse proxy's access log besides.
2255
+ *
2256
+ * The three failures are answered separately because a user does something different about each:
2257
+ * a ticket nobody minted, a ticket that sat too long, and a file that went away between the mint
2258
+ * and the fetch. Collapsing them is the defect this change exists to stop repeating.
2259
+ */
2260
+ handleBuildDownload(req, res) {
2261
+ const ticket = req.headers['x-tapflow-build-ticket'];
2262
+ if (typeof ticket !== 'string' || ticket === '') {
2263
+ return json(res, 401, { error: 'Missing X-Tapflow-Build-Ticket' });
2264
+ }
2265
+ const found = this.buildTickets.redeem(ticket);
2266
+ if (found === 'unknown') {
2267
+ return json(res, 404, { error: 'This build ticket is not valid. It may already have been used.' });
2268
+ }
2269
+ if (found === 'expired') {
2270
+ return json(res, 410, { error: 'This build ticket expired before it was used.' });
2271
+ }
2272
+ let size;
2273
+ try {
2274
+ size = fs.statSync(found.filePath).size;
2275
+ }
2276
+ catch {
2277
+ return json(res, 404, { error: 'The build file is no longer on the relay.' });
2278
+ }
2279
+ // Set even though the agent compares against `buildBytes` instead: a correct header costs
2280
+ // nothing and helps anything else that looks. It is not the check — a proxy may drop it.
2281
+ res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Content-Length': size });
2282
+ // **`pipe` does not destroy its source when the destination goes away** — it only unpipes — so an
2283
+ // aborted download leaves the read stream and its file descriptor open for the life of the
2284
+ // process. Measured: after a client abort, `rs.destroyed` is still false and the fd still stats.
2285
+ // On this route an abort is the *ordinary* failure, not an edge case: the agent's own idle
2286
+ // timeout destroys the request, and so does an agent that exits or a link that drops. Enough of
2287
+ // them and the relay hits its descriptor limit and stops accepting connections at all.
2288
+ const rs = fs.createReadStream(found.filePath);
2289
+ res.on('close', () => { rs.destroy(); });
2290
+ rs.on('error', () => { res.destroy(); });
2291
+ rs.pipe(res);
2292
+ }
2183
2293
  serveUpload(req, res) {
2184
2294
  const urlPath = (req.url ?? '/').split('?')[0];
2185
2295
  const filePath = path.join(this.uploadsDir, urlPath.replace('/uploads/', ''));
@@ -2196,9 +2306,11 @@ export class RelayServer {
2196
2306
  }
2197
2307
  const contentType = MIME_TYPES[path.extname(filePath)] ?? 'application/octet-stream';
2198
2308
  res.writeHead(200, { 'Content-Type': contentType });
2199
- fs.createReadStream(filePath)
2200
- .on('error', () => { res.destroy(); })
2201
- .pipe(res);
2309
+ // Same leak as `handleBuildDownload` above, and the same one line closes it.
2310
+ const rs = fs.createReadStream(filePath);
2311
+ res.on('close', () => { rs.destroy(); });
2312
+ rs.on('error', () => { res.destroy(); });
2313
+ rs.pipe(res);
2202
2314
  }
2203
2315
  serveStatic(req, res) {
2204
2316
  const urlPath = (req.url ?? '/').split('?')[0];
@@ -2222,34 +2334,69 @@ export class RelayServer {
2222
2334
  const contentType = MIME_TYPES[path.extname(filePath)] ?? 'text/html';
2223
2335
  const headers = { 'Content-Type': contentType };
2224
2336
  // Content-hashed build assets never change → cache them forever.
2337
+ // HTML entry points must revalidate on every load so updates are picked up immediately.
2225
2338
  if (urlPath.startsWith('/assets/')) {
2226
2339
  headers['Cache-Control'] = 'public, max-age=31536000, immutable';
2227
2340
  }
2228
- // Serve the build-time .br sibling when accepted (precompressed → no runtime CPU on the stream path).
2341
+ else if (filePath.endsWith('index.html')) {
2342
+ headers['Cache-Control'] = 'no-cache';
2343
+ }
2344
+ // Serve precompressed siblings (.br or .gz) when accepted (precompressed → no runtime CPU on the stream path).
2229
2345
  const acceptHeader = req.headers['accept-encoding'];
2230
2346
  const accept = Array.isArray(acceptHeader) ? acceptHeader.join(',') : acceptHeader ?? '';
2231
- const brAccepted = accept.split(',').some((token) => {
2232
- const [name, ...params] = token.trim().split(';');
2233
- const coding = name.trim().toLowerCase();
2234
- if (coding !== 'br' && coding !== '*')
2235
- return false;
2236
- const qParam = params.map((p) => p.trim()).find((p) => p.startsWith('q='));
2237
- const q = qParam ? Number(qParam.slice(2)) : 1;
2238
- return !Number.isNaN(q) && q > 0;
2239
- });
2347
+ // **An explicit coding outranks `*`, rather than being maxed with it.** RFC 9110 §12.5.3 gives
2348
+ // the more specific match precedence, and taking the maximum across both meant
2349
+ // `Accept-Encoding: br;q=0, *` answered 1 for `br` — serving brotli to a client that named it
2350
+ // and refused it. The two are tracked apart and the wildcard is consulted only when the coding
2351
+ // was never named.
2352
+ const parseQuality = (codingName) => {
2353
+ let named = -1;
2354
+ let wildcard = -1;
2355
+ for (const token of accept.split(',')) {
2356
+ const [name, ...params] = token.trim().split(';');
2357
+ const coding = name.trim().toLowerCase();
2358
+ if (coding !== codingName && coding !== '*')
2359
+ continue;
2360
+ // Lowercased because RFC 9110 §5.6.6 makes parameter names case-insensitive: `br;Q=0` was
2361
+ // missed, fell through to the default weight of 1, and served brotli to a client that had
2362
+ // refused it. The qvalue itself is numeric, so folding the whole token is safe.
2363
+ const qParam = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith('q='));
2364
+ const q = qParam ? Number(qParam.slice(2)) : 1;
2365
+ if (Number.isNaN(q))
2366
+ continue;
2367
+ if (coding === '*')
2368
+ wildcard = Math.max(wildcard, q);
2369
+ else
2370
+ named = Math.max(named, q);
2371
+ }
2372
+ return named >= 0 ? named : wildcard;
2373
+ };
2374
+ const brQ = parseQuality('br');
2375
+ const gzQ = Math.max(parseQuality('gzip'), parseQuality('x-gzip'));
2376
+ // **`Vary` unconditionally, and that is what lets the probes below be skipped.** It declares a
2377
+ // cache key rather than describing this response: a shared cache that stored the uncompressed
2378
+ // body without it would hand that body to the next client, which may accept brotli. Deriving it
2379
+ // from "does a sibling exist" is what forced a `stat` on every static request even when the
2380
+ // client asked for no encoding at all — a syscall per asset per request, on the path this exists
2381
+ // to make cheaper. Set it always, and ask the filesystem only about codings that could be served.
2382
+ headers['Vary'] = 'Accept-Encoding';
2383
+ const hasBr = brQ > 0 && fs.existsSync(filePath + '.br');
2384
+ const hasGz = gzQ > 0 && fs.existsSync(filePath + '.gz');
2240
2385
  let servePath = filePath;
2241
- const hasBr = fs.existsSync(filePath + '.br');
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) {
2386
+ if (brQ > 0 && hasBr && (gzQ <= 0 || !hasGz || brQ >= gzQ)) {
2246
2387
  servePath = filePath + '.br';
2247
2388
  headers['Content-Encoding'] = 'br';
2248
2389
  }
2390
+ else if (gzQ > 0 && hasGz) {
2391
+ servePath = filePath + '.gz';
2392
+ headers['Content-Encoding'] = 'gzip';
2393
+ }
2249
2394
  res.writeHead(200, headers);
2250
- fs.createReadStream(servePath)
2251
- .on('error', () => { res.destroy(); })
2252
- .pipe(res);
2395
+ // Same leak as `handleBuildDownload` above, and the same one line closes it.
2396
+ const rs = fs.createReadStream(servePath);
2397
+ res.on('close', () => { rs.destroy(); });
2398
+ rs.on('error', () => { res.destroy(); });
2399
+ rs.pipe(res);
2253
2400
  }
2254
2401
  }
2255
2402
  //# sourceMappingURL=RelayServer.js.map