@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 +2 -2
- package/config.d.ts +7 -0
- package/exchange.d.ts +23 -0
- package/index.js +261 -41
- package/index.js.map +8 -7
- package/main.js +430 -207
- package/main.js.map +10 -9
- package/notopen.d.ts +24 -0
- package/package.json +1 -1
- package/session.d.ts +5 -1
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
3360
|
-
|
|
3361
|
-
|
|
3362
|
-
|
|
3363
|
-
|
|
3364
|
-
|
|
3365
|
-
|
|
3366
|
-
|
|
3367
|
-
|
|
3368
|
-
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
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=
|
|
3777
|
+
//# debugId=819DDD907387E9BC64756E2164756E21
|
|
3558
3778
|
//# sourceMappingURL=index.js.map
|