@tapflowio/relay 0.26.0 → 0.27.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 +81 -3
- package/dist/RelayServer.d.ts.map +1 -1
- package/dist/RelayServer.js +278 -17
- package/dist/RelayServer.js.map +1 -1
- package/dist/SessionManager.d.ts +5 -3
- package/dist/SessionManager.d.ts.map +1 -1
- package/dist/SessionManager.js +3 -1
- package/dist/SessionManager.js.map +1 -1
- package/dist/api/auth.d.ts +2 -1
- package/dist/api/auth.d.ts.map +1 -1
- package/dist/api/auth.js +1 -1
- package/dist/api/auth.js.map +1 -1
- package/dist/api/passwordReset.d.ts +2 -1
- package/dist/api/passwordReset.d.ts.map +1 -1
- package/dist/api/passwordReset.js +1 -1
- package/dist/api/passwordReset.js.map +1 -1
- package/dist/api/team.d.ts +3 -2
- package/dist/api/team.d.ts.map +1 -1
- package/dist/api/team.js +2 -2
- package/dist/api/team.js.map +1 -1
- package/dist/api/tokens.d.ts +2 -1
- package/dist/api/tokens.d.ts.map +1 -1
- package/dist/api/tokens.js +1 -1
- package/dist/api/tokens.js.map +1 -1
- package/dist/lib/connectionAuth.d.ts +29 -0
- package/dist/lib/connectionAuth.d.ts.map +1 -1
- package/dist/lib/connectionAuth.js +15 -0
- package/dist/lib/connectionAuth.js.map +1 -1
- package/dist/lib/webhookTransport.d.ts +29 -0
- package/dist/lib/webhookTransport.d.ts.map +1 -0
- package/dist/lib/webhookTransport.js +86 -0
- package/dist/lib/webhookTransport.js.map +1 -0
- package/dist/lib/webhookUrl.d.ts +6 -0
- package/dist/lib/webhookUrl.d.ts.map +1 -1
- package/dist/lib/webhookUrl.js +50 -14
- package/dist/lib/webhookUrl.js.map +1 -1
- package/dist/lib/webhooks.d.ts +2 -10
- package/dist/lib/webhooks.d.ts.map +1 -1
- package/dist/lib/webhooks.js +8 -3
- package/dist/lib/webhooks.js.map +1 -1
- package/package.json +6 -6
- package/public/assets/{AppCenter-DZCsC2wa.js → AppCenter-BMdYtQbV.js} +7 -7
- package/public/assets/AppCenter-BMdYtQbV.js.br +0 -0
- package/public/assets/AppCenter-BMdYtQbV.js.gz +0 -0
- package/public/assets/Default-0DHHVGaN.js +1 -0
- package/public/assets/Default-0DHHVGaN.js.br +0 -0
- package/public/assets/Default-0DHHVGaN.js.gz +0 -0
- package/public/assets/Invite-C0ZB_HiU.js +1 -0
- package/public/assets/Invite-C0ZB_HiU.js.br +0 -0
- package/public/assets/Invite-C0ZB_HiU.js.gz +0 -0
- package/public/assets/{MacResources-B0DQ9ISE.js → MacResources-DmY5pRV2.js} +1 -1
- package/public/assets/MacResources-DmY5pRV2.js.br +0 -0
- package/public/assets/MacResources-DmY5pRV2.js.gz +0 -0
- package/public/assets/QASession-2QogWEX7.js +150 -0
- package/public/assets/QASession-2QogWEX7.js.br +0 -0
- package/public/assets/QASession-2QogWEX7.js.gz +0 -0
- package/public/assets/ResetPassword-jNRuertx.js +1 -0
- package/public/assets/ResetPassword-jNRuertx.js.br +0 -0
- package/public/assets/ResetPassword-jNRuertx.js.gz +0 -0
- package/public/assets/Setup-CF_FN9Dt.js +1 -0
- package/public/assets/Setup-CF_FN9Dt.js.br +0 -0
- package/public/assets/Setup-CF_FN9Dt.js.gz +0 -0
- package/public/assets/Team-BbiyOGau.js +6 -0
- package/public/assets/Team-BbiyOGau.js.br +0 -0
- package/public/assets/Team-BbiyOGau.js.gz +0 -0
- package/public/assets/Tokens-CZo4sPTx.js +1 -0
- package/public/assets/Tokens-CZo4sPTx.js.br +0 -0
- package/public/assets/Tokens-CZo4sPTx.js.gz +0 -0
- package/public/assets/alert-dialog-DFz8G_zu.js +1 -0
- package/public/assets/alert-dialog-DFz8G_zu.js.br +0 -0
- package/public/assets/alert-dialog-DFz8G_zu.js.gz +0 -0
- package/public/assets/badge-B3vaBVIQ.js +1 -0
- package/public/assets/badge-B3vaBVIQ.js.br +0 -0
- package/public/assets/badge-B3vaBVIQ.js.gz +0 -0
- package/public/assets/{build-format-fzt-JrUX.js → build-format-dbQPU3A4.js} +3 -3
- package/public/assets/build-format-dbQPU3A4.js.br +0 -0
- package/public/assets/build-format-dbQPU3A4.js.gz +0 -0
- package/public/assets/{dialog-DQkbaw8h.js → dialog-DeBPk-I3.js} +5 -5
- package/public/assets/dialog-DeBPk-I3.js.br +0 -0
- package/public/assets/dialog-DeBPk-I3.js.gz +0 -0
- package/public/assets/field-hint-CMKmbVpy.js +1 -0
- package/public/assets/field-hint-CMKmbVpy.js.br +0 -0
- package/public/assets/field-hint-CMKmbVpy.js.gz +0 -0
- package/public/assets/index-B4w4Lh3e.js +218 -0
- package/public/assets/index-B4w4Lh3e.js.br +0 -0
- package/public/assets/index-B4w4Lh3e.js.gz +0 -0
- package/public/assets/index-CDWu0GQL.css +1 -0
- package/public/assets/index-CDWu0GQL.css.br +0 -0
- package/public/assets/index-CDWu0GQL.css.gz +0 -0
- package/public/assets/list-view-BvINA5t4.js +1 -0
- package/public/assets/list-view-BvINA5t4.js.br +0 -0
- package/public/assets/list-view-BvINA5t4.js.gz +0 -0
- package/public/assets/{pencil-BJcOp3oN.js → pencil-CycAOoC_.js} +2 -2
- package/public/assets/pencil-CycAOoC_.js.br +0 -0
- package/public/assets/pencil-CycAOoC_.js.gz +0 -0
- package/public/assets/resource-health-C1qSG3MK.js +1 -0
- package/public/assets/resource-health-C1qSG3MK.js.br +0 -0
- package/public/assets/resource-health-C1qSG3MK.js.gz +0 -0
- package/public/assets/{trash-DhBxh6H7.js → trash-BXQnOI5U.js} +3 -3
- package/public/assets/trash-BXQnOI5U.js.br +0 -0
- package/public/assets/trash-BXQnOI5U.js.gz +0 -0
- package/public/index.html +2 -2
- package/public/index.html.br +0 -0
- package/public/index.html.gz +0 -0
- package/public/assets/AppCenter-DZCsC2wa.js.br +0 -0
- package/public/assets/AppCenter-DZCsC2wa.js.gz +0 -0
- package/public/assets/Default-xD9Fmd5X.js +0 -1
- package/public/assets/Default-xD9Fmd5X.js.br +0 -0
- package/public/assets/Default-xD9Fmd5X.js.gz +0 -0
- package/public/assets/Invite-29Erwv8j.js +0 -1
- package/public/assets/Invite-29Erwv8j.js.br +0 -0
- package/public/assets/Invite-29Erwv8j.js.gz +0 -0
- package/public/assets/MacResources-B0DQ9ISE.js.br +0 -0
- package/public/assets/MacResources-B0DQ9ISE.js.gz +0 -0
- package/public/assets/QASession-D-n6PXuD.js +0 -150
- package/public/assets/QASession-D-n6PXuD.js.br +0 -0
- package/public/assets/QASession-D-n6PXuD.js.gz +0 -0
- package/public/assets/ResetPassword-BnPIwTYH.js +0 -1
- package/public/assets/ResetPassword-BnPIwTYH.js.br +0 -0
- package/public/assets/ResetPassword-BnPIwTYH.js.gz +0 -0
- package/public/assets/Setup-DmUGFblg.js +0 -1
- package/public/assets/Setup-DmUGFblg.js.br +0 -0
- package/public/assets/Setup-DmUGFblg.js.gz +0 -0
- package/public/assets/Team-BpvRgFfZ.js +0 -6
- package/public/assets/Team-BpvRgFfZ.js.br +0 -0
- package/public/assets/Team-BpvRgFfZ.js.gz +0 -0
- package/public/assets/Tokens-x6BtCcL7.js +0 -1
- package/public/assets/Tokens-x6BtCcL7.js.br +0 -0
- package/public/assets/Tokens-x6BtCcL7.js.gz +0 -0
- package/public/assets/alert-dialog-C8wDimW-.js +0 -1
- package/public/assets/alert-dialog-C8wDimW-.js.br +0 -0
- package/public/assets/alert-dialog-C8wDimW-.js.gz +0 -0
- package/public/assets/badge-CWVbeuH3.js +0 -1
- package/public/assets/badge-CWVbeuH3.js.br +0 -0
- package/public/assets/badge-CWVbeuH3.js.gz +0 -0
- package/public/assets/build-format-fzt-JrUX.js.br +0 -0
- package/public/assets/build-format-fzt-JrUX.js.gz +0 -0
- package/public/assets/dialog-DQkbaw8h.js.br +0 -0
- package/public/assets/dialog-DQkbaw8h.js.gz +0 -0
- package/public/assets/index-C1Fo8g_c.js +0 -218
- package/public/assets/index-C1Fo8g_c.js.br +0 -0
- package/public/assets/index-C1Fo8g_c.js.gz +0 -0
- package/public/assets/index-C2F1AgwB.css +0 -1
- package/public/assets/index-C2F1AgwB.css.br +0 -0
- package/public/assets/index-C2F1AgwB.css.gz +0 -0
- package/public/assets/list-view-DxI397-H.js +0 -1
- package/public/assets/list-view-DxI397-H.js.br +0 -0
- package/public/assets/list-view-DxI397-H.js.gz +0 -0
- package/public/assets/pencil-BJcOp3oN.js.br +0 -0
- package/public/assets/pencil-BJcOp3oN.js.gz +0 -0
- package/public/assets/resource-health-Cq3gqqVd.js +0 -1
- package/public/assets/resource-health-Cq3gqqVd.js.br +0 -0
- package/public/assets/resource-health-Cq3gqqVd.js.gz +0 -0
- package/public/assets/trash-DhBxh6H7.js.br +0 -0
- package/public/assets/trash-DhBxh6H7.js.gz +0 -0
package/dist/RelayServer.js
CHANGED
|
@@ -9,7 +9,7 @@ import { SessionManager } from './SessionManager.js';
|
|
|
9
9
|
import { directionOf, parseInbound } from '@tapflowio/protocol/validate';
|
|
10
10
|
import { Router, json } from './router.js';
|
|
11
11
|
import { requireViewAuth, requireAuth, getAuth, verifyPat, touchPat, findPat, findUser } from './middleware/auth.js';
|
|
12
|
-
import { AGENT_SCOPE, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, revalidatePrincipal } from './lib/connectionAuth.js';
|
|
12
|
+
import { AGENT_SCOPE, WS_ACCESS_CHANGED_REASON, WS_AGENT_OWNER_REASON, WS_SCOPE_REASON, classifyConnection, isAffectedBy, revalidatePrincipal } from './lib/connectionAuth.js';
|
|
13
13
|
import { isTunnelIngress, markTunnelIngress, resolveClientAddress, resolveRequestClient } from './lib/clientAddress.js';
|
|
14
14
|
import { BuildTicketStore } from './lib/buildTickets.js';
|
|
15
15
|
import { resolveBuildFile } from './lib/buildFiles.js';
|
|
@@ -22,7 +22,7 @@ import { createTrailingRequester, systemTimerScheduler } from './lib/trailingReq
|
|
|
22
22
|
import { getDb } from './db.js';
|
|
23
23
|
import { handleLogin, handleLogout, handleMe, handleChangePassword, handleInit, handleAuthStatus } from './api/auth.js';
|
|
24
24
|
import { handleVerify, handleAccept } from './api/invitations.js';
|
|
25
|
-
import { createLogger } from '@tapflowio/agent-core';
|
|
25
|
+
import { createLogger, hasCapability } from '@tapflowio/agent-core';
|
|
26
26
|
import { createKeyframeAwareSender, createRateLimitedDropWarn, sendAudioYieldingToVideo, DEFAULT_BACKPRESSURE_BYTES, hasEnvelope, patchRelayedAt, readEnvelopeFlags, CODEC_JPEG, CODEC_AUDIO, } from '@tapflowio/agent-core/utils';
|
|
27
27
|
const logger = createLogger('relay');
|
|
28
28
|
// True when a remote IP is public — i.e. not loopback, private LAN, or link-local. Used to pick the
|
|
@@ -171,6 +171,19 @@ function describeRejection(failure, suppressed) {
|
|
|
171
171
|
const outcome = failure.reason === 'bad-payload' ? 'refused, and the sender told' : 'dropped';
|
|
172
172
|
return `[tapflow] inbound ${failure.type} does not match the contract — ${outcome}${also}:\n${failure.detail}`;
|
|
173
173
|
}
|
|
174
|
+
/** Just past `mcp-server`'s 30s shutdown deadline, so an entry outlives every caller still waiting on it. */
|
|
175
|
+
const SHUTDOWN_REQUESTER_TTL_MS = 35_000;
|
|
176
|
+
/**
|
|
177
|
+
* How long a tracked boot lives without an answer. The same horizon as both clients'
|
|
178
|
+
* `BOOT_DEADLINE_MS`: past it the caller has already timed out, so the entry is pure
|
|
179
|
+
* bookkeeping. Expiry forgets silently — it sends no failure, because the client timeout
|
|
180
|
+
* owns that case. This bounds the map, not the boot: it changes nothing about how long a
|
|
181
|
+
* boot may take, which is #588's question, not this one's.
|
|
182
|
+
*/
|
|
183
|
+
const PENDING_BOOT_TTL_MS = 180_000;
|
|
184
|
+
/** Said when a session ends with a shutdown unanswered — which is not the same as the shutdown failing. */
|
|
185
|
+
const SHUTDOWN_OUTCOME_LOST = 'The session ended before its agent confirmed the shutdown, so whether the device shut down is unknown.';
|
|
186
|
+
const shutdownRequesterKey = (sessionId, requestId) => `${sessionId}\n${requestId}`;
|
|
174
187
|
export class RelayServer {
|
|
175
188
|
options;
|
|
176
189
|
httpServer;
|
|
@@ -278,6 +291,9 @@ export class RelayServer {
|
|
|
278
291
|
backpressureBytes;
|
|
279
292
|
screenshotTimeoutMs;
|
|
280
293
|
agentGraceMs;
|
|
294
|
+
/** How long a tracked boot lives without an answer. An option so tests can shrink the
|
|
295
|
+
* 180s horizon; production always takes the default. */
|
|
296
|
+
pendingBootTtlMs;
|
|
281
297
|
corsAllowed;
|
|
282
298
|
// One-shot warning when XFF arrives on a loopback socket but TAPFLOW_TRUSTED_PROXIES is unset.
|
|
283
299
|
warnedProxyMisconfig = false;
|
|
@@ -287,6 +303,39 @@ export class RelayServer {
|
|
|
287
303
|
pendingScreenshots = new Map();
|
|
288
304
|
uiTreeTimeoutMs;
|
|
289
305
|
pendingUITrees = new Map();
|
|
306
|
+
/**
|
|
307
|
+
* Who asked for each correlated `device:shutdown` still waiting on its agent (#567). The agent's answer
|
|
308
|
+
* goes to the session's `browserSocket`, and a caller that never joined — an MCP client shutting down a
|
|
309
|
+
* session nobody holds, which `mayShutDown` permits — has no socket there, so it waited out its 30s for a
|
|
310
|
+
* shutdown that had happened. Keyed by session **and** id, because the id is chosen by the client and
|
|
311
|
+
* would alone let one session's answer reach another session's caller.
|
|
312
|
+
*
|
|
313
|
+
* **Settled when the session is removed, never when the agent socket drops — the opposite of a boot.**
|
|
314
|
+
* Both agents reconnect without restarting, and a shutdown that finishes after the reconnect is answered
|
|
315
|
+
* on the new socket, so an agent going away or being rebound is not evidence that the answer is lost.
|
|
316
|
+
* For a boot it is (#885): the binding a boot creates is exactly what a rebind loses. Folding the two
|
|
317
|
+
* into one rule would tell a caller its shutdown failed for a device that then powered off.
|
|
318
|
+
*/
|
|
319
|
+
shutdownRequesters = new Map();
|
|
320
|
+
/**
|
|
321
|
+
* In-flight `device:boot` requests, keyed by session and correlator (#885).
|
|
322
|
+
*
|
|
323
|
+
* The relay is the only layer that knows which agent socket each boot was actually dispatched
|
|
324
|
+
* to, so it owns settling the ones a rebind strands. The client cannot tell "issued before the
|
|
325
|
+
* rebound" from "delivered to the old agent", which is why the previous client-side settle on
|
|
326
|
+
* `session:rebound` answered boots the new agent was already handling. Entries record the
|
|
327
|
+
* concrete agent socket from the `device:boot` dispatch, so a rebind invalidates only the boots
|
|
328
|
+
* tied to the replaced socket and leaves a boot dispatched to the new socket alone.
|
|
329
|
+
*
|
|
330
|
+
* Cleared when a correlated `device:ready` or `device:boot-error` arrives, when the boot is
|
|
331
|
+
* invalidated at rebind, when its session is forgotten or evicted — and by its own expiry
|
|
332
|
+
* (`PENDING_BOOT_TTL_MS`) when none of those happen first, so a live agent that never answers
|
|
333
|
+
* cannot pin the entry past every caller that could have waited on it. An absent `requestId`
|
|
334
|
+
* answers no request (protocol/AGENTS.md 「Lifecycle correlation」), so id-less replies clear
|
|
335
|
+
* nothing. A leftover entry is harmless: its id names no waiter, so both clients and the
|
|
336
|
+
* dashboard drop the synthetic error it would produce.
|
|
337
|
+
*/
|
|
338
|
+
pendingBoots = new Map();
|
|
290
339
|
constructor(options) {
|
|
291
340
|
this.options = options;
|
|
292
341
|
// 0 on both sides is two ephemeral ports, which is what the tests ask for.
|
|
@@ -311,6 +360,7 @@ export class RelayServer {
|
|
|
311
360
|
const graceEnv = graceRaw ? Number(graceRaw) : NaN;
|
|
312
361
|
const graceUsable = Number.isFinite(graceEnv) && graceEnv >= 0;
|
|
313
362
|
this.agentGraceMs = options.agentGraceMs ?? (graceUsable ? graceEnv : DEFAULT_AGENT_GRACE_MS);
|
|
363
|
+
this.pendingBootTtlMs = options.pendingBootTtlMs ?? PENDING_BOOT_TTL_MS;
|
|
314
364
|
// Say so rather than only documenting it. Both times this parsing was wrong the symptom was
|
|
315
365
|
// the same — the hold switched off and nothing mentioned it — and somebody who types `15s` is
|
|
316
366
|
// reading their terminal, not the configuration table.
|
|
@@ -550,6 +600,13 @@ export class RelayServer {
|
|
|
550
600
|
for (const requester of this.networkStateRequesters.values())
|
|
551
601
|
requester.dispose();
|
|
552
602
|
this.networkStateRequesters.clear();
|
|
603
|
+
for (const entry of this.shutdownRequesters.values())
|
|
604
|
+
clearTimeout(entry.timer);
|
|
605
|
+
this.shutdownRequesters.clear();
|
|
606
|
+
// Same argument as the holds above: an expiry firing after `stop()` would run against a dead
|
|
607
|
+
// server, and an unref'd 180s timer still outlives the test runner's process.
|
|
608
|
+
for (const key of this.pendingBoots.keys())
|
|
609
|
+
this.forgetBoot(key);
|
|
553
610
|
const closeTunnel = new Promise((resolve, reject) => {
|
|
554
611
|
// Not listening means `start()` never reached it, or its listen failed; `close()` would reject.
|
|
555
612
|
if (!this.tunnelServer?.listening)
|
|
@@ -618,15 +675,22 @@ export class RelayServer {
|
|
|
618
675
|
}
|
|
619
676
|
/**
|
|
620
677
|
* Injected into the handlers whose write can take access away — member update and removal, token
|
|
621
|
-
* revocation, a password reset or change — and called after the write commits.
|
|
622
|
-
*
|
|
623
|
-
*
|
|
678
|
+
* revocation, a password reset or change — and called after the write commits. The sweep re-derives
|
|
679
|
+
* every socket's standing from the database, so when the database answers, no handler has to know which
|
|
680
|
+
* sockets its change affects.
|
|
681
|
+
*
|
|
682
|
+
* `affected` names the credential the write just reduced (`AuthChange`), and matters only when a lookup
|
|
683
|
+
* fails: the sockets resting on that credential are closed anyway (fail closed), because the one thing
|
|
684
|
+
* known for certain is that it just went down. Scoped to what the write touched, so revoking an unused
|
|
685
|
+
* CI token cannot take down the owner's agents and every session running on them. Every other socket is
|
|
686
|
+
* left alone as before.
|
|
624
687
|
*/
|
|
625
|
-
onAuthChanged = () => {
|
|
626
|
-
this.revalidateSockets(this.wss.clients);
|
|
688
|
+
onAuthChanged = (affected) => {
|
|
689
|
+
this.revalidateSockets(this.wss.clients, affected);
|
|
627
690
|
};
|
|
628
|
-
revalidateSockets(clients) {
|
|
691
|
+
revalidateSockets(clients, affected) {
|
|
629
692
|
const now = Math.floor(Date.now() / 1000);
|
|
693
|
+
let fault = null;
|
|
630
694
|
for (const ws of clients) {
|
|
631
695
|
const principal = this.principals.get(ws);
|
|
632
696
|
if (!principal || this.revokedSockets.has(ws) || ws.readyState !== WebSocket.OPEN)
|
|
@@ -651,13 +715,20 @@ export class RelayServer {
|
|
|
651
715
|
}
|
|
652
716
|
catch (err) {
|
|
653
717
|
// A database fault is not a revocation: closing every socket on a transient `SQLITE_BUSY` would
|
|
654
|
-
// disconnect the whole team
|
|
655
|
-
|
|
656
|
-
|
|
718
|
+
// disconnect the whole team, so the others wait for the next sweep. The exception is the member
|
|
719
|
+
// whose access this sweep was run for: a write just took it away, and letting their socket keep
|
|
720
|
+
// sending until the database recovers is the hole this closes. Keep going rather than stopping at
|
|
721
|
+
// the first fault, or a socket of that member later in the set would never be reached.
|
|
722
|
+
fault ??= err;
|
|
723
|
+
if (affected && isAffectedBy(principal, affected))
|
|
724
|
+
this.closeForAuth(ws, WS_ACCESS_CHANGED_REASON);
|
|
725
|
+
continue;
|
|
657
726
|
}
|
|
658
727
|
if (reason !== null)
|
|
659
728
|
this.closeForAuth(ws, reason);
|
|
660
729
|
}
|
|
730
|
+
if (fault !== null)
|
|
731
|
+
logger.error('socket re-validation failed:', fault);
|
|
661
732
|
}
|
|
662
733
|
closeForAuth(ws, reason) {
|
|
663
734
|
this.revokedSockets.add(ws);
|
|
@@ -743,6 +814,13 @@ export class RelayServer {
|
|
|
743
814
|
this.idrRequesters.delete(sessionId);
|
|
744
815
|
this.networkStateRequesters.get(sessionId)?.dispose();
|
|
745
816
|
this.networkStateRequesters.delete(sessionId);
|
|
817
|
+
// Tracked boots die with the session too (#885 review). These paths — `session:end`,
|
|
818
|
+
// `session:leave`, the cross-identity removal — bypass `evictAgentSocket`, so without this
|
|
819
|
+
// the entry outlives the session it names. Harmless but unbounded: a late correlated reply
|
|
820
|
+
// still clears via `settleBoot`, and `invalidateBootsFor` sends nothing for a gone session,
|
|
821
|
+
// so this only bounds the map. Scoped by session, never by socket, so a rebound session's
|
|
822
|
+
// entry — which lives on under the same id — is untouched.
|
|
823
|
+
this.forgetEvictedBoots(new Set([sessionId]));
|
|
746
824
|
}
|
|
747
825
|
/**
|
|
748
826
|
* Throttled callback asking the session's agent for an on-demand IDR (fast drop recovery); ignored by
|
|
@@ -1010,6 +1088,7 @@ export class RelayServer {
|
|
|
1010
1088
|
ws.on('close', () => {
|
|
1011
1089
|
this.wsRoles.delete(ws);
|
|
1012
1090
|
this.wsExternal.delete(ws);
|
|
1091
|
+
this.forgetShutdownRequesters(ws);
|
|
1013
1092
|
// Agent main socket disconnected → hold its sessions open for a moment in case the agent is
|
|
1014
1093
|
// coming back (#426), rather than ending them where they stand.
|
|
1015
1094
|
if (this.holdAgentSocket(ws))
|
|
@@ -1223,7 +1302,12 @@ export class RelayServer {
|
|
|
1223
1302
|
// `session:leave-error` would grow the wire for a message no consumer reads — and `session:end` has
|
|
1224
1303
|
// no in-repo sender at all, so it would be a reply to nobody.
|
|
1225
1304
|
case 'session:end': {
|
|
1226
|
-
|
|
1305
|
+
const ended = this.sessions.get(msg.sessionId);
|
|
1306
|
+
if (this.ownsSession(ws, ended)) {
|
|
1307
|
+
// Silently: the holder ended it, and the shutdown may still complete on the device — saying it
|
|
1308
|
+
// failed would be a guess. The caller keeps its own deadline, as it did before #567.
|
|
1309
|
+
if (ended)
|
|
1310
|
+
this.settleShutdownRequesters(ended, null);
|
|
1227
1311
|
this.sessions.remove(msg.sessionId);
|
|
1228
1312
|
this.forgetSessionState(msg.sessionId);
|
|
1229
1313
|
}
|
|
@@ -1307,6 +1391,10 @@ export class RelayServer {
|
|
|
1307
1391
|
break;
|
|
1308
1392
|
}
|
|
1309
1393
|
case 'device:boot-error': {
|
|
1394
|
+
// A correlated reply answers a tracked boot, whatever else it does — so the entry goes
|
|
1395
|
+
// even when nobody is left to forward to. An id-less frame answers no request, so it
|
|
1396
|
+
// clears nothing.
|
|
1397
|
+
this.settleBoot(msg.sessionId, msg.requestId);
|
|
1310
1398
|
const session = this.sessions.get(msg.sessionId);
|
|
1311
1399
|
if (session?.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1312
1400
|
session.browserSocket.send(JSON.stringify(raw));
|
|
@@ -1322,6 +1410,19 @@ export class RelayServer {
|
|
|
1322
1410
|
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1323
1411
|
session.browserSocket.send(JSON.stringify(raw));
|
|
1324
1412
|
}
|
|
1413
|
+
this.answerShutdownRequester(session, msg.requestId, raw);
|
|
1414
|
+
break;
|
|
1415
|
+
}
|
|
1416
|
+
case 'device:shutdown-error': {
|
|
1417
|
+
// The agent's half of the pair (#455). Device status is left alone: the shutdown was attempted
|
|
1418
|
+
// and not confirmed, so the device may well still be running.
|
|
1419
|
+
const session = this.sessions.get(msg.sessionId);
|
|
1420
|
+
if (!session)
|
|
1421
|
+
break;
|
|
1422
|
+
if (session.browserSocket?.readyState === WebSocket.OPEN) {
|
|
1423
|
+
session.browserSocket.send(JSON.stringify(raw));
|
|
1424
|
+
}
|
|
1425
|
+
this.answerShutdownRequester(session, msg.requestId, raw);
|
|
1325
1426
|
break;
|
|
1326
1427
|
}
|
|
1327
1428
|
case 'device:ready': {
|
|
@@ -1330,6 +1431,8 @@ export class RelayServer {
|
|
|
1330
1431
|
// an agent that omits it resolves no session here, exactly as before.
|
|
1331
1432
|
if (msg.sessionId === undefined)
|
|
1332
1433
|
break;
|
|
1434
|
+
// Correlated only: a replay or an old agent's id-less ready answers no tracked boot.
|
|
1435
|
+
this.settleBoot(msg.sessionId, msg.requestId);
|
|
1333
1436
|
const session = this.sessions.get(msg.sessionId);
|
|
1334
1437
|
if (!session)
|
|
1335
1438
|
break;
|
|
@@ -1383,7 +1486,7 @@ export class RelayServer {
|
|
|
1383
1486
|
// These two shared one fall-through clause. Separated because the sharing is the trap for
|
|
1384
1487
|
// whoever adds the door gate: a correlator check written into a shared body would gate
|
|
1385
1488
|
// `device:shutdown` too, and the relay originates that message with no id — so the dashboard's
|
|
1386
|
-
//
|
|
1489
|
+
// three id-less senders and the relay's own idle timer would stop reaching the agent, silently, in the
|
|
1387
1490
|
// one direction no reply reports. That gate is a schema now and cannot be written into a case at
|
|
1388
1491
|
// all, which is what makes the trap unreachable rather than merely avoided.
|
|
1389
1492
|
case 'device:boot': {
|
|
@@ -1422,6 +1525,9 @@ export class RelayServer {
|
|
|
1422
1525
|
// presence check the old line carried is gone because the schema requires the payload — and
|
|
1423
1526
|
// `external` is deliberately not declared on `DeviceBoot`, since the browser never sends it.
|
|
1424
1527
|
msg.payload['external'] = this.wsExternal.get(ws) ?? false;
|
|
1528
|
+
// Tracked by the socket it actually goes to (#885): a rebind invalidates only the boots
|
|
1529
|
+
// tied to the replaced socket, so a boot dispatched after the rebind stays answerable.
|
|
1530
|
+
this.rememberBoot(msg.sessionId, msg.requestId, boot.session.agentSocket);
|
|
1425
1531
|
// The parse product, so a key a viewer appended from devtools is gone before the agent sees it.
|
|
1426
1532
|
boot.session.agentSocket.send(JSON.stringify(msg));
|
|
1427
1533
|
break;
|
|
@@ -1464,6 +1570,7 @@ export class RelayServer {
|
|
|
1464
1570
|
});
|
|
1465
1571
|
break;
|
|
1466
1572
|
}
|
|
1573
|
+
this.rememberShutdownRequester(ws, target.session.id, msg.requestId);
|
|
1467
1574
|
target.session.agentSocket.send(JSON.stringify(msg));
|
|
1468
1575
|
break;
|
|
1469
1576
|
}
|
|
@@ -1629,6 +1736,58 @@ export class RelayServer {
|
|
|
1629
1736
|
* Messages the relay merely *forwards* do not use this: they are re-serialised unchanged, so
|
|
1630
1737
|
* they keep their inbound type and there is nothing new to check.
|
|
1631
1738
|
*/
|
|
1739
|
+
rememberShutdownRequester(ws, sessionId, requestId) {
|
|
1740
|
+
// The idle timer's shutdown and the dashboard's teardown carry no id, and nobody waits on either.
|
|
1741
|
+
if (requestId === undefined)
|
|
1742
|
+
return;
|
|
1743
|
+
const key = shutdownRequesterKey(sessionId, requestId);
|
|
1744
|
+
const prior = this.shutdownRequesters.get(key);
|
|
1745
|
+
if (prior)
|
|
1746
|
+
clearTimeout(prior.timer);
|
|
1747
|
+
// Bounds an entry an older agent will never answer — it has no failure reply to send.
|
|
1748
|
+
const timer = setTimeout(() => this.shutdownRequesters.delete(key), SHUTDOWN_REQUESTER_TTL_MS);
|
|
1749
|
+
this.shutdownRequesters.set(key, { sessionId, requestId, ws, timer });
|
|
1750
|
+
}
|
|
1751
|
+
/** Hands the agent's answer to whoever asked for it, when that is not the holder — who already has it. */
|
|
1752
|
+
answerShutdownRequester(session, requestId, raw) {
|
|
1753
|
+
if (requestId === undefined)
|
|
1754
|
+
return;
|
|
1755
|
+
const key = shutdownRequesterKey(session.id, requestId);
|
|
1756
|
+
const entry = this.shutdownRequesters.get(key);
|
|
1757
|
+
if (!entry)
|
|
1758
|
+
return;
|
|
1759
|
+
clearTimeout(entry.timer);
|
|
1760
|
+
this.shutdownRequesters.delete(key);
|
|
1761
|
+
// Compared now rather than at dispatch: a caller that has joined since holds the session, and was sent
|
|
1762
|
+
// this a line ago.
|
|
1763
|
+
if (entry.ws === session.browserSocket)
|
|
1764
|
+
return;
|
|
1765
|
+
if (entry.ws.readyState === WebSocket.OPEN)
|
|
1766
|
+
entry.ws.send(JSON.stringify(raw));
|
|
1767
|
+
}
|
|
1768
|
+
/**
|
|
1769
|
+
* The session is being removed, so an answer from its agent — if one ever comes — is dropped at
|
|
1770
|
+
* `sessions.get`. `message` null settles in silence; otherwise a caller that never joined is told.
|
|
1771
|
+
*/
|
|
1772
|
+
settleShutdownRequesters(session, message) {
|
|
1773
|
+
for (const [key, entry] of this.shutdownRequesters) {
|
|
1774
|
+
if (entry.sessionId !== session.id)
|
|
1775
|
+
continue;
|
|
1776
|
+
clearTimeout(entry.timer);
|
|
1777
|
+
this.shutdownRequesters.delete(key);
|
|
1778
|
+
if (message === null || entry.ws === session.browserSocket)
|
|
1779
|
+
continue;
|
|
1780
|
+
this.sendTo(entry.ws, { type: 'device:shutdown-error', sessionId: session.id, requestId: entry.requestId, message });
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
forgetShutdownRequesters(ws) {
|
|
1784
|
+
for (const [key, entry] of this.shutdownRequesters) {
|
|
1785
|
+
if (entry.ws !== ws)
|
|
1786
|
+
continue;
|
|
1787
|
+
clearTimeout(entry.timer);
|
|
1788
|
+
this.shutdownRequesters.delete(key);
|
|
1789
|
+
}
|
|
1790
|
+
}
|
|
1632
1791
|
sendTo(socket, msg) {
|
|
1633
1792
|
if (socket.readyState !== WebSocket.OPEN)
|
|
1634
1793
|
return;
|
|
@@ -1651,6 +1810,77 @@ export class RelayServer {
|
|
|
1651
1810
|
}
|
|
1652
1811
|
}
|
|
1653
1812
|
}
|
|
1813
|
+
bootKey(sessionId, requestId) {
|
|
1814
|
+
return `${sessionId}${requestId}`;
|
|
1815
|
+
}
|
|
1816
|
+
/** Record a boot against the agent socket it was dispatched to. The door guarantees both ids. */
|
|
1817
|
+
rememberBoot(sessionId, requestId, agentSocket) {
|
|
1818
|
+
const key = this.bootKey(sessionId, requestId);
|
|
1819
|
+
// Replacing, not doubling: a client never reuses a correlator, but forgetting the old timer
|
|
1820
|
+
// here is what keeps a replaced entry from firing after its successor was settled.
|
|
1821
|
+
this.forgetBoot(key);
|
|
1822
|
+
const timer = setTimeout(() => this.forgetBoot(key), this.pendingBootTtlMs);
|
|
1823
|
+
this.pendingBoots.set(key, { sessionId, requestId, agentSocket, timer });
|
|
1824
|
+
}
|
|
1825
|
+
/** The one way an entry leaves: every removal path funnels through here so no timer survives it. */
|
|
1826
|
+
forgetBoot(key) {
|
|
1827
|
+
const pending = this.pendingBoots.get(key);
|
|
1828
|
+
if (!pending)
|
|
1829
|
+
return;
|
|
1830
|
+
clearTimeout(pending.timer);
|
|
1831
|
+
this.pendingBoots.delete(key);
|
|
1832
|
+
}
|
|
1833
|
+
/**
|
|
1834
|
+
* Drop the tracked boot a correlated reply answers. The key needs no live session: an agent
|
|
1835
|
+
* answering after its session was removed still answers that request. An absent `requestId`
|
|
1836
|
+
* is not the answer to anything, so it drops nothing.
|
|
1837
|
+
*/
|
|
1838
|
+
settleBoot(sessionId, requestId) {
|
|
1839
|
+
if (typeof sessionId !== 'string' || typeof requestId !== 'string')
|
|
1840
|
+
return;
|
|
1841
|
+
this.forgetBoot(this.bootKey(sessionId, requestId));
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Answer the tracked boots tied to a replaced agent socket (#885). Called after the
|
|
1845
|
+
* `session:rebound` frames are sent, so the client records `needsReboot` first and the error
|
|
1846
|
+
* below is classified with the cause attached. Boots dispatched to any other socket — in
|
|
1847
|
+
* particular to the new agent after the rebind — are left pending.
|
|
1848
|
+
*/
|
|
1849
|
+
invalidateBootsFor(oldSockets) {
|
|
1850
|
+
if (oldSockets.size === 0)
|
|
1851
|
+
return;
|
|
1852
|
+
for (const [key, pending] of this.pendingBoots.entries()) {
|
|
1853
|
+
if (!oldSockets.has(pending.agentSocket))
|
|
1854
|
+
continue;
|
|
1855
|
+
this.forgetBoot(key);
|
|
1856
|
+
const session = this.sessions.get(pending.sessionId);
|
|
1857
|
+
// `sendTo` skips a socket that is not OPEN, and a session nobody has joined has no
|
|
1858
|
+
// `browserSocket` at all — either way the entry is still gone, so nothing later answers
|
|
1859
|
+
// a request nobody holds.
|
|
1860
|
+
if (!session?.browserSocket)
|
|
1861
|
+
continue;
|
|
1862
|
+
this.sendTo(session.browserSocket, {
|
|
1863
|
+
type: 'device:boot-error',
|
|
1864
|
+
sessionId: pending.sessionId,
|
|
1865
|
+
requestId: pending.requestId,
|
|
1866
|
+
message: `session ${pending.sessionId} rebounded to a new agent while this boot was in flight, ` +
|
|
1867
|
+
'and that agent never saw this request',
|
|
1868
|
+
});
|
|
1869
|
+
}
|
|
1870
|
+
}
|
|
1871
|
+
/**
|
|
1872
|
+
* Silently drop the tracked boots of sessions that no longer exist. Their callers were already
|
|
1873
|
+
* settled with the better diagnosis (`session:terminated`), so answering as well would send a
|
|
1874
|
+
* second failure for the same request. Scoped to the evicted sessions rather than the socket:
|
|
1875
|
+
* rebound sessions have already moved off it, and their entries are `invalidateBootsFor`'s to
|
|
1876
|
+
* answer, not this one's to drop.
|
|
1877
|
+
*/
|
|
1878
|
+
forgetEvictedBoots(sessionIds) {
|
|
1879
|
+
for (const [key, pending] of this.pendingBoots.entries()) {
|
|
1880
|
+
if (sessionIds.has(pending.sessionId))
|
|
1881
|
+
this.forgetBoot(key);
|
|
1882
|
+
}
|
|
1883
|
+
}
|
|
1654
1884
|
/**
|
|
1655
1885
|
* The agent's socket went away. Keep its sessions and wait for that agent to come back, instead
|
|
1656
1886
|
* of ending them here — a restarting agent registers about a second later, and until #426 stage 3
|
|
@@ -1725,6 +1955,10 @@ export class RelayServer {
|
|
|
1725
1955
|
if (agentSessions.length === 0)
|
|
1726
1956
|
return false;
|
|
1727
1957
|
this.rejectPending(new Set(agentSessions.map((s) => s.id)), 'Agent disconnected');
|
|
1958
|
+
// Their tracked boots go with them, silently: the `session:terminated` below settles those
|
|
1959
|
+
// callers with the better diagnosis. Rebound sessions are not in this set — they moved off
|
|
1960
|
+
// this socket — so their boots survive for `invalidateBootsFor` to answer.
|
|
1961
|
+
this.forgetEvictedBoots(new Set(agentSessions.map((s) => s.id)));
|
|
1728
1962
|
// Tell whoever is attached before the session stops existing — after `remove()` the socket
|
|
1729
1963
|
// reference is gone. Without this the browser keeps a live socket addressed to a sessionId the
|
|
1730
1964
|
// relay no longer knows, so everything it sends is dropped as unknown and nothing streams back:
|
|
@@ -1741,7 +1975,10 @@ export class RelayServer {
|
|
|
1741
1975
|
});
|
|
1742
1976
|
}
|
|
1743
1977
|
}
|
|
1978
|
+
// After `session:terminated`, so a caller that holds the session is settled by that — it carries the
|
|
1979
|
+
// better diagnosis — and only a caller that never joined hears from `settleShutdownRequesters`.
|
|
1744
1980
|
for (const s of agentSessions) {
|
|
1981
|
+
this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
|
|
1745
1982
|
this.sessions.remove(s.id);
|
|
1746
1983
|
this.forgetSessionState(s.id);
|
|
1747
1984
|
}
|
|
@@ -1774,6 +2011,10 @@ export class RelayServer {
|
|
|
1774
2011
|
// deduplicate — the second card would name a session the agent has never heard of, which is the
|
|
1775
2012
|
// symptom this whole change is fixing.
|
|
1776
2013
|
const rebound = new Map();
|
|
2014
|
+
// Sockets a rebind actually moved a session off of. Only boots tied to one of these are
|
|
2015
|
+
// stranded: a boot dispatched after the rebind below rides the new socket, and the eviction
|
|
2016
|
+
// further down must not sweep the entries this set protects (see `forgetEvictedBoots`).
|
|
2017
|
+
const reboundOldSockets = new Set();
|
|
1777
2018
|
if (identity) {
|
|
1778
2019
|
for (const old of this.sessions.getAgentSocketsByIdentity(identity, msg.platform)) {
|
|
1779
2020
|
if (old === ws)
|
|
@@ -1793,6 +2034,7 @@ export class RelayServer {
|
|
|
1793
2034
|
continue;
|
|
1794
2035
|
this.sessions.rebind(s.id, ws, device, agent);
|
|
1795
2036
|
rebound.set(s.deviceId, s.id);
|
|
2037
|
+
reboundOldSockets.add(old);
|
|
1796
2038
|
}
|
|
1797
2039
|
// Evict before terminate: the old socket's close fires async, by which point its sessions are
|
|
1798
2040
|
// gone and its in-flight screenshots would be undiscoverable — reject them here instead.
|
|
@@ -1803,6 +2045,7 @@ export class RelayServer {
|
|
|
1803
2045
|
}
|
|
1804
2046
|
// Their in-flight requests are addressed to a process that is gone, and the eviction above can
|
|
1805
2047
|
// no longer see them. Nothing else would ever settle these.
|
|
2048
|
+
// Shutdowns are deliberately not settled here — see `shutdownRequesters` for why they follow the session.
|
|
1806
2049
|
if (rebound.size > 0)
|
|
1807
2050
|
this.rejectPending(new Set(rebound.values()), 'Agent restarted');
|
|
1808
2051
|
// A device can be back under an agent this session cannot be rebound to — identity is
|
|
@@ -1819,6 +2062,7 @@ export class RelayServer {
|
|
|
1819
2062
|
if (s.browserSocket) {
|
|
1820
2063
|
this.sendTo(s.browserSocket, { type: 'session:terminated', sessionId: s.id, reason: 'agent-disconnected' });
|
|
1821
2064
|
}
|
|
2065
|
+
this.settleShutdownRequesters(s, SHUTDOWN_OUTCOME_LOST);
|
|
1822
2066
|
this.sessions.remove(s.id);
|
|
1823
2067
|
this.forgetSessionState(s.id);
|
|
1824
2068
|
}
|
|
@@ -1841,6 +2085,11 @@ export class RelayServer {
|
|
|
1841
2085
|
this.sendTo(s.browserSocket, { type: 'session:rebound', sessionId, capabilities: msg.capabilities ?? [] });
|
|
1842
2086
|
}
|
|
1843
2087
|
}
|
|
2088
|
+
// After the rebound frames, so the client records `needsReboot` before the error below
|
|
2089
|
+
// arrives and classifies it with the cause attached. Only boots tied to the replaced
|
|
2090
|
+
// sockets: a boot sent after the rebind above was dispatched to the new agent, which boots
|
|
2091
|
+
// the device and answers — settling it here would fail a boot that succeeds (#885).
|
|
2092
|
+
this.invalidateBootsFor(reboundOldSockets);
|
|
1844
2093
|
if (rebound.size > 0)
|
|
1845
2094
|
logger.info(`agent restarted — ${rebound.size} session(s) kept across the restart`);
|
|
1846
2095
|
// The startup banner prints "Waiting for agents..." once and then the relay says nothing either
|
|
@@ -1868,8 +2117,9 @@ export class RelayServer {
|
|
|
1868
2117
|
// pick another" — advice for a problem they cannot act on, while the actual reason went unreported.
|
|
1869
2118
|
// It is a map read the relay has already done, so ordering it first costs nothing.
|
|
1870
2119
|
//
|
|
1871
|
-
//
|
|
1872
|
-
//
|
|
2120
|
+
// `owner !== ownerOf(ws)` because a client re-joining a session it already holds is not contending
|
|
2121
|
+
// with anyone: the viewer re-sends `session:start` each time its socket reconnects, and `mcp-server`
|
|
2122
|
+
// and `flow-runner` keep one `clientId` per process, so their explicit re-join is the same client.
|
|
1873
2123
|
// The same three conditions `join()` applies, and they have to agree: this one answers the caller and
|
|
1874
2124
|
// that one performs the bind, so a disagreement is a refusal for a session the bind would have allowed.
|
|
1875
2125
|
if (session.browserSocket &&
|
|
@@ -1988,7 +2238,7 @@ export class RelayServer {
|
|
|
1988
2238
|
// skipping safe is not the agent's silence but the viewer's: with `readySent` false the
|
|
1989
2239
|
// `device:ready` above is not replayed either, so nothing is rendering a live device to be wrong
|
|
1990
2240
|
// about. `deviceStatus` is not the fix — the comment on `device:ready` says why (#440).
|
|
1991
|
-
if (session.agentCapabilities
|
|
2241
|
+
if (hasCapability(session.agentCapabilities, 'network-control'))
|
|
1992
2242
|
this.networkStateRequester(session.id)();
|
|
1993
2243
|
}
|
|
1994
2244
|
}
|
|
@@ -2350,7 +2600,7 @@ export class RelayServer {
|
|
|
2350
2600
|
* laptop waking up would otherwise repeat this indefinitely. Same shape as `warnedProxyMisconfig`.
|
|
2351
2601
|
*/
|
|
2352
2602
|
warnLegacyInstaller(identity, capabilities) {
|
|
2353
|
-
if (capabilities
|
|
2603
|
+
if (hasCapability(capabilities, 'build-download') || this.warnedLegacyInstaller.has(identity))
|
|
2354
2604
|
return;
|
|
2355
2605
|
this.warnedLegacyInstaller.add(identity);
|
|
2356
2606
|
logger.warn(`[relay] agent ${identity} predates build downloading. If it is not on the same machine as ` +
|
|
@@ -2654,6 +2904,17 @@ export class RelayServer {
|
|
|
2654
2904
|
serveStatic(req, res) {
|
|
2655
2905
|
const urlPath = (req.url ?? '/').split('?')[0];
|
|
2656
2906
|
let filePath = path.join(this.publicDir, urlPath === '/' ? '/index.html' : urlPath);
|
|
2907
|
+
// **Contained before anything is read or probed.** `req.url` is the raw request target and Node does
|
|
2908
|
+
// not normalise it, so `/../../etc/passwd` reached `path.join` intact and the join walked out of
|
|
2909
|
+
// `publicDir` — unauthenticated, and the relay's environment and database were one request away.
|
|
2910
|
+
// `serveUpload` has had this check since #173. Checked on the joined path because the index lookup and
|
|
2911
|
+
// the `.br`/`.gz` siblings below are all derived from it; the separator keeps a sibling directory
|
|
2912
|
+
// whose name starts with `publicDir`'s outside.
|
|
2913
|
+
if (!path.resolve(filePath).startsWith(path.resolve(this.publicDir) + path.sep)) {
|
|
2914
|
+
res.writeHead(404);
|
|
2915
|
+
res.end('Not found');
|
|
2916
|
+
return;
|
|
2917
|
+
}
|
|
2657
2918
|
// Next.js static export: try exact path, then path/index.html (trailingSlash)
|
|
2658
2919
|
if (!fs.existsSync(filePath)) {
|
|
2659
2920
|
const withIndex = path.join(filePath, 'index.html');
|