@ractive-ch/tab-mcp 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -10,7 +10,7 @@ and gets an MCP address. The user adds that address to an AI app and pairs it by
10
10
  a code from the page on the relay's sign-in page. The relay then forwards the AI app's
11
11
  requests to the page. It knows nothing about the tools themselves.
12
12
 
13
- **Status: 0.1.0.** Until 1.0, a new minor version can still change settings and the API.
13
+ **Status: 0.2.1.** Until 1.0, a new minor version can still change settings and the API.
14
14
  The relay speaks MCP revision 2026-07-28 only and accepts AI apps that register through
15
15
  OAuth Client ID Metadata Documents; see [compatibility](https://github.com/ractive/tab-mcp/blob/main/docs/compatibility.md)
16
16
  for which apps work.
@@ -32,7 +32,7 @@ Or with Docker (linux/amd64 and linux/arm64):
32
32
  docker run -d -p 127.0.0.1:8080:8080 -v tab-mcp-data:/data \
33
33
  -e TAB_MCP_PUBLIC_URL=https://relay.example.com \
34
34
  -e TAB_MCP_ALLOWED_ORIGINS=https://app.example.com \
35
- ractive/tab-mcp:0.1.0
35
+ ractive/tab-mcp:0.2.1
36
36
  ```
37
37
 
38
38
  The image is also on `ghcr.io/ractive/tab-mcp`. Put the relay behind a reverse proxy
package/config.d.ts CHANGED
@@ -45,6 +45,8 @@ export interface Site {
45
45
  * chunk (below 896 bytes).
46
46
  */
47
47
  export declare const MIN_MESSAGE_BYTES = 16384;
48
+ /** Longest grace period for a page to (re)connect (DEC-074). */
49
+ export declare const MAX_PAGE_WAIT_MS = 60000;
48
50
  /** Longest app name (configured or sent by the page). */
49
51
  export declare const MAX_APP_NAME = 64;
50
52
  export interface RelayConfig {
@@ -77,6 +79,11 @@ export interface RelayConfig {
77
79
  helloTimeoutMs: number;
78
80
  /** Deadline for receiving an MCP request body (Bun's idle timer does not cover it). */
79
81
  bodyTimeoutMs: number;
82
+ /**
83
+ * How long an MCP request waits for its page to (re)connect before the relay
84
+ * answers "the page is not open" (DEC-074); 0 answers at once.
85
+ */
86
+ pageWaitMs: number;
80
87
  /** Limit for MCP request bodies and for messages from the tab. */
81
88
  maxMessageBytes: number;
82
89
  accessTokenTtlMs: number;
package/exchange.d.ts CHANGED
@@ -17,6 +17,18 @@ export declare const RelayErrorCode: {
17
17
  readonly storeUnavailable: -31008;
18
18
  };
19
19
  export type CancelReason = "client_closed" | "timeout" | "error";
20
+ /**
21
+ * What to do when the page goes away mid-request (DEC-074): send the request
22
+ * to the page again once it is back (only while nothing was sent to the AI
23
+ * app), or answer with this JSON-RPC message at HTTP 200 (`undefined` for a
24
+ * notification: then 503).
25
+ */
26
+ export type PageGoneReply = {
27
+ retry: true;
28
+ } | {
29
+ retry: false;
30
+ body?: string;
31
+ };
20
32
  export interface ExchangeOptions {
21
33
  id: string;
22
34
  /** JSON-RPC id of the request; `undefined` for a notification. */
@@ -36,6 +48,11 @@ export interface ExchangeOptions {
36
48
  cancel: (reason: CancelReason) => void;
37
49
  /** Called once when the exchange is over. */
38
50
  onDone: (exchange: Exchange) => void;
51
+ /**
52
+ * Decides the answer when the page goes away mid-request; `waiting` is true
53
+ * while nothing was sent to the AI app. Without it: 503, `-31003`.
54
+ */
55
+ pageGone?: (waiting: boolean) => PageGoneReply;
39
56
  }
40
57
  /** One SSE `message` event carrying `data` (split into `data:` lines). */
41
58
  export declare function sseEvent(data: string): string;
@@ -60,6 +77,12 @@ export declare class Exchange {
60
77
  onEnd(): void;
61
78
  /** The AI app went away: tell the tab, send nothing more. */
62
79
  clientClosed(): void;
80
+ /**
81
+ * The page's session ended. With a retry the exchange ends without an
82
+ * answer (its `response` never settles; the relay sends the request again),
83
+ * else the AI app gets the `pageGone` answer at HTTP 200 (DEC-074).
84
+ */
85
+ tabGone(): void;
63
86
  /**
64
87
  * End the exchange with an error the AI app can read: an HTTP error while
65
88
  * nothing was sent, else a JSON-RPC error event if the stream is at an
package/index.js CHANGED
@@ -38,6 +38,7 @@ function shortAddress(id) {
38
38
  }
39
39
  // src/config.ts
40
40
  var MIN_MESSAGE_BYTES = 16384;
41
+ var MAX_PAGE_WAIT_MS = 60000;
41
42
  var MAX_APP_NAME = 64;
42
43
  var DEFAULT_CONFIG = {
43
44
  port: 8080,
@@ -59,6 +60,7 @@ var DEFAULT_CONFIG = {
59
60
  heartbeatMs: 25000,
60
61
  helloTimeoutMs: 1e4,
61
62
  bodyTimeoutMs: 1e4,
63
+ pageWaitMs: 1e4,
62
64
  maxMessageBytes: 1048576,
63
65
  accessTokenTtlMs: 3600000,
64
66
  refreshTokenTtlMs: 2592000000,
@@ -142,6 +144,9 @@ function resolveConfig(input = {}) {
142
144
  throw new ConfigError(`${key} must be a positive number`);
143
145
  }
144
146
  }
147
+ if (typeof config.pageWaitMs !== "number" || !Number.isFinite(config.pageWaitMs) || config.pageWaitMs < 0 || config.pageWaitMs > MAX_PAGE_WAIT_MS) {
148
+ throw new ConfigError(`pageWaitMs must be 0-${MAX_PAGE_WAIT_MS} (0-${MAX_PAGE_WAIT_MS / 1000} seconds)`);
149
+ }
145
150
  if (config.maxMessageBytes < MIN_MESSAGE_BYTES) {
146
151
  throw new ConfigError(`maxMessageBytes must be at least ${MIN_MESSAGE_BYTES} (16 KiB)`);
147
152
  }
@@ -265,6 +270,16 @@ function seconds(env, name) {
265
270
  }
266
271
  return Math.round(n * 1000);
267
272
  }
273
+ function secondsOrZero(env, name) {
274
+ const raw = env[name];
275
+ if (raw === undefined || raw === "")
276
+ return;
277
+ const n = Number(raw);
278
+ if (!Number.isFinite(n) || n < 0) {
279
+ throw new ConfigError(`${name} must be 0 or a positive number of seconds`);
280
+ }
281
+ return Math.round(n * 1000);
282
+ }
268
283
  function integer(env, name) {
269
284
  const raw = env[name];
270
285
  if (raw === undefined || raw === "")
@@ -341,6 +356,7 @@ function configFromEnv(env) {
341
356
  set("sessionLifetimeMs", seconds(env, "TAB_MCP_SESSION_LIFETIME_S"));
342
357
  set("heartbeatMs", seconds(env, "TAB_MCP_HEARTBEAT_S"));
343
358
  set("bodyTimeoutMs", seconds(env, "TAB_MCP_BODY_TIMEOUT_S"));
359
+ set("pageWaitMs", secondsOrZero(env, "TAB_MCP_PAGE_WAIT_S"));
344
360
  set("maxMessageBytes", integer(env, "TAB_MCP_MAX_MESSAGE_BYTES"));
345
361
  set("accessTokenTtlMs", seconds(env, "TAB_MCP_ACCESS_TOKEN_TTL_S"));
346
362
  set("refreshTokenTtlMs", seconds(env, "TAB_MCP_REFRESH_TOKEN_TTL_S"));
@@ -708,7 +724,7 @@ function parseTabMessage(text) {
708
724
  }
709
725
  }
710
726
  // package.json
711
- var version = "0.1.0";
727
+ var version = "0.2.1";
712
728
 
713
729
  // src/exchange.ts
714
730
  var RelayErrorCode = {
@@ -866,6 +882,31 @@ class Exchange {
866
882
  this.#options.cancel("client_closed");
867
883
  this.#finish("client_closed", false);
868
884
  }
885
+ tabGone() {
886
+ if (this.#state === "done")
887
+ return;
888
+ const waiting = this.#state === "waiting";
889
+ const reply = this.#options.pageGone?.(waiting);
890
+ if (reply?.retry === true && waiting) {
891
+ clearTimeout(this.#startTimer);
892
+ this.#finish("page_gone_retry");
893
+ return;
894
+ }
895
+ const body = reply?.retry === false ? reply.body : undefined;
896
+ if (body === undefined) {
897
+ this.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
898
+ return;
899
+ }
900
+ if (waiting) {
901
+ this.#respond(new Response(body, {
902
+ status: 200,
903
+ headers: this.#headers({ "content-type": "application/json" })
904
+ }), 200);
905
+ } else if (this.#atBoundary) {
906
+ this.#write(sseEvent(body));
907
+ }
908
+ this.#finish("page_gone");
909
+ }
869
910
  fail(status, code, message, cancelTab) {
870
911
  if (this.#state === "done")
871
912
  return;
@@ -1004,6 +1045,67 @@ class Exchange {
1004
1045
  }
1005
1046
  }
1006
1047
 
1048
+ // src/notopen.ts
1049
+ var RETRYABLE = new Set(["tools/list"]);
1050
+ function isRetryable(method) {
1051
+ return typeof method === "string" && RETRYABLE.has(method);
1052
+ }
1053
+ function pageName(appName) {
1054
+ return appName ?? "the web page";
1055
+ }
1056
+ function notOpenText(appName) {
1057
+ return `The page is not open, or not in front. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then try again.`;
1058
+ }
1059
+ function goneDuringCallText(appName) {
1060
+ return `The page closed while the tool was running, so it may or may not have finished. Ask the user to open ${pageName(appName)} and keep it in front (on a phone, in split screen), then check the result before trying again.`;
1061
+ }
1062
+ function notOpenAnswer(body, rpcId, appName, phase, relay) {
1063
+ if (rpcId === undefined)
1064
+ return;
1065
+ const id = rpcId;
1066
+ const method = body["method"];
1067
+ if (method === "tools/call") {
1068
+ const text = phase === "gone" ? goneDuringCallText(appName) : notOpenText(appName);
1069
+ return JSON.stringify({
1070
+ jsonrpc: "2.0",
1071
+ id,
1072
+ result: {
1073
+ resultType: "complete",
1074
+ content: [{ type: "text", text }],
1075
+ isError: true
1076
+ }
1077
+ });
1078
+ }
1079
+ if (method === "server/discover") {
1080
+ return JSON.stringify({
1081
+ jsonrpc: "2.0",
1082
+ id,
1083
+ result: {
1084
+ resultType: "complete",
1085
+ supportedVersions: [MCP_PROTOCOL_VERSION],
1086
+ capabilities: { tools: { listChanged: true } },
1087
+ instructions: notOpenText(appName),
1088
+ ttlMs: 0,
1089
+ cacheScope: "private",
1090
+ _meta: {
1091
+ "io.modelcontextprotocol/serverInfo": {
1092
+ name: relay.name,
1093
+ version: relay.version
1094
+ }
1095
+ }
1096
+ }
1097
+ });
1098
+ }
1099
+ return JSON.stringify({
1100
+ jsonrpc: "2.0",
1101
+ id,
1102
+ error: {
1103
+ code: phase === "gone" ? RelayErrorCode.tabGone : RelayErrorCode.pageNotOpen,
1104
+ message: notOpenText(appName)
1105
+ }
1106
+ });
1107
+ }
1108
+
1007
1109
  // src/oauth/server.ts
1008
1110
  import { timingSafeEqual as timingSafeEqual2 } from "crypto";
1009
1111
 
@@ -2669,6 +2771,7 @@ class TabSession {
2669
2771
  library: `${library.name}@${library.version}`,
2670
2772
  replaced: replaced !== undefined
2671
2773
  });
2774
+ this.#host.ready?.(this);
2672
2775
  }
2673
2776
  #tick() {
2674
2777
  if (this.#state !== "ready")
@@ -2707,7 +2810,8 @@ class TabSession {
2707
2810
  this.pending.delete(id);
2708
2811
  this.#lastRequestAt = Date.now();
2709
2812
  input.onDone(e);
2710
- }
2813
+ },
2814
+ ...input.pageGone === undefined ? {} : { pageGone: input.pageGone }
2711
2815
  });
2712
2816
  this.pending.set(id, exchange);
2713
2817
  const sent = this.send({
@@ -2746,10 +2850,9 @@ class TabSession {
2746
2850
  clearTimeout(this.#helloTimer);
2747
2851
  clearInterval(this.#heartbeatTimer);
2748
2852
  clearTimeout(this.#lifetimeTimer);
2749
- for (const exchange of [...this.pending.values()]) {
2750
- exchange.fail(503, RelayErrorCode.tabGone, "The page closed its connection to the relay.", false);
2751
- }
2752
2853
  this.#host.released(this);
2854
+ for (const exchange of [...this.pending.values()])
2855
+ exchange.tabGone();
2753
2856
  if (wasReady) {
2754
2857
  this.#host.logger.info("tab.closed", {
2755
2858
  session: this.sessionId,
@@ -3001,6 +3104,8 @@ class SqliteTokenStore {
3001
3104
 
3002
3105
  // src/relay.ts
3003
3106
  var VERSION = version;
3107
+ var RELAY_INFO = { name: "tab-mcp", version };
3108
+ var RETRY = Symbol("retry");
3004
3109
  var CLAUDE_MISMATCHED_HEADER = "2025-11-25";
3005
3110
  var FORWARDED_HEADERS = new Set([
3006
3111
  "content-type",
@@ -3061,6 +3166,9 @@ function startRelay(options, hooks) {
3061
3166
  const sessions = new Map;
3062
3167
  const allSessions = new Set;
3063
3168
  const perIp = new Map;
3169
+ const waiters = new Map;
3170
+ let waitingCount = 0;
3171
+ const appNames = new Map;
3064
3172
  let claudeWorkarounds = 0;
3065
3173
  let publicUrl = config.publicUrl ?? "";
3066
3174
  const corsHeaders = config.cors ? {
@@ -3124,8 +3232,68 @@ function startRelay(options, hooks) {
3124
3232
  sessions.delete(session.addressId);
3125
3233
  }
3126
3234
  releaseIp(session.ip);
3235
+ },
3236
+ ready(session) {
3237
+ const id = session.addressId;
3238
+ if (id === undefined)
3239
+ return;
3240
+ if (session.appName !== undefined) {
3241
+ appNames.delete(id);
3242
+ appNames.set(id, session.appName);
3243
+ if (appNames.size > config.maxSessions * 2) {
3244
+ const oldest = appNames.keys().next().value;
3245
+ if (oldest !== undefined)
3246
+ appNames.delete(oldest);
3247
+ }
3248
+ }
3249
+ const waiting = waiters.get(id);
3250
+ if (waiting !== undefined)
3251
+ for (const wake of [...waiting])
3252
+ wake(session);
3127
3253
  }
3128
3254
  };
3255
+ function readySession(addressId) {
3256
+ const s = sessions.get(addressId);
3257
+ return s?.ready === true ? s : undefined;
3258
+ }
3259
+ function waitForPage(addressId, signal, waitMs) {
3260
+ const now = readySession(addressId);
3261
+ if (now !== undefined)
3262
+ return Promise.resolve(now);
3263
+ if (waitMs === 0 || stopping !== undefined) {
3264
+ return Promise.resolve(undefined);
3265
+ }
3266
+ const set = waiters.get(addressId) ?? new Set;
3267
+ if (set.size >= config.maxConcurrentRequests || waitingCount >= config.maxSessions) {
3268
+ return Promise.resolve(undefined);
3269
+ }
3270
+ return new Promise((resolve) => {
3271
+ let timer;
3272
+ let settled = false;
3273
+ const done = (result) => {
3274
+ if (settled)
3275
+ return;
3276
+ settled = true;
3277
+ clearTimeout(timer);
3278
+ signal.removeEventListener("abort", onAbort);
3279
+ set.delete(wake);
3280
+ waitingCount--;
3281
+ if (set.size === 0 && waiters.get(addressId) === set) {
3282
+ waiters.delete(addressId);
3283
+ }
3284
+ resolve(result);
3285
+ };
3286
+ const wake = (session) => done(session);
3287
+ const onAbort = () => done("aborted");
3288
+ set.add(wake);
3289
+ waiters.set(addressId, set);
3290
+ waitingCount++;
3291
+ timer = setTimeout(() => done(undefined), waitMs);
3292
+ signal.addEventListener("abort", onAbort, { once: true });
3293
+ if (signal.aborted)
3294
+ onAbort();
3295
+ });
3296
+ }
3129
3297
  function releaseIp(ip) {
3130
3298
  const n = (perIp.get(ip) ?? 1) - 1;
3131
3299
  if (n <= 0)
@@ -3329,15 +3497,6 @@ function startRelay(options, hooks) {
3329
3497
  "www-authenticate": wwwAuthenticate(addressId, auth === "invalid")
3330
3498
  });
3331
3499
  }
3332
- const session = sessions.get(addressId);
3333
- if (session === undefined || !session.ready) {
3334
- logDone(503, method, { reason: "page_not_open" });
3335
- return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, 'The page is not open. Open the web page that offers these tools and click "Connect your AI app".', corsHeaders);
3336
- }
3337
- if (session.pending.size >= session.limits.maxConcurrentRequests) {
3338
- logDone(429, method);
3339
- return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
3340
- }
3341
3500
  const headers = {};
3342
3501
  for (const [k, v] of req.headers) {
3343
3502
  const key = k.toLowerCase();
@@ -3352,33 +3511,89 @@ function startRelay(options, hooks) {
3352
3511
  return "";
3353
3512
  }
3354
3513
  })();
3355
- if (req.signal.aborted) {
3514
+ const isListen = body.value["method"] === "subscriptions/listen";
3515
+ const waitMs = body.value["method"] === "server/discover" || Buffer.byteLength(body.text) > MIN_MESSAGE_BYTES ? 0 : config.pageWaitMs;
3516
+ const closed = () => {
3356
3517
  logDone(499, method, { outcome: "client_closed" });
3357
3518
  return new Response(null, { status: 499 });
3519
+ };
3520
+ if (readySession(addressId) === undefined && waitMs > 0) {
3521
+ server.timeout(req, 0);
3358
3522
  }
3359
- server.timeout(req, 0);
3360
- const exchange = session.forward({
3361
- method: "POST",
3362
- headers,
3363
- body: body.text,
3364
- rpcId,
3365
- isListen: body.value["method"] === "subscriptions/listen",
3366
- grant: {
3367
- grantId: auth.grantId,
3368
- clientId: auth.clientId,
3369
- scopes: auth.scopes
3370
- },
3371
- extraHeaders: corsHeaders,
3372
- onDone: (e) => logDone(e.status, method, {
3373
- session: session.sessionId,
3374
- client: clientHostName,
3375
- outcome: e.outcome
3376
- })
3377
- });
3378
- req.signal.addEventListener("abort", () => exchange.clientClosed(), {
3379
- once: true
3523
+ let retried = false;
3524
+ for (;; ) {
3525
+ const found = await waitForPage(addressId, req.signal, waitMs);
3526
+ if (found === "aborted" || req.signal.aborted)
3527
+ return closed();
3528
+ if (found === undefined) {
3529
+ return pageNotOpen(body.value, rpcId, addressId, logDone, method);
3530
+ }
3531
+ const session = found;
3532
+ if (Buffer.byteLength(body.text) > session.limits.maxMessageBytes) {
3533
+ logDone(413, method);
3534
+ return rpcError(413, rpcId, -32600, "Request body too large.", corsHeaders);
3535
+ }
3536
+ if (session.pending.size >= session.limits.maxConcurrentRequests) {
3537
+ logDone(429, method);
3538
+ return rpcError(429, rpcId, RelayErrorCode.overloaded, "Too many requests at once for this page.", corsHeaders);
3539
+ }
3540
+ server.timeout(req, 0);
3541
+ let retry = () => {};
3542
+ const retrying = new Promise((resolve) => {
3543
+ retry = () => resolve(RETRY);
3544
+ });
3545
+ const mayRetry = !retried && waitMs > 0 && isRetryable(body.value["method"]);
3546
+ const exchange = session.forward({
3547
+ method: "POST",
3548
+ headers,
3549
+ body: body.text,
3550
+ rpcId,
3551
+ isListen,
3552
+ grant: {
3553
+ grantId: auth.grantId,
3554
+ clientId: auth.clientId,
3555
+ scopes: auth.scopes
3556
+ },
3557
+ extraHeaders: corsHeaders,
3558
+ onDone: (e) => {
3559
+ if (e.outcome === "page_gone_retry")
3560
+ return;
3561
+ logDone(e.status, method, {
3562
+ session: session.sessionId,
3563
+ client: clientHostName,
3564
+ outcome: e.outcome,
3565
+ ...e.outcome === "page_gone" ? { reason: "page_not_open" } : {}
3566
+ });
3567
+ },
3568
+ pageGone: (waiting) => {
3569
+ if (waiting && mayRetry && !req.signal.aborted) {
3570
+ retry();
3571
+ return { retry: true };
3572
+ }
3573
+ const answer = notOpenAnswer(body.value, rpcId, session.appName, "gone", RELAY_INFO);
3574
+ return answer === undefined ? { retry: false } : { retry: false, body: answer };
3575
+ }
3576
+ });
3577
+ const onAbort = () => exchange.clientClosed();
3578
+ req.signal.addEventListener("abort", onAbort, { once: true });
3579
+ const result = await Promise.race([exchange.response, retrying]);
3580
+ if (result !== RETRY)
3581
+ return result;
3582
+ req.signal.removeEventListener("abort", onAbort);
3583
+ retried = true;
3584
+ }
3585
+ }
3586
+ function pageNotOpen(value, rpcId, addressId, logDone, method) {
3587
+ const answer = notOpenAnswer(value, rpcId, appNames.get(addressId), "not_open", RELAY_INFO);
3588
+ if (answer === undefined) {
3589
+ logDone(503, method, { reason: "page_not_open" });
3590
+ return rpcError(503, rpcId, RelayErrorCode.pageNotOpen, "The page is not open.", corsHeaders);
3591
+ }
3592
+ logDone(200, method, { reason: "page_not_open" });
3593
+ return new Response(answer, {
3594
+ status: 200,
3595
+ headers: { ...corsHeaders, "content-type": "application/json" }
3380
3596
  });
3381
- return exchange.response;
3382
3597
  }
3383
3598
  function preflight(req) {
3384
3599
  if (!config.cors)
@@ -3413,14 +3628,15 @@ function startRelay(options, hooks) {
3413
3628
  status: "ok",
3414
3629
  version: VERSION,
3415
3630
  counters: { claudeHeaderWorkaround: claudeWorkarounds }
3416
- })
3631
+ }, { "cache-control": "no-store" })
3417
3632
  },
3418
3633
  "/": {
3419
3634
  GET: () => new Response(INDEX_HTML, {
3420
3635
  headers: {
3421
3636
  "content-type": "text/html; charset=utf-8",
3422
3637
  "content-security-policy": "default-src 'none'; frame-ancestors 'none'",
3423
- "x-content-type-options": "nosniff"
3638
+ "x-content-type-options": "nosniff",
3639
+ "cache-control": "public, max-age=3600"
3424
3640
  }
3425
3641
  })
3426
3642
  },
@@ -3484,6 +3700,7 @@ function startRelay(options, hooks) {
3484
3700
  }
3485
3701
  }
3486
3702
  });
3703
+ let stopping;
3487
3704
  const port = server.port ?? config.port;
3488
3705
  if (publicUrl === "")
3489
3706
  publicUrl = `http://localhost:${port}`;
@@ -3514,7 +3731,6 @@ function startRelay(options, hooks) {
3514
3731
  store: ownsStore ? config.store : "custom",
3515
3732
  origins: siteCount
3516
3733
  });
3517
- let stopping;
3518
3734
  return {
3519
3735
  url: publicUrl,
3520
3736
  port,
@@ -3526,6 +3742,10 @@ function startRelay(options, hooks) {
3526
3742
  stop() {
3527
3743
  stopping ??= (async () => {
3528
3744
  clearInterval(sweep);
3745
+ for (const set of [...waiters.values()]) {
3746
+ for (const wake of [...set])
3747
+ wake(undefined);
3748
+ }
3529
3749
  for (const session of [...allSessions]) {
3530
3750
  session.end(CloseCode.shuttingDown, "relay shutting down", "shutdown");
3531
3751
  }
@@ -3554,5 +3774,5 @@ export {
3554
3774
  resolveConfig
3555
3775
  };
3556
3776
 
3557
- //# debugId=0BEEC7645167157064756E2164756E21
3777
+ //# debugId=819DDD907387E9BC64756E2164756E21
3558
3778
  //# sourceMappingURL=index.js.map