@tonyclaw/agent-inspector 3.0.43 → 3.0.45
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/.output/cli.js +20744 -16945
- package/.output/nitro.json +1 -1
- package/.output/public/assets/{CompareDrawer-BAXxwTwT.js → CompareDrawer-B3_TLTJh.js} +1 -1
- package/.output/public/assets/{InspectorPet-xYuGV4Qz.js → InspectorPet-Cpn6hv4P.js} +1 -1
- package/.output/public/assets/{ProxyViewerContainer-B6GZnUWi.js → ProxyViewerContainer-BEAhuGOJ.js} +27 -27
- package/.output/public/assets/{ReplayDialog-D73pyIg3.js → ReplayDialog-B3EKVdJk.js} +1 -1
- package/.output/public/assets/{RequestAnatomy-CSDta_nu.js → RequestAnatomy-COOQjyUC.js} +1 -1
- package/.output/public/assets/{ResponseView-C4aIUPby.js → ResponseView-DBCjkwye.js} +1 -1
- package/.output/public/assets/{StreamingChunkSequence-D4xxosLx.js → StreamingChunkSequence-CuXhDgMo.js} +1 -1
- package/.output/public/assets/{_sessionId-CZHbJyWO.js → _sessionId-BJlM3nmt.js} +1 -1
- package/.output/public/assets/{_sessionId-DvuNLzSE.js → _sessionId-zbgYl7ZJ.js} +1 -1
- package/.output/public/assets/{index-DZ_wpqQM.js → index-BIK4bPTv.js} +1 -1
- package/.output/public/assets/{index-B17u7JMC.js → index-BZo_8koU.js} +15 -15
- package/.output/public/assets/{index-C0g79BAs.js → index-D2Qw6Ri2.js} +1 -1
- package/.output/public/assets/{index-BWjeqxbk.css → index-ZWschkCh.css} +1 -1
- package/.output/public/assets/{index-CmEO8ykj.js → index-xF_NY8Yb.js} +5 -5
- package/.output/public/assets/{json-viewer-CuWU-H6K.js → json-viewer-B-Ao6pwW.js} +1 -1
- package/.output/public/assets/{jszip.min-DPJI_UKW.js → jszip.min-B_rfAUCe.js} +1 -1
- package/.output/server/_libs/modelcontextprotocol__core.mjs +25 -25
- package/.output/server/_libs/modelcontextprotocol__server.mjs +2 -2
- package/.output/server/_libs/zod.mjs +6 -6
- package/.output/server/{_sessionId-Cun21KZF.mjs → _sessionId-CJ99gZFV.mjs} +3 -1
- package/.output/server/{_sessionId-CoFyeOti.mjs → _sessionId-DEjScBpK.mjs} +5 -3
- package/.output/server/_ssr/{CompareDrawer-Bgc2TZh_.mjs → CompareDrawer-DtOIMeQB.mjs} +4 -2
- package/.output/server/_ssr/{InspectorPet-DyK2ojeh.mjs → InspectorPet-CXc4oCBQ.mjs} +3 -1
- package/.output/server/_ssr/{ProxyViewerContainer-hvn6oVWD.mjs → ProxyViewerContainer-DVPlwfkt.mjs} +52 -27
- package/.output/server/_ssr/{ReplayDialog-KpnrGtZ-.mjs → ReplayDialog-BQEA95qC.mjs} +6 -4
- package/.output/server/_ssr/{RequestAnatomy-uCsfucn8.mjs → RequestAnatomy-BMyd8B7Q.mjs} +4 -2
- package/.output/server/_ssr/{ResponseView-C8UPqoPO.mjs → ResponseView-qrTaEIM8.mjs} +4 -2
- package/.output/server/_ssr/{StreamingChunkSequence-BP28I9sh.mjs → StreamingChunkSequence-BFTL8mIy.mjs} +5 -3
- package/.output/server/_ssr/{index-Du3YNDKx.mjs → index-Bcj1Hcy4.mjs} +4 -2
- package/.output/server/_ssr/{index-BVwVoQlZ.mjs → index-DW-M7XUG.mjs} +3 -1
- package/.output/server/_ssr/index.mjs +4 -3
- package/.output/server/_ssr/{json-viewer-DrE0vtlB.mjs → json-viewer-DiI0Yjsn.mjs} +4 -2
- package/.output/server/_ssr/publicBasePathContext-5264L3rX.mjs +148 -0
- package/.output/server/_ssr/{router-C6nWUZHK.mjs → router-BJ_ltLim.mjs} +2432 -475
- package/.output/server/_ssr/start-C6MjPjxp.mjs +57 -0
- package/.output/server/_tanstack-start-manifest_v-C6pXXX9R.mjs +4 -0
- package/.output/server/index.mjs +99 -99
- package/.output/workers/logFinalizer.worker.js +4 -0
- package/.output/workers/sessionWorkerEntry.js +4 -0
- package/README.md +221 -25
- package/docs/instance-control.openapi.yaml +306 -0
- package/package.json +4 -1
- package/src/cli/alias.ts +440 -0
- package/src/cli/instance.ts +1037 -0
- package/src/cli/instanceArgs.ts +266 -0
- package/src/cli/instanceControl.ts +183 -0
- package/src/cli/instanceModel.ts +275 -0
- package/src/cli/instanceRegistry.ts +740 -0
- package/src/cli/onboard.ts +123 -35
- package/src/cli/onboardTarget.ts +179 -0
- package/src/cli/templates/codex-skill-onboard.ts +76 -28
- package/src/cli/templates/skill-onboard.ts +93 -59
- package/src/cli.ts +154 -16
- package/src/components/ProxyViewer.tsx +13 -5
- package/src/components/providers/SettingsDialog.tsx +13 -8
- package/src/lib/basePath.ts +8 -2
- package/src/lib/codingAgentEndpoints.ts +16 -0
- package/src/lib/instanceContract.ts +220 -0
- package/src/lib/managedInstance.ts +104 -0
- package/src/lib/publicBasePathContext.ts +107 -0
- package/src/mcp/instanceHandlers.ts +149 -0
- package/src/mcp/loopback.ts +16 -5
- package/src/mcp/mode.ts +25 -0
- package/src/mcp/server.ts +258 -18
- package/src/mcp/toolHandlers.ts +2 -2
- package/src/proxy/identityProxy.ts +656 -36
- package/src/proxy/runtimeShutdown.ts +73 -1
- package/src/proxy/sessionInfo.ts +5 -4
- package/src/routes/api/-instances.ts +109 -0
- package/src/routes/api/instances.$name.connection.ts +12 -0
- package/src/routes/api/instances.$name.restart.ts +23 -0
- package/src/routes/api/instances.$name.start.ts +18 -0
- package/src/routes/api/instances.$name.stop.ts +23 -0
- package/src/routes/api/instances.$name.ts +12 -0
- package/src/routes/api/instances.ts +16 -0
- package/src/start.ts +10 -0
- package/.output/server/_ssr/start-HYkvq4Ni.mjs +0 -4
- package/.output/server/_tanstack-start-manifest_v-rshlRaiN.mjs +0 -4
|
@@ -1,14 +1,22 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
|
-
import { timingSafeEqual } from "node:crypto";
|
|
3
|
+
import { createHash, timingSafeEqual } from "node:crypto";
|
|
4
4
|
import { isIP } from "node:net";
|
|
5
5
|
import {
|
|
6
6
|
AGENT_INSPECTOR_BASE_PATH_META_NAME,
|
|
7
|
+
AGENT_INSPECTOR_PUBLIC_BASE_PATH_HEADER,
|
|
8
|
+
AGENT_INSPECTOR_PUBLIC_PORT_HEADER,
|
|
7
9
|
getConfiguredBasePath,
|
|
8
10
|
normalizeBasePath,
|
|
9
11
|
stripBasePath,
|
|
10
12
|
stripBasePathFromRequestPath,
|
|
13
|
+
withBasePath,
|
|
11
14
|
} from "../lib/basePath";
|
|
15
|
+
import {
|
|
16
|
+
MANAGED_INSTANCE_CONTROL_PATH,
|
|
17
|
+
MANAGED_INSTANCE_SHUTDOWN_PATH,
|
|
18
|
+
MANAGED_INSTANCE_TOKEN_HEADER,
|
|
19
|
+
} from "../lib/managedInstance";
|
|
12
20
|
import { lookupClientByPort } from "./socketTracker";
|
|
13
21
|
|
|
14
22
|
const IDENTITY_HEADERS = {
|
|
@@ -23,6 +31,187 @@ const PROXY_TOKEN_HEADER = "x-agent-inspector-proxy-token";
|
|
|
23
31
|
const CSRF_HEADER = "x-agent-inspector-csrf";
|
|
24
32
|
const DEFAULT_MAX_REQUEST_BYTES = 64 * 1024 * 1024;
|
|
25
33
|
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000;
|
|
34
|
+
const ALIAS_CONTROL_MAX_REQUEST_BYTES = 8 * 1024;
|
|
35
|
+
|
|
36
|
+
export const IDENTITY_PROXY_CONTROL_NAMESPACE = "/.agent-inspector";
|
|
37
|
+
export const IDENTITY_PROXY_ALIAS_CONTROL_PATH = `${IDENTITY_PROXY_CONTROL_NAMESPACE}/base-path-aliases`;
|
|
38
|
+
|
|
39
|
+
export type BasePathAliasResolution = {
|
|
40
|
+
effectiveBasePath: string;
|
|
41
|
+
isAlias: boolean;
|
|
42
|
+
upstreamPath: string;
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type BasePathAliasMutationResult =
|
|
46
|
+
| { ok: true; basePath: string }
|
|
47
|
+
| {
|
|
48
|
+
ok: false;
|
|
49
|
+
code: "canonical" | "duplicate" | "invalid" | "not-found" | "overlap" | "reserved";
|
|
50
|
+
error: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type BasePathAliasRegistry = {
|
|
54
|
+
canonicalBasePath: string;
|
|
55
|
+
add: (basePath: string) => BasePathAliasMutationResult;
|
|
56
|
+
list: () => readonly string[];
|
|
57
|
+
remove: (basePath: string) => BasePathAliasMutationResult;
|
|
58
|
+
resolve: (rawPath: string) => BasePathAliasResolution;
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
function normalizedAliasCandidate(
|
|
62
|
+
value: string,
|
|
63
|
+
): { ok: true; basePath: string } | { ok: false; code: "invalid" | "reserved"; error: string } {
|
|
64
|
+
const normalized = normalizeBasePath(value);
|
|
65
|
+
if (normalized.length === 0) {
|
|
66
|
+
return {
|
|
67
|
+
ok: false,
|
|
68
|
+
code: "invalid",
|
|
69
|
+
error: "Base Path aliases must be non-root paths such as /inspector.",
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
if (!/^\/[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*$/u.test(normalized)) {
|
|
73
|
+
return {
|
|
74
|
+
ok: false,
|
|
75
|
+
code: "invalid",
|
|
76
|
+
error: "Base Path aliases may contain only URL-safe path segments.",
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
if (normalized.split("/").some((segment) => segment === "." || segment === "..")) {
|
|
80
|
+
return {
|
|
81
|
+
ok: false,
|
|
82
|
+
code: "invalid",
|
|
83
|
+
error: "Base Path aliases cannot contain dot path segments.",
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
if (
|
|
87
|
+
normalized === IDENTITY_PROXY_CONTROL_NAMESPACE ||
|
|
88
|
+
normalized.startsWith(`${IDENTITY_PROXY_CONTROL_NAMESPACE}/`)
|
|
89
|
+
) {
|
|
90
|
+
return {
|
|
91
|
+
ok: false,
|
|
92
|
+
code: "reserved",
|
|
93
|
+
error: `${normalized} collides with the reserved Agent Inspector control path.`,
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
const publicNamespace = ["/api", "/assets", "/proxy", "/session", "/livez", "/readyz"].find(
|
|
97
|
+
(namespace) => normalized === namespace || normalized.startsWith(`${namespace}/`),
|
|
98
|
+
);
|
|
99
|
+
if (publicNamespace !== undefined) {
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
code: "reserved",
|
|
103
|
+
error: `${normalized} collides with the reserved ${publicNamespace} public namespace.`,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
return { ok: true, basePath: normalized };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function pathMatchesBasePath(pathname: string, basePath: string): boolean {
|
|
110
|
+
return pathname === basePath || pathname.startsWith(`${basePath}/`);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function basePathsOverlap(left: string, right: string): boolean {
|
|
114
|
+
return pathMatchesBasePath(left, right) || pathMatchesBasePath(right, left);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Create an in-memory registry owned by one identity ingress. Runtime aliases
|
|
119
|
+
* intentionally disappear when that ingress exits.
|
|
120
|
+
*/
|
|
121
|
+
export function createBasePathAliasRegistry(
|
|
122
|
+
canonicalBasePath: string,
|
|
123
|
+
initialAliases: readonly string[] = [],
|
|
124
|
+
): BasePathAliasRegistry {
|
|
125
|
+
const canonical = normalizeBasePath(canonicalBasePath);
|
|
126
|
+
const aliases: string[] = [];
|
|
127
|
+
|
|
128
|
+
const add = (value: string): BasePathAliasMutationResult => {
|
|
129
|
+
const candidate = normalizedAliasCandidate(value);
|
|
130
|
+
if (!candidate.ok) return candidate;
|
|
131
|
+
if (candidate.basePath === canonical) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
code: "canonical",
|
|
135
|
+
error: `${candidate.basePath} is already the canonical Base Path.`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
if (canonical.length > 0 && basePathsOverlap(candidate.basePath, canonical)) {
|
|
139
|
+
return {
|
|
140
|
+
ok: false,
|
|
141
|
+
code: "overlap",
|
|
142
|
+
error: `${candidate.basePath} overlaps the canonical Base Path ${canonical}.`,
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
if (aliases.includes(candidate.basePath)) {
|
|
146
|
+
return {
|
|
147
|
+
ok: false,
|
|
148
|
+
code: "duplicate",
|
|
149
|
+
error: `${candidate.basePath} is already an active runtime alias.`,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
const overlappingAlias = aliases.find((alias) => basePathsOverlap(candidate.basePath, alias));
|
|
153
|
+
if (overlappingAlias !== undefined) {
|
|
154
|
+
return {
|
|
155
|
+
ok: false,
|
|
156
|
+
code: "overlap",
|
|
157
|
+
error: `${candidate.basePath} overlaps the active runtime alias ${overlappingAlias}.`,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
aliases.push(candidate.basePath);
|
|
161
|
+
aliases.sort((left, right) => right.length - left.length || left.localeCompare(right));
|
|
162
|
+
return { ok: true, basePath: candidate.basePath };
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
const remove = (value: string): BasePathAliasMutationResult => {
|
|
166
|
+
const candidate = normalizedAliasCandidate(value);
|
|
167
|
+
if (!candidate.ok) return candidate;
|
|
168
|
+
if (candidate.basePath === canonical) {
|
|
169
|
+
return {
|
|
170
|
+
ok: false,
|
|
171
|
+
code: "canonical",
|
|
172
|
+
error: `${candidate.basePath} is the canonical Base Path and cannot be removed.`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
const index = aliases.indexOf(candidate.basePath);
|
|
176
|
+
if (index === -1) {
|
|
177
|
+
return {
|
|
178
|
+
ok: false,
|
|
179
|
+
code: "not-found",
|
|
180
|
+
error: `${candidate.basePath} is not an active runtime alias.`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
aliases.splice(index, 1);
|
|
184
|
+
return { ok: true, basePath: candidate.basePath };
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
const resolve = (rawPath: string): BasePathAliasResolution => {
|
|
188
|
+
const requestUrl = new URL(rawPath, "http://localhost");
|
|
189
|
+
const alias = aliases.find((candidate) => pathMatchesBasePath(requestUrl.pathname, candidate));
|
|
190
|
+
const effectiveBasePath = alias ?? canonical;
|
|
191
|
+
return {
|
|
192
|
+
effectiveBasePath,
|
|
193
|
+
isAlias: alias !== undefined,
|
|
194
|
+
upstreamPath: stripBasePathFromRequestPath(rawPath, effectiveBasePath),
|
|
195
|
+
};
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const registry: BasePathAliasRegistry = {
|
|
199
|
+
canonicalBasePath: canonical,
|
|
200
|
+
add,
|
|
201
|
+
list: () => [...aliases],
|
|
202
|
+
remove,
|
|
203
|
+
resolve,
|
|
204
|
+
};
|
|
205
|
+
for (const alias of initialAliases) add(alias);
|
|
206
|
+
return registry;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
export function resolveBasePathAliasRequest(
|
|
210
|
+
registry: BasePathAliasRegistry,
|
|
211
|
+
rawPath: string,
|
|
212
|
+
): BasePathAliasResolution {
|
|
213
|
+
return registry.resolve(rawPath);
|
|
214
|
+
}
|
|
26
215
|
|
|
27
216
|
type IdentityProxyRuntimeState = {
|
|
28
217
|
acceptingRequests: boolean;
|
|
@@ -83,33 +272,95 @@ function htmlAttributeEscape(value: string): string {
|
|
|
83
272
|
.replace(/>/gu, ">");
|
|
84
273
|
}
|
|
85
274
|
|
|
86
|
-
|
|
275
|
+
function rewriteRootRelativePublicPath(
|
|
276
|
+
value: string,
|
|
277
|
+
effectiveBasePath: string,
|
|
278
|
+
sourceBasePath: string,
|
|
279
|
+
): string {
|
|
280
|
+
if (!value.startsWith("/") || value.startsWith("//")) return value;
|
|
281
|
+
const normalizedEffective = normalizeBasePath(effectiveBasePath);
|
|
282
|
+
const normalizedSource = normalizeBasePath(sourceBasePath);
|
|
283
|
+
const suffixIndex = value.search(/[?#]/u);
|
|
284
|
+
const pathname = suffixIndex === -1 ? value : value.slice(0, suffixIndex);
|
|
285
|
+
const suffix = suffixIndex === -1 ? "" : value.slice(suffixIndex);
|
|
286
|
+
if (normalizedEffective.length > 0 && pathMatchesBasePath(pathname, normalizedEffective)) {
|
|
287
|
+
return value;
|
|
288
|
+
}
|
|
289
|
+
const rootRelativePath =
|
|
290
|
+
normalizedSource.length > 0 && pathMatchesBasePath(pathname, normalizedSource)
|
|
291
|
+
? pathname.slice(normalizedSource.length) || "/"
|
|
292
|
+
: pathname;
|
|
293
|
+
const rewritten = withBasePath(rootRelativePath, normalizedEffective);
|
|
294
|
+
const trailingSlash = pathname.endsWith("/") && !rewritten.endsWith("/") ? "/" : "";
|
|
295
|
+
return `${rewritten}${trailingSlash}${suffix}`;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function rewriteQuotedAssetPaths(
|
|
299
|
+
source: string,
|
|
300
|
+
effectiveBasePath: string,
|
|
301
|
+
sourceBasePath: string,
|
|
302
|
+
): string {
|
|
303
|
+
return source.replace(
|
|
304
|
+
/(["'])(\/(?:[^"'/]+\/)*assets\/)/gu,
|
|
305
|
+
(_match: string, quote: string, assetPrefix: string) =>
|
|
306
|
+
`${quote}${rewriteRootRelativePublicPath(assetPrefix, effectiveBasePath, sourceBasePath)}`,
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export function rewriteHtmlForBasePath(
|
|
311
|
+
html: string,
|
|
312
|
+
basePath: string,
|
|
313
|
+
sourceBasePath = "",
|
|
314
|
+
): string {
|
|
87
315
|
const normalized = normalizeBasePath(basePath);
|
|
88
|
-
if (normalized.length === 0) return html;
|
|
89
316
|
const escaped = htmlAttributeEscape(normalized);
|
|
90
317
|
const meta = `<meta name="${AGENT_INSPECTOR_BASE_PATH_META_NAME}" content="${escaped}">`;
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
(
|
|
318
|
+
const metaPattern = new RegExp(
|
|
319
|
+
`<meta\\b[^>]*\\bname\\s*=\\s*("|')${AGENT_INSPECTOR_BASE_PATH_META_NAME}\\1[^>]*>`,
|
|
320
|
+
"giu",
|
|
321
|
+
);
|
|
322
|
+
let replacedMeta = false;
|
|
323
|
+
const withReplacedMeta = html.replace(metaPattern, () => {
|
|
324
|
+
if (replacedMeta) return "";
|
|
325
|
+
replacedMeta = true;
|
|
326
|
+
return meta;
|
|
327
|
+
});
|
|
328
|
+
const withMeta = replacedMeta
|
|
329
|
+
? withReplacedMeta
|
|
330
|
+
: withReplacedMeta.replace(/<head(\s[^>]*)?>/iu, (match) => `${match}${meta}`);
|
|
331
|
+
const withRewrittenAttributes = withMeta.replace(
|
|
332
|
+
/\b(href|src|action)=(["'])(\/(?!\/)[^"']*)\2/giu,
|
|
333
|
+
(_match: string, attribute: string, quote: string, path: string) =>
|
|
334
|
+
`${attribute}=${quote}${rewriteRootRelativePublicPath(path, normalized, sourceBasePath)}${quote}`,
|
|
98
335
|
);
|
|
336
|
+
return rewriteQuotedAssetPaths(withRewrittenAttributes, normalized, sourceBasePath);
|
|
99
337
|
}
|
|
100
338
|
|
|
101
339
|
function javaScriptStringEscape(value: string): string {
|
|
102
340
|
return value.replace(/\\/gu, "\\\\").replace(/"/gu, '\\"');
|
|
103
341
|
}
|
|
104
342
|
|
|
105
|
-
export function rewriteJavaScriptForBasePath(
|
|
343
|
+
export function rewriteJavaScriptForBasePath(
|
|
344
|
+
source: string,
|
|
345
|
+
basePath: string,
|
|
346
|
+
sourceBasePath = "",
|
|
347
|
+
): string {
|
|
106
348
|
const normalized = normalizeBasePath(basePath);
|
|
107
|
-
if (normalized.length === 0) return source;
|
|
108
349
|
const escaped = javaScriptStringEscape(normalized);
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
350
|
+
const withRouterBasePath = source.replace(
|
|
351
|
+
/\bbasepath:\s*(["'])[^"']*\1/gu,
|
|
352
|
+
`basepath:"${escaped}"`,
|
|
353
|
+
);
|
|
354
|
+
const withDynamicPaths = withRouterBasePath.replace(
|
|
355
|
+
/return(["'])(\/(?!\/)[^"']*)\1\+([A-Za-z_$][\w$]*)/gu,
|
|
356
|
+
(_match: string, quote: string, path: string, variable: string) =>
|
|
357
|
+
`return${quote}${rewriteRootRelativePublicPath(
|
|
358
|
+
path,
|
|
359
|
+
normalized,
|
|
360
|
+
sourceBasePath,
|
|
361
|
+
)}${quote}+${variable}`,
|
|
362
|
+
);
|
|
363
|
+
return rewriteQuotedAssetPaths(withDynamicPaths, normalized, sourceBasePath);
|
|
113
364
|
}
|
|
114
365
|
|
|
115
366
|
export type IdentityProxyOptions = {
|
|
@@ -137,6 +388,22 @@ export type IdentityProxyOptions = {
|
|
|
137
388
|
maxRequestBytes?: number;
|
|
138
389
|
/** Deadline for receiving and forwarding one request body. */
|
|
139
390
|
requestTimeoutMs?: number;
|
|
391
|
+
/** Runtime-scoped additional public Base Path mounts. */
|
|
392
|
+
aliasRegistry?: BasePathAliasRegistry;
|
|
393
|
+
/** Private lifecycle identity exposed only to the owning local instance manager. */
|
|
394
|
+
managedInstance?: ManagedInstanceControlOptions;
|
|
395
|
+
};
|
|
396
|
+
|
|
397
|
+
export type ManagedInstanceControlOptions = {
|
|
398
|
+
name: string;
|
|
399
|
+
instanceId: string;
|
|
400
|
+
launchId: string;
|
|
401
|
+
controlToken: string;
|
|
402
|
+
supervisorPid: number;
|
|
403
|
+
startedAt: string;
|
|
404
|
+
publicPort: number;
|
|
405
|
+
upstreamPort: number;
|
|
406
|
+
requestShutdown: () => void;
|
|
140
407
|
};
|
|
141
408
|
|
|
142
409
|
function isLoopbackPeer(address: string | undefined): boolean {
|
|
@@ -163,6 +430,13 @@ function tokensMatch(received: string | null, expected: string | undefined): boo
|
|
|
163
430
|
);
|
|
164
431
|
}
|
|
165
432
|
|
|
433
|
+
function managedInstanceTokensMatch(received: string | undefined, expected: string): boolean {
|
|
434
|
+
if (received === undefined || received.length === 0 || expected.length === 0) return false;
|
|
435
|
+
const receivedDigest = createHash("sha256").update(received).digest();
|
|
436
|
+
const expectedDigest = createHash("sha256").update(expected).digest();
|
|
437
|
+
return timingSafeEqual(receivedDigest, expectedDigest);
|
|
438
|
+
}
|
|
439
|
+
|
|
166
440
|
function firstHeader(value: string | readonly string[] | undefined): string | undefined {
|
|
167
441
|
return typeof value === "string" ? value : value?.[0];
|
|
168
442
|
}
|
|
@@ -203,8 +477,11 @@ function isSafeMethod(method: string | undefined): boolean {
|
|
|
203
477
|
}
|
|
204
478
|
|
|
205
479
|
function isPublicHealthPath(pathname: string): boolean {
|
|
206
|
-
|
|
207
|
-
|
|
480
|
+
return pathname === "/livez" || pathname === "/readyz" || pathname === "/api/health";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function isInstanceManagementPath(pathname: string): boolean {
|
|
484
|
+
return pathname === "/api/instances" || pathname.startsWith("/api/instances/");
|
|
208
485
|
}
|
|
209
486
|
|
|
210
487
|
function configuredPositiveInteger(value: number | undefined, fallback: number): number {
|
|
@@ -220,6 +497,7 @@ function declaredContentLength(value: string | undefined): number | null {
|
|
|
220
497
|
|
|
221
498
|
export function isBrowserMutationAllowed(input: {
|
|
222
499
|
path: string;
|
|
500
|
+
basePath?: string;
|
|
223
501
|
method?: string;
|
|
224
502
|
host?: string;
|
|
225
503
|
origin?: string;
|
|
@@ -229,7 +507,7 @@ export function isBrowserMutationAllowed(input: {
|
|
|
229
507
|
if (isSafeMethod(input.method)) return true;
|
|
230
508
|
const pathname = stripBasePath(
|
|
231
509
|
new URL(input.path, "http://localhost").pathname,
|
|
232
|
-
getConfiguredBasePath(process.env),
|
|
510
|
+
input.basePath ?? getConfiguredBasePath(process.env),
|
|
233
511
|
);
|
|
234
512
|
if (pathname === "/proxy" || pathname.startsWith("/proxy/")) return true;
|
|
235
513
|
|
|
@@ -250,6 +528,7 @@ export function isBrowserMutationAllowed(input: {
|
|
|
250
528
|
|
|
251
529
|
export function isIdentityProxyRequestAllowed(input: {
|
|
252
530
|
path: string;
|
|
531
|
+
basePath?: string;
|
|
253
532
|
remoteAddress?: string;
|
|
254
533
|
authorization?: string;
|
|
255
534
|
proxyAuthorization?: string;
|
|
@@ -263,14 +542,17 @@ export function isIdentityProxyRequestAllowed(input: {
|
|
|
263
542
|
| "allowUnauthenticatedRemoteProxy"
|
|
264
543
|
>;
|
|
265
544
|
}): boolean {
|
|
545
|
+
const pathname = stripBasePath(
|
|
546
|
+
new URL(input.path, "http://localhost").pathname,
|
|
547
|
+
input.basePath ?? getConfiguredBasePath(process.env),
|
|
548
|
+
);
|
|
549
|
+
if (!isLoopbackPeer(input.remoteAddress) && isInstanceManagementPath(pathname)) {
|
|
550
|
+
return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
|
|
551
|
+
}
|
|
266
552
|
if (input.options.restrictRemoteControl !== true) return true;
|
|
267
553
|
if (input.options.allowRemoteControl === true) return true;
|
|
268
554
|
if (isLoopbackPeer(input.remoteAddress)) return true;
|
|
269
555
|
|
|
270
|
-
const pathname = stripBasePath(
|
|
271
|
-
new URL(input.path, "http://localhost").pathname,
|
|
272
|
-
getConfiguredBasePath(process.env),
|
|
273
|
-
);
|
|
274
556
|
if (pathname === "/proxy" || pathname.startsWith("/proxy/")) {
|
|
275
557
|
if (input.options.allowUnauthenticatedRemoteProxy === true) return true;
|
|
276
558
|
const proxyCredential = bearerToken(input.proxyAuthorization) ?? input.proxyTokenHeader ?? null;
|
|
@@ -280,6 +562,267 @@ export function isIdentityProxyRequestAllowed(input: {
|
|
|
280
562
|
return tokensMatch(bearerToken(input.authorization), input.options.controlToken);
|
|
281
563
|
}
|
|
282
564
|
|
|
565
|
+
type AliasControlBodyResult =
|
|
566
|
+
| { ok: true; body: string }
|
|
567
|
+
| { ok: false; status: number; error: string };
|
|
568
|
+
|
|
569
|
+
function readAliasControlBody(req: http.IncomingMessage): Promise<AliasControlBodyResult> {
|
|
570
|
+
const contentLength = declaredContentLength(req.headers["content-length"]);
|
|
571
|
+
if (contentLength !== null && (!Number.isFinite(contentLength) || contentLength < 0)) {
|
|
572
|
+
req.resume();
|
|
573
|
+
return Promise.resolve({ ok: false, status: 400, error: "Invalid Content-Length header" });
|
|
574
|
+
}
|
|
575
|
+
if (contentLength !== null && contentLength > ALIAS_CONTROL_MAX_REQUEST_BYTES) {
|
|
576
|
+
req.resume();
|
|
577
|
+
return Promise.resolve({
|
|
578
|
+
ok: false,
|
|
579
|
+
status: 413,
|
|
580
|
+
error: `Alias control body exceeds ${String(ALIAS_CONTROL_MAX_REQUEST_BYTES)} bytes`,
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
return new Promise((resolve) => {
|
|
585
|
+
const chunks: Buffer[] = [];
|
|
586
|
+
let receivedBytes = 0;
|
|
587
|
+
let settled = false;
|
|
588
|
+
const finish = (result: AliasControlBodyResult): void => {
|
|
589
|
+
if (settled) return;
|
|
590
|
+
settled = true;
|
|
591
|
+
resolve(result);
|
|
592
|
+
};
|
|
593
|
+
req.on("data", (chunk: unknown) => {
|
|
594
|
+
if (settled) return;
|
|
595
|
+
const buffer = toBuffer(chunk);
|
|
596
|
+
receivedBytes += buffer.byteLength;
|
|
597
|
+
if (receivedBytes > ALIAS_CONTROL_MAX_REQUEST_BYTES) {
|
|
598
|
+
finish({
|
|
599
|
+
ok: false,
|
|
600
|
+
status: 413,
|
|
601
|
+
error: `Alias control body exceeds ${String(ALIAS_CONTROL_MAX_REQUEST_BYTES)} bytes`,
|
|
602
|
+
});
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
chunks.push(buffer);
|
|
606
|
+
});
|
|
607
|
+
req.on("end", () => {
|
|
608
|
+
finish({ ok: true, body: Buffer.concat(chunks).toString("utf8") });
|
|
609
|
+
});
|
|
610
|
+
req.on("aborted", () => {
|
|
611
|
+
finish({ ok: false, status: 400, error: "Client aborted alias control request" });
|
|
612
|
+
});
|
|
613
|
+
req.on("error", () => {
|
|
614
|
+
finish({ ok: false, status: 400, error: "Alias control request body failed" });
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function isUnknownRecord(value: unknown): value is Record<string, unknown> {
|
|
620
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
function aliasBasePathFromJson(
|
|
624
|
+
body: string,
|
|
625
|
+
): { ok: true; basePath: string } | { ok: false; error: string } {
|
|
626
|
+
let parsed: unknown;
|
|
627
|
+
try {
|
|
628
|
+
parsed = JSON.parse(body);
|
|
629
|
+
} catch {
|
|
630
|
+
return { ok: false, error: "Alias control body must be valid JSON." };
|
|
631
|
+
}
|
|
632
|
+
if (!isUnknownRecord(parsed) || typeof parsed.basePath !== "string") {
|
|
633
|
+
return { ok: false, error: 'Alias control body must contain a string "basePath" field.' };
|
|
634
|
+
}
|
|
635
|
+
return { ok: true, basePath: parsed.basePath };
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
function writeAliasControlResponse(
|
|
639
|
+
res: http.ServerResponse,
|
|
640
|
+
registry: BasePathAliasRegistry,
|
|
641
|
+
status: number,
|
|
642
|
+
result:
|
|
643
|
+
| { basePath?: string; code?: string; error?: string }
|
|
644
|
+
| { basePath: string }
|
|
645
|
+
| undefined = undefined,
|
|
646
|
+
extraHeaders: Readonly<Record<string, string>> = {},
|
|
647
|
+
): void {
|
|
648
|
+
res.writeHead(status, {
|
|
649
|
+
"content-type": "application/json; charset=utf-8",
|
|
650
|
+
"cache-control": "no-store",
|
|
651
|
+
...extraHeaders,
|
|
652
|
+
});
|
|
653
|
+
res.end(
|
|
654
|
+
JSON.stringify({
|
|
655
|
+
canonicalBasePath: registry.canonicalBasePath,
|
|
656
|
+
aliases: registry.list(),
|
|
657
|
+
runtimeScoped: true,
|
|
658
|
+
...result,
|
|
659
|
+
}),
|
|
660
|
+
);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function handleAliasControlRequest(
|
|
664
|
+
req: http.IncomingMessage,
|
|
665
|
+
res: http.ServerResponse,
|
|
666
|
+
registry: BasePathAliasRegistry,
|
|
667
|
+
): Promise<void> {
|
|
668
|
+
if (!isLoopbackPeer(req.socket.remoteAddress)) {
|
|
669
|
+
req.resume();
|
|
670
|
+
writeAliasControlResponse(res, registry, 403, {
|
|
671
|
+
error: "Base Path aliases can only be managed from the local machine.",
|
|
672
|
+
});
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
if (
|
|
676
|
+
!isBrowserMutationAllowed({
|
|
677
|
+
path: req.url ?? IDENTITY_PROXY_ALIAS_CONTROL_PATH,
|
|
678
|
+
basePath: "",
|
|
679
|
+
method: req.method,
|
|
680
|
+
host: req.headers.host,
|
|
681
|
+
origin: req.headers.origin,
|
|
682
|
+
secFetchSite: firstHeader(req.headers["sec-fetch-site"]),
|
|
683
|
+
csrfToken: firstHeader(req.headers[CSRF_HEADER]),
|
|
684
|
+
})
|
|
685
|
+
) {
|
|
686
|
+
req.resume();
|
|
687
|
+
writeAliasControlResponse(res, registry, 403, {
|
|
688
|
+
error: "Browser mutation requires same-origin CSRF proof.",
|
|
689
|
+
});
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
694
|
+
if (method === "GET") {
|
|
695
|
+
req.resume();
|
|
696
|
+
writeAliasControlResponse(res, registry, 200);
|
|
697
|
+
return;
|
|
698
|
+
}
|
|
699
|
+
if (method !== "POST" && method !== "DELETE") {
|
|
700
|
+
req.resume();
|
|
701
|
+
writeAliasControlResponse(
|
|
702
|
+
res,
|
|
703
|
+
registry,
|
|
704
|
+
405,
|
|
705
|
+
{ error: "Alias control supports GET, POST, and DELETE." },
|
|
706
|
+
{ allow: "GET, POST, DELETE" },
|
|
707
|
+
);
|
|
708
|
+
return;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
const bodyResult = await readAliasControlBody(req);
|
|
712
|
+
if (!bodyResult.ok) {
|
|
713
|
+
writeAliasControlResponse(res, registry, bodyResult.status, { error: bodyResult.error });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
const input = aliasBasePathFromJson(bodyResult.body);
|
|
717
|
+
if (!input.ok) {
|
|
718
|
+
writeAliasControlResponse(res, registry, 400, { error: input.error });
|
|
719
|
+
return;
|
|
720
|
+
}
|
|
721
|
+
const mutation =
|
|
722
|
+
method === "POST" ? registry.add(input.basePath) : registry.remove(input.basePath);
|
|
723
|
+
if (mutation.ok) {
|
|
724
|
+
writeAliasControlResponse(res, registry, method === "POST" ? 201 : 200, {
|
|
725
|
+
basePath: mutation.basePath,
|
|
726
|
+
});
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
const status =
|
|
730
|
+
mutation.code === "duplicate" || mutation.code === "overlap"
|
|
731
|
+
? 409
|
|
732
|
+
: mutation.code === "not-found"
|
|
733
|
+
? 404
|
|
734
|
+
: 400;
|
|
735
|
+
writeAliasControlResponse(res, registry, status, {
|
|
736
|
+
code: mutation.code,
|
|
737
|
+
error: mutation.error,
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
function writeManagedInstanceControlResponse(
|
|
742
|
+
res: http.ServerResponse,
|
|
743
|
+
status: number,
|
|
744
|
+
body: Readonly<Record<string, unknown>>,
|
|
745
|
+
extraHeaders: Readonly<Record<string, string>> = {},
|
|
746
|
+
onFinished: (() => void) | undefined = undefined,
|
|
747
|
+
): void {
|
|
748
|
+
res.writeHead(status, {
|
|
749
|
+
"content-type": "application/json; charset=utf-8",
|
|
750
|
+
"cache-control": "no-store",
|
|
751
|
+
...extraHeaders,
|
|
752
|
+
});
|
|
753
|
+
res.end(JSON.stringify(body), onFinished);
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
function handleManagedInstanceControlRequest(
|
|
757
|
+
req: http.IncomingMessage,
|
|
758
|
+
res: http.ServerResponse,
|
|
759
|
+
pathname: string,
|
|
760
|
+
managedInstance: ManagedInstanceControlOptions | undefined,
|
|
761
|
+
): void {
|
|
762
|
+
req.resume();
|
|
763
|
+
if (managedInstance === undefined) {
|
|
764
|
+
writeManagedInstanceControlResponse(res, 404, { error: "Control endpoint not found" });
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
const authorized = managedInstanceTokensMatch(
|
|
768
|
+
firstHeader(req.headers[MANAGED_INSTANCE_TOKEN_HEADER]),
|
|
769
|
+
managedInstance.controlToken,
|
|
770
|
+
);
|
|
771
|
+
if (!authorized) {
|
|
772
|
+
writeManagedInstanceControlResponse(res, 403, { error: "Instance control access denied" });
|
|
773
|
+
return;
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
777
|
+
if (pathname === MANAGED_INSTANCE_CONTROL_PATH) {
|
|
778
|
+
if (method !== "GET") {
|
|
779
|
+
writeManagedInstanceControlResponse(
|
|
780
|
+
res,
|
|
781
|
+
405,
|
|
782
|
+
{ error: "Instance identity supports GET only" },
|
|
783
|
+
{ allow: "GET" },
|
|
784
|
+
);
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
writeManagedInstanceControlResponse(res, 200, {
|
|
788
|
+
schemaVersion: 1,
|
|
789
|
+
name: managedInstance.name,
|
|
790
|
+
instanceId: managedInstance.instanceId,
|
|
791
|
+
launchId: managedInstance.launchId,
|
|
792
|
+
supervisorPid: managedInstance.supervisorPid,
|
|
793
|
+
startedAt: managedInstance.startedAt,
|
|
794
|
+
publicPort: managedInstance.publicPort,
|
|
795
|
+
upstreamPort: managedInstance.upstreamPort,
|
|
796
|
+
});
|
|
797
|
+
return;
|
|
798
|
+
}
|
|
799
|
+
if (method !== "POST") {
|
|
800
|
+
writeManagedInstanceControlResponse(
|
|
801
|
+
res,
|
|
802
|
+
405,
|
|
803
|
+
{ error: "Instance shutdown supports POST only" },
|
|
804
|
+
{ allow: "POST" },
|
|
805
|
+
);
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
808
|
+
writeManagedInstanceControlResponse(
|
|
809
|
+
res,
|
|
810
|
+
202,
|
|
811
|
+
{ accepted: true, launchId: managedInstance.launchId },
|
|
812
|
+
{},
|
|
813
|
+
() => {
|
|
814
|
+
process.nextTick(managedInstance.requestShutdown);
|
|
815
|
+
},
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function isIdentityProxyControlPath(pathname: string): boolean {
|
|
820
|
+
return (
|
|
821
|
+
pathname === IDENTITY_PROXY_CONTROL_NAMESPACE ||
|
|
822
|
+
pathname.startsWith(`${IDENTITY_PROXY_CONTROL_NAMESPACE}/`)
|
|
823
|
+
);
|
|
824
|
+
}
|
|
825
|
+
|
|
283
826
|
/**
|
|
284
827
|
* Start a tiny native Node HTTP server that:
|
|
285
828
|
* 1. Reads the client connection's source port (`req.socket.remotePort`).
|
|
@@ -306,6 +849,8 @@ export function isIdentityProxyRequestAllowed(input: {
|
|
|
306
849
|
export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.Server> {
|
|
307
850
|
return new Promise((resolve, reject) => {
|
|
308
851
|
const server = http.createServer();
|
|
852
|
+
const aliasRegistry =
|
|
853
|
+
options.aliasRegistry ?? createBasePathAliasRegistry(getConfiguredBasePath(process.env));
|
|
309
854
|
const runtimeState: IdentityProxyRuntimeState = {
|
|
310
855
|
acceptingRequests: true,
|
|
311
856
|
activeRequests: 0,
|
|
@@ -348,7 +893,7 @@ export function startIdentityProxy(options: IdentityProxyOptions): Promise<http.
|
|
|
348
893
|
};
|
|
349
894
|
res.once("finish", release);
|
|
350
895
|
res.once("close", release);
|
|
351
|
-
void handleRequest(req, res, options).catch((err: unknown) => {
|
|
896
|
+
void handleRequest(req, res, options, aliasRegistry).catch((err: unknown) => {
|
|
352
897
|
const message = err instanceof Error ? err.message : String(err);
|
|
353
898
|
if (!res.headersSent) {
|
|
354
899
|
res.writeHead(502, { "content-type": "text/plain" });
|
|
@@ -399,9 +944,8 @@ async function handleRequest(
|
|
|
399
944
|
req: http.IncomingMessage,
|
|
400
945
|
res: http.ServerResponse,
|
|
401
946
|
options: IdentityProxyOptions,
|
|
947
|
+
aliasRegistry: BasePathAliasRegistry,
|
|
402
948
|
): Promise<void> {
|
|
403
|
-
const basePath = getConfiguredBasePath(process.env);
|
|
404
|
-
const upstreamPath = stripBasePathFromRequestPath(req.url ?? "/", basePath);
|
|
405
949
|
const requestUrl = new URL(req.url ?? "/", "http://localhost");
|
|
406
950
|
if (!isTrustedIdentityProxyHost(req.headers.host, options)) {
|
|
407
951
|
res.writeHead(400, {
|
|
@@ -413,6 +957,29 @@ async function handleRequest(
|
|
|
413
957
|
return;
|
|
414
958
|
}
|
|
415
959
|
|
|
960
|
+
if (requestUrl.pathname === IDENTITY_PROXY_ALIAS_CONTROL_PATH) {
|
|
961
|
+
await handleAliasControlRequest(req, res, aliasRegistry);
|
|
962
|
+
return;
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
if (
|
|
966
|
+
requestUrl.pathname === MANAGED_INSTANCE_CONTROL_PATH ||
|
|
967
|
+
requestUrl.pathname === MANAGED_INSTANCE_SHUTDOWN_PATH
|
|
968
|
+
) {
|
|
969
|
+
handleManagedInstanceControlRequest(req, res, requestUrl.pathname, options.managedInstance);
|
|
970
|
+
return;
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
if (isIdentityProxyControlPath(requestUrl.pathname)) {
|
|
974
|
+
req.resume();
|
|
975
|
+
writeManagedInstanceControlResponse(res, 404, { error: "Control endpoint not found" });
|
|
976
|
+
return;
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
const resolution = resolveBasePathAliasRequest(aliasRegistry, req.url ?? "/");
|
|
980
|
+
const basePath = resolution.effectiveBasePath;
|
|
981
|
+
const upstreamPath = resolution.upstreamPath;
|
|
982
|
+
|
|
416
983
|
if (basePath.length > 0 && requestUrl.pathname.startsWith("/assets/")) {
|
|
417
984
|
res.writeHead(307, {
|
|
418
985
|
location: `${basePath}${requestUrl.pathname}${requestUrl.search}`,
|
|
@@ -426,6 +993,7 @@ async function handleRequest(
|
|
|
426
993
|
if (
|
|
427
994
|
!isBrowserMutationAllowed({
|
|
428
995
|
path: req.url ?? "/",
|
|
996
|
+
basePath,
|
|
429
997
|
method: req.method,
|
|
430
998
|
host: req.headers.host,
|
|
431
999
|
origin: req.headers.origin,
|
|
@@ -445,6 +1013,7 @@ async function handleRequest(
|
|
|
445
1013
|
if (
|
|
446
1014
|
!isIdentityProxyRequestAllowed({
|
|
447
1015
|
path: req.url ?? "/",
|
|
1016
|
+
basePath,
|
|
448
1017
|
remoteAddress: req.socket.remoteAddress,
|
|
449
1018
|
authorization: req.headers.authorization,
|
|
450
1019
|
proxyAuthorization: req.headers["proxy-authorization"],
|
|
@@ -507,11 +1076,20 @@ async function handleRequest(
|
|
|
507
1076
|
for (const [name, value] of Object.entries(req.headers)) {
|
|
508
1077
|
const lowerName = name.toLowerCase();
|
|
509
1078
|
if (REQUEST_HOP_BY_HOP.has(lowerName)) continue;
|
|
510
|
-
if (
|
|
1079
|
+
if (
|
|
1080
|
+
basePath.length > 0 &&
|
|
1081
|
+
(lowerName === "accept-encoding" || lowerName === "range" || lowerName === "if-range")
|
|
1082
|
+
)
|
|
1083
|
+
continue;
|
|
511
1084
|
if (lowerName.startsWith(IDENTITY_HEADER_PREFIX)) continue;
|
|
512
1085
|
if (value === undefined) continue;
|
|
513
1086
|
upstreamHeaders[name] = value;
|
|
514
1087
|
}
|
|
1088
|
+
upstreamHeaders[AGENT_INSPECTOR_PUBLIC_BASE_PATH_HEADER] = basePath.length === 0 ? "/" : basePath;
|
|
1089
|
+
const publicPort = req.socket.localPort;
|
|
1090
|
+
if (publicPort !== undefined && publicPort > 0 && publicPort <= 65_535) {
|
|
1091
|
+
upstreamHeaders[AGENT_INSPECTOR_PUBLIC_PORT_HEADER] = String(publicPort);
|
|
1092
|
+
}
|
|
515
1093
|
if (identity !== null && identity.pid !== null) {
|
|
516
1094
|
upstreamHeaders[IDENTITY_HEADERS.pid] = String(identity.pid);
|
|
517
1095
|
if (identity.port !== null) {
|
|
@@ -539,10 +1117,25 @@ async function handleRequest(
|
|
|
539
1117
|
headers: upstreamHeaders,
|
|
540
1118
|
},
|
|
541
1119
|
(proxyRes) => {
|
|
542
|
-
if (terminal) {
|
|
543
|
-
proxyRes.
|
|
1120
|
+
if (terminal || res.destroyed) {
|
|
1121
|
+
proxyRes.destroy();
|
|
544
1122
|
return;
|
|
545
1123
|
}
|
|
1124
|
+
const destroyUpstreamResponse = (): void => {
|
|
1125
|
+
proxyRes.destroy();
|
|
1126
|
+
proxyRes.socket.destroy();
|
|
1127
|
+
proxyReq.destroy();
|
|
1128
|
+
};
|
|
1129
|
+
const removeDownstreamListeners = (): void => {
|
|
1130
|
+
res.removeListener("close", destroyUpstreamResponse);
|
|
1131
|
+
res.removeListener("error", destroyUpstreamResponse);
|
|
1132
|
+
req.socket.removeListener("close", destroyUpstreamResponse);
|
|
1133
|
+
};
|
|
1134
|
+
res.once("close", destroyUpstreamResponse);
|
|
1135
|
+
res.once("error", destroyUpstreamResponse);
|
|
1136
|
+
req.socket.once("close", destroyUpstreamResponse);
|
|
1137
|
+
proxyRes.once("end", removeDownstreamListeners);
|
|
1138
|
+
proxyRes.once("close", removeDownstreamListeners);
|
|
546
1139
|
const outHeaders: Record<string, string | string[]> = {};
|
|
547
1140
|
const contentType = firstHeader(proxyRes.headers["content-type"]);
|
|
548
1141
|
const shouldRewriteHtml =
|
|
@@ -555,9 +1148,33 @@ async function handleRequest(
|
|
|
555
1148
|
for (const [name, value] of Object.entries(proxyRes.headers)) {
|
|
556
1149
|
const lowerName = name.toLowerCase();
|
|
557
1150
|
if (RESPONSE_HOP_BY_HOP.has(lowerName)) continue;
|
|
558
|
-
if (
|
|
1151
|
+
if (
|
|
1152
|
+
shouldRewriteBody &&
|
|
1153
|
+
(lowerName === "content-length" ||
|
|
1154
|
+
lowerName === "content-range" ||
|
|
1155
|
+
lowerName === "accept-ranges" ||
|
|
1156
|
+
lowerName === "etag" ||
|
|
1157
|
+
lowerName === "last-modified" ||
|
|
1158
|
+
lowerName === "content-md5" ||
|
|
1159
|
+
lowerName === "digest")
|
|
1160
|
+
)
|
|
1161
|
+
continue;
|
|
559
1162
|
if (value === undefined) continue;
|
|
560
|
-
|
|
1163
|
+
if (
|
|
1164
|
+
lowerName === "location" &&
|
|
1165
|
+
basePath.length > 0 &&
|
|
1166
|
+
typeof value === "string" &&
|
|
1167
|
+
value.startsWith("/") &&
|
|
1168
|
+
!value.startsWith("//")
|
|
1169
|
+
) {
|
|
1170
|
+
outHeaders[name] = rewriteRootRelativePublicPath(
|
|
1171
|
+
value,
|
|
1172
|
+
basePath,
|
|
1173
|
+
aliasRegistry.canonicalBasePath,
|
|
1174
|
+
);
|
|
1175
|
+
} else {
|
|
1176
|
+
outHeaders[name] = value;
|
|
1177
|
+
}
|
|
561
1178
|
}
|
|
562
1179
|
for (const [name, value] of Object.entries(BASE_SECURITY_HEADERS)) {
|
|
563
1180
|
outHeaders[name] = value;
|
|
@@ -565,7 +1182,9 @@ async function handleRequest(
|
|
|
565
1182
|
if (contentType?.toLowerCase().includes("text/html") === true) {
|
|
566
1183
|
outHeaders["content-security-policy"] = DOCUMENT_CONTENT_SECURITY_POLICY;
|
|
567
1184
|
}
|
|
568
|
-
|
|
1185
|
+
const responseStatus =
|
|
1186
|
+
shouldRewriteBody && proxyRes.statusCode === 206 ? 200 : (proxyRes.statusCode ?? 502);
|
|
1187
|
+
res.writeHead(responseStatus, outHeaders);
|
|
569
1188
|
if (shouldRewriteBody) {
|
|
570
1189
|
const chunks: Buffer[] = [];
|
|
571
1190
|
proxyRes.on("data", (chunk: unknown) => {
|
|
@@ -573,11 +1192,12 @@ async function handleRequest(
|
|
|
573
1192
|
});
|
|
574
1193
|
proxyRes.on("end", () => {
|
|
575
1194
|
const body = Buffer.concat(chunks).toString("utf8");
|
|
576
|
-
res.
|
|
1195
|
+
res.write(
|
|
577
1196
|
shouldRewriteHtml
|
|
578
|
-
? rewriteHtmlForBasePath(body, basePath)
|
|
579
|
-
: rewriteJavaScriptForBasePath(body, basePath),
|
|
1197
|
+
? rewriteHtmlForBasePath(body, basePath, aliasRegistry.canonicalBasePath)
|
|
1198
|
+
: rewriteJavaScriptForBasePath(body, basePath, aliasRegistry.canonicalBasePath),
|
|
580
1199
|
);
|
|
1200
|
+
res.end();
|
|
581
1201
|
});
|
|
582
1202
|
proxyRes.on("error", () => {
|
|
583
1203
|
if (!res.writableEnded) res.destroy();
|
|
@@ -625,7 +1245,7 @@ async function handleRequest(
|
|
|
625
1245
|
proxyReq.on("drain", () => req.resume());
|
|
626
1246
|
res.once("close", () => {
|
|
627
1247
|
clearDeadline();
|
|
628
|
-
|
|
1248
|
+
proxyReq.destroy();
|
|
629
1249
|
});
|
|
630
1250
|
|
|
631
1251
|
let receivedBytes = 0;
|