@crouton-kit/crouter 0.3.34 → 0.3.35
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/builtin-pi-packages/pi-mode-switch/extensions/index.ts +10 -5
- package/dist/clients/attach/attach-cmd.js +732 -704
- package/dist/core/__tests__/fixtures/fake-engine.js +18 -8
- package/dist/core/__tests__/flagship-lifecycle.test.js +4 -1
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +1 -1
- package/dist/core/__tests__/full/detach-focus.test.js +15 -1
- package/dist/core/__tests__/live-mutation-verbs.test.js +8 -2
- package/dist/core/__tests__/live-mutation.test.js +7 -2
- package/dist/core/hearth/config.js +3 -6
- package/dist/core/hearth/index.d.ts +0 -1
- package/dist/core/hearth/index.js +0 -1
- package/dist/core/hearth/model-auth-guest.js +9 -14
- package/dist/core/hearth/providers/blaxel.js +3 -3
- package/dist/core/hearth/types.d.ts +0 -1
- package/dist/web-client/assets/index-DUThOUzU.css +2 -0
- package/dist/web-client/assets/index-IAJVtuVe.js +80 -0
- package/dist/web-client/index.html +2 -2
- package/package.json +2 -3
- package/dist/core/hearth/registry.d.ts +0 -15
- package/dist/core/hearth/registry.js +0 -180
- package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.js +0 -289
- package/dist/hearth/wake-proxy/__tests__/config-timeout.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/config-timeout.test.js +0 -34
- package/dist/hearth/wake-proxy/__tests__/guest-source.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/guest-source.test.js +0 -203
- package/dist/hearth/wake-proxy/__tests__/hardening.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/hardening.test.js +0 -59
- package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.js +0 -372
- package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.js +0 -258
- package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.js +0 -437
- package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.js +0 -15
- package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.js +0 -141
- package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.d.ts +0 -1
- package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.js +0 -143
- package/dist/hearth/wake-proxy/anthropic-oauth.d.ts +0 -58
- package/dist/hearth/wake-proxy/anthropic-oauth.js +0 -189
- package/dist/hearth/wake-proxy/auth.d.ts +0 -22
- package/dist/hearth/wake-proxy/auth.js +0 -128
- package/dist/hearth/wake-proxy/config.d.ts +0 -2
- package/dist/hearth/wake-proxy/config.js +0 -151
- package/dist/hearth/wake-proxy/guest-source.d.ts +0 -14
- package/dist/hearth/wake-proxy/guest-source.js +0 -130
- package/dist/hearth/wake-proxy/hearth-status.d.ts +0 -44
- package/dist/hearth/wake-proxy/hearth-status.js +0 -267
- package/dist/hearth/wake-proxy/home.d.ts +0 -61
- package/dist/hearth/wake-proxy/home.js +0 -333
- package/dist/hearth/wake-proxy/main.d.ts +0 -1
- package/dist/hearth/wake-proxy/main.js +0 -134
- package/dist/hearth/wake-proxy/model-auth.d.ts +0 -13
- package/dist/hearth/wake-proxy/model-auth.js +0 -345
- package/dist/hearth/wake-proxy/proxy.d.ts +0 -35
- package/dist/hearth/wake-proxy/proxy.js +0 -716
- package/dist/hearth/wake-proxy/public-source-gate.d.ts +0 -9
- package/dist/hearth/wake-proxy/public-source-gate.js +0 -409
- package/dist/hearth/wake-proxy/redact.d.ts +0 -1
- package/dist/hearth/wake-proxy/redact.js +0 -17
- package/dist/hearth/wake-proxy/server.d.ts +0 -11
- package/dist/hearth/wake-proxy/server.js +0 -142
- package/dist/hearth/wake-proxy/state.d.ts +0 -18
- package/dist/hearth/wake-proxy/state.js +0 -342
- package/dist/hearth/wake-proxy/types.d.ts +0 -76
- package/dist/hearth/wake-proxy/types.js +0 -1
- package/dist/web-client/assets/index-BRKxe-hy.js +0 -80
- package/dist/web-client/assets/index-BZUxTkv5.css +0 -2
|
@@ -1,716 +0,0 @@
|
|
|
1
|
-
import { Readable } from 'node:stream';
|
|
2
|
-
import { WebSocket, WebSocketServer } from 'ws';
|
|
3
|
-
import { BROKER_READ_CAPS } from '../../core/runtime/broker-protocol.js';
|
|
4
|
-
import { CrtrError, general } from '../../core/errors.js';
|
|
5
|
-
import { loadProxyState, ensureProxyState } from './state.js';
|
|
6
|
-
import { acquireActivity, ensureAwake, invalidateWarmVerdict, releaseActivity, resolveRunningHome } from './home.js';
|
|
7
|
-
import { ownerAuthRedactedMessage, resolveOwnerAuth, setOwnerCookie } from './auth.js';
|
|
8
|
-
const hopByHopHeaders = new Set([
|
|
9
|
-
'connection',
|
|
10
|
-
'keep-alive',
|
|
11
|
-
'proxy-authenticate',
|
|
12
|
-
'proxy-authorization',
|
|
13
|
-
'proxy-connection',
|
|
14
|
-
'te',
|
|
15
|
-
'trailer',
|
|
16
|
-
'transfer-encoding',
|
|
17
|
-
'upgrade',
|
|
18
|
-
]);
|
|
19
|
-
function currentState(runtime) {
|
|
20
|
-
return loadProxyState(runtime.proxy.statePath) ?? ensureProxyState(runtime.proxy);
|
|
21
|
-
}
|
|
22
|
-
function redact(message) {
|
|
23
|
-
return ownerAuthRedactedMessage(message).replace(/\bsha256:[0-9a-f]{64}\b/gi, 'sha256:[redacted]');
|
|
24
|
-
}
|
|
25
|
-
function redactDeep(value) {
|
|
26
|
-
if (typeof value === 'string')
|
|
27
|
-
return redact(value);
|
|
28
|
-
if (Array.isArray(value))
|
|
29
|
-
return value.map((entry) => redactDeep(entry));
|
|
30
|
-
if (value !== null && typeof value === 'object') {
|
|
31
|
-
return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactDeep(entry)]));
|
|
32
|
-
}
|
|
33
|
-
return value;
|
|
34
|
-
}
|
|
35
|
-
export function serializeWakeProxyError(error) {
|
|
36
|
-
if (error instanceof CrtrError) {
|
|
37
|
-
return {
|
|
38
|
-
error: {
|
|
39
|
-
code: error.code,
|
|
40
|
-
message: redact(error.message),
|
|
41
|
-
exitCode: error.exitCode,
|
|
42
|
-
...(error.details === undefined ? {} : { details: redactDeep(error.details) }),
|
|
43
|
-
},
|
|
44
|
-
};
|
|
45
|
-
}
|
|
46
|
-
if (error instanceof Error) {
|
|
47
|
-
return { error: { message: redact(error.message) } };
|
|
48
|
-
}
|
|
49
|
-
return { error: { message: redact(String(error)) } };
|
|
50
|
-
}
|
|
51
|
-
function logError(message, details = {}) {
|
|
52
|
-
process.stderr.write(`${JSON.stringify({ level: 'error', message, details })}\n`);
|
|
53
|
-
}
|
|
54
|
-
function requestPath(req) {
|
|
55
|
-
return req.url ?? '/';
|
|
56
|
-
}
|
|
57
|
-
function requestPathname(req) {
|
|
58
|
-
return new URL(requestPath(req), 'http://127.0.0.1').pathname;
|
|
59
|
-
}
|
|
60
|
-
const publicStaticAssetPaths = new Set([
|
|
61
|
-
'/favicon.ico',
|
|
62
|
-
'/favicon.svg',
|
|
63
|
-
'/favicon.png',
|
|
64
|
-
'/apple-touch-icon.png',
|
|
65
|
-
'/apple-touch-icon-precomposed.png',
|
|
66
|
-
'/manifest.webmanifest',
|
|
67
|
-
'/site.webmanifest',
|
|
68
|
-
'/robots.txt',
|
|
69
|
-
'/vite.svg',
|
|
70
|
-
]);
|
|
71
|
-
const publicStaticAssetExtensions = new Set([
|
|
72
|
-
'.css',
|
|
73
|
-
'.js',
|
|
74
|
-
'.mjs',
|
|
75
|
-
'.map',
|
|
76
|
-
'.png',
|
|
77
|
-
'.jpg',
|
|
78
|
-
'.jpeg',
|
|
79
|
-
'.gif',
|
|
80
|
-
'.webp',
|
|
81
|
-
'.svg',
|
|
82
|
-
'.ico',
|
|
83
|
-
'.json',
|
|
84
|
-
'.txt',
|
|
85
|
-
]);
|
|
86
|
-
export function isPublicStaticAssetPath(pathname) {
|
|
87
|
-
if (pathname.startsWith('/assets/'))
|
|
88
|
-
return true;
|
|
89
|
-
if (publicStaticAssetPaths.has(pathname))
|
|
90
|
-
return true;
|
|
91
|
-
const match = /^\/[^/?#]+(\.[^/?#]+)$/.exec(pathname);
|
|
92
|
-
return match !== null && publicStaticAssetExtensions.has(match[1].toLowerCase());
|
|
93
|
-
}
|
|
94
|
-
function stripTokenFromTarget(rawUrl) {
|
|
95
|
-
const url = new URL(rawUrl, 'http://127.0.0.1');
|
|
96
|
-
url.searchParams.delete('token');
|
|
97
|
-
const query = url.searchParams.toString();
|
|
98
|
-
return `${url.pathname}${query === '' ? '' : `?${query}`}`;
|
|
99
|
-
}
|
|
100
|
-
function extractNodeId(pathname) {
|
|
101
|
-
const match = /^\/node\/([^/?#]+)\/?$/.exec(pathname);
|
|
102
|
-
if (match === null)
|
|
103
|
-
return null;
|
|
104
|
-
try {
|
|
105
|
-
const nodeId = decodeURIComponent(match[1]);
|
|
106
|
-
if (nodeId === '' || /[/\\]/.test(nodeId) || nodeId.includes('..'))
|
|
107
|
-
return null;
|
|
108
|
-
return nodeId;
|
|
109
|
-
}
|
|
110
|
-
catch {
|
|
111
|
-
return null;
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
function validateOriginFormRequestTarget(rawRequestUrl) {
|
|
115
|
-
const raw = rawRequestUrl === undefined || rawRequestUrl === '' ? '/' : rawRequestUrl;
|
|
116
|
-
if (!raw.startsWith('/')) {
|
|
117
|
-
throw general('proxy only accepts origin-form request targets', { requestUrl: raw.replace(/\?.*$/, '') });
|
|
118
|
-
}
|
|
119
|
-
if (raw.startsWith('//')) {
|
|
120
|
-
throw general('proxy rejected scheme-relative request target', { requestUrl: raw.replace(/\?.*$/, '') });
|
|
121
|
-
}
|
|
122
|
-
decodePathForValidation(raw);
|
|
123
|
-
return raw;
|
|
124
|
-
}
|
|
125
|
-
function decodePathForValidation(raw) {
|
|
126
|
-
for (let i = 0; i < raw.length; i += 1) {
|
|
127
|
-
const ch = raw[i];
|
|
128
|
-
if (ch === '%') {
|
|
129
|
-
const a = raw[i + 1];
|
|
130
|
-
const b = raw[i + 2];
|
|
131
|
-
if (a === undefined || b === undefined || !/[0-9A-Fa-f]/.test(a) || !/[0-9A-Fa-f]/.test(b)) {
|
|
132
|
-
throw general('proxy rejected malformed percent-encoding in request target', { requestUrl: '[redacted]' });
|
|
133
|
-
}
|
|
134
|
-
const decoded = Number.parseInt(`${a}${b}`, 16);
|
|
135
|
-
if (decoded <= 0x1f || decoded === 0x7f || decoded === 0x5c) {
|
|
136
|
-
throw general('proxy rejected control or backslash characters in request target', { requestUrl: '[redacted]' });
|
|
137
|
-
}
|
|
138
|
-
i += 2;
|
|
139
|
-
continue;
|
|
140
|
-
}
|
|
141
|
-
if (ch === '\\' || ch === '\u0000' || ch < ' ' || ch === '\u007f') {
|
|
142
|
-
throw general('proxy rejected control characters in request target', { requestUrl: '[redacted]' });
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
export function buildUpstreamHttpUrl(routeUrl, rawRequestUrl) {
|
|
147
|
-
const route = new URL(routeUrl);
|
|
148
|
-
const raw = validateOriginFormRequestTarget(rawRequestUrl);
|
|
149
|
-
const upstream = new URL(raw, route);
|
|
150
|
-
if (upstream.origin !== route.origin) {
|
|
151
|
-
throw general('proxy rejected request target that escapes the upstream origin', { requestUrl: raw.replace(/\?.*$/, '') });
|
|
152
|
-
}
|
|
153
|
-
upstream.searchParams.delete('token');
|
|
154
|
-
if (upstream.origin !== route.origin) {
|
|
155
|
-
throw general('proxy rejected upstream origin mismatch', { requestUrl: raw.replace(/\?.*$/, '') });
|
|
156
|
-
}
|
|
157
|
-
return upstream;
|
|
158
|
-
}
|
|
159
|
-
function filteredRequestHeaders(req, relayToken) {
|
|
160
|
-
const headers = new Headers();
|
|
161
|
-
for (const [name, value] of Object.entries(req.headers)) {
|
|
162
|
-
if (value === undefined)
|
|
163
|
-
continue;
|
|
164
|
-
const lower = name.toLowerCase();
|
|
165
|
-
if (lower === 'host' || lower === 'authorization' || lower === 'cookie' || lower === 'referer' || lower === 'referrer' || lower.startsWith('proxy-') || lower.startsWith('sec-websocket-') || hopByHopHeaders.has(lower)) {
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
headers.set(lower, Array.isArray(value) ? value.join(',') : value);
|
|
169
|
-
}
|
|
170
|
-
headers.set('authorization', `Bearer ${relayToken}`);
|
|
171
|
-
headers.set('accept-encoding', 'identity');
|
|
172
|
-
return headers;
|
|
173
|
-
}
|
|
174
|
-
function sanitizeLocation(location, upstreamOrigin) {
|
|
175
|
-
// Same-origin relative redirect (origin-form) is safe to forward verbatim.
|
|
176
|
-
if (location.startsWith('/') && !location.startsWith('//'))
|
|
177
|
-
return location;
|
|
178
|
-
try {
|
|
179
|
-
const abs = new URL(location, upstreamOrigin);
|
|
180
|
-
// An absolute redirect back to the upstream route origin is rewritten to a Hearth-relative path so
|
|
181
|
-
// the browser stays behind the front door; any other absolute origin is dropped entirely.
|
|
182
|
-
if (abs.origin === upstreamOrigin)
|
|
183
|
-
return `${abs.pathname}${abs.search}${abs.hash}`;
|
|
184
|
-
}
|
|
185
|
-
catch {
|
|
186
|
-
/* malformed Location — drop it */
|
|
187
|
-
}
|
|
188
|
-
return null;
|
|
189
|
-
}
|
|
190
|
-
function applyResponseHeaders(res, headers, upstreamOrigin) {
|
|
191
|
-
headers.forEach((value, key) => {
|
|
192
|
-
const lower = key.toLowerCase();
|
|
193
|
-
if (lower === 'set-cookie' || hopByHopHeaders.has(lower) || lower.startsWith('proxy-'))
|
|
194
|
-
return;
|
|
195
|
-
if (lower === 'location') {
|
|
196
|
-
const sanitized = sanitizeLocation(value, upstreamOrigin);
|
|
197
|
-
if (sanitized !== null)
|
|
198
|
-
res.setHeader('location', sanitized);
|
|
199
|
-
return;
|
|
200
|
-
}
|
|
201
|
-
res.setHeader(lower, value);
|
|
202
|
-
});
|
|
203
|
-
const contentType = headers.get('content-type');
|
|
204
|
-
if (contentType !== null && !res.hasHeader('content-type'))
|
|
205
|
-
res.setHeader('content-type', contentType);
|
|
206
|
-
}
|
|
207
|
-
function tuneStreamingHeaders(res, isEventStream) {
|
|
208
|
-
if (!isEventStream)
|
|
209
|
-
return;
|
|
210
|
-
const cacheControl = res.getHeader('cache-control');
|
|
211
|
-
if (typeof cacheControl === 'string') {
|
|
212
|
-
if (!/no-transform/i.test(cacheControl))
|
|
213
|
-
res.setHeader('cache-control', `${cacheControl}, no-transform`);
|
|
214
|
-
}
|
|
215
|
-
else if (cacheControl === undefined) {
|
|
216
|
-
res.setHeader('cache-control', 'no-cache, no-transform');
|
|
217
|
-
}
|
|
218
|
-
res.setHeader('connection', 'keep-alive');
|
|
219
|
-
res.setHeader('x-accel-buffering', 'no');
|
|
220
|
-
res.socket?.setNoDelay(true);
|
|
221
|
-
}
|
|
222
|
-
async function waitForDrain(res, signal) {
|
|
223
|
-
await new Promise((resolve, reject) => {
|
|
224
|
-
const cleanup = () => {
|
|
225
|
-
res.off('drain', onDrain);
|
|
226
|
-
res.off('close', onClose);
|
|
227
|
-
res.off('error', onError);
|
|
228
|
-
signal.removeEventListener('abort', onAbort);
|
|
229
|
-
};
|
|
230
|
-
const onDrain = () => {
|
|
231
|
-
cleanup();
|
|
232
|
-
resolve();
|
|
233
|
-
};
|
|
234
|
-
const onClose = () => {
|
|
235
|
-
cleanup();
|
|
236
|
-
reject(new Error('response closed while streaming'));
|
|
237
|
-
};
|
|
238
|
-
const onError = (error) => {
|
|
239
|
-
cleanup();
|
|
240
|
-
reject(error instanceof Error ? error : new Error(String(error)));
|
|
241
|
-
};
|
|
242
|
-
const onAbort = () => {
|
|
243
|
-
cleanup();
|
|
244
|
-
reject(new Error('response aborted while streaming'));
|
|
245
|
-
};
|
|
246
|
-
res.once('drain', onDrain);
|
|
247
|
-
res.once('close', onClose);
|
|
248
|
-
res.once('error', onError);
|
|
249
|
-
signal.addEventListener('abort', onAbort, { once: true });
|
|
250
|
-
});
|
|
251
|
-
}
|
|
252
|
-
async function streamResponseBody(body, res, signal) {
|
|
253
|
-
const reader = body.getReader();
|
|
254
|
-
try {
|
|
255
|
-
while (true) {
|
|
256
|
-
const { value, done } = await reader.read();
|
|
257
|
-
if (done)
|
|
258
|
-
return;
|
|
259
|
-
if (!res.write(Buffer.from(value))) {
|
|
260
|
-
await waitForDrain(res, signal);
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
finally {
|
|
265
|
-
reader.releaseLock();
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
async function withWakeTimeout(work, timeoutMs, label) {
|
|
269
|
-
if (timeoutMs <= 0)
|
|
270
|
-
return work;
|
|
271
|
-
let timer = null;
|
|
272
|
-
try {
|
|
273
|
-
return await Promise.race([
|
|
274
|
-
work,
|
|
275
|
-
new Promise((_, reject) => {
|
|
276
|
-
timer = setTimeout(() => reject(general(`${label} timed out`, { timeoutMs })), timeoutMs);
|
|
277
|
-
timer.unref();
|
|
278
|
-
}),
|
|
279
|
-
]);
|
|
280
|
-
}
|
|
281
|
-
finally {
|
|
282
|
-
if (timer !== null)
|
|
283
|
-
clearTimeout(timer);
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
export const proxyInternals = {
|
|
287
|
-
withWakeTimeout,
|
|
288
|
-
attachWsKeepalive,
|
|
289
|
-
proxyWebSocketBrowser,
|
|
290
|
-
};
|
|
291
|
-
function closeHttpWithError(res, statusCode, message) {
|
|
292
|
-
if (!res.headersSent) {
|
|
293
|
-
res.writeHead(statusCode, { 'content-type': 'text/plain; charset=utf-8' });
|
|
294
|
-
}
|
|
295
|
-
res.end(`${message}\n`);
|
|
296
|
-
}
|
|
297
|
-
/**
|
|
298
|
-
* Forward one HTTP request to an already-selected upstream route and stream the response back.
|
|
299
|
-
* Returns normally once the response is fully relayed; throws on a genuine upstream/network
|
|
300
|
-
* failure (connect refused/reset, DNS, body-stream error). An app-level 4xx/5xx is a RESOLVED
|
|
301
|
-
* fetch and is relayed verbatim — it never throws here, so it is never mistaken for a dead home.
|
|
302
|
-
*/
|
|
303
|
-
async function forwardUpstream(req, res, awakened, controller) {
|
|
304
|
-
const upstreamUrl = buildUpstreamHttpUrl(awakened.routeUrl, requestPath(req));
|
|
305
|
-
const method = req.method ?? 'GET';
|
|
306
|
-
const canHaveBody = method !== 'GET' && method !== 'HEAD';
|
|
307
|
-
const requestInit = {
|
|
308
|
-
method,
|
|
309
|
-
headers: filteredRequestHeaders(req, awakened.relayToken),
|
|
310
|
-
body: canHaveBody ? Readable.toWeb(req) : undefined,
|
|
311
|
-
signal: controller.signal,
|
|
312
|
-
redirect: 'manual',
|
|
313
|
-
};
|
|
314
|
-
if (canHaveBody)
|
|
315
|
-
requestInit.duplex = 'half';
|
|
316
|
-
const response = await fetch(upstreamUrl, requestInit);
|
|
317
|
-
const responseHeaders = new Headers(response.headers);
|
|
318
|
-
const isEventStream = /text\/event-stream/i.test(responseHeaders.get('content-type') ?? '');
|
|
319
|
-
if (isEventStream) {
|
|
320
|
-
responseHeaders.delete('content-length');
|
|
321
|
-
}
|
|
322
|
-
res.statusCode = response.status;
|
|
323
|
-
res.statusMessage = response.statusText;
|
|
324
|
-
applyResponseHeaders(res, responseHeaders, new URL(awakened.routeUrl).origin);
|
|
325
|
-
tuneStreamingHeaders(res, isEventStream);
|
|
326
|
-
if (response.body === null || method === 'HEAD') {
|
|
327
|
-
res.end();
|
|
328
|
-
return;
|
|
329
|
-
}
|
|
330
|
-
res.flushHeaders?.();
|
|
331
|
-
await streamResponseBody(response.body, res, controller.signal);
|
|
332
|
-
if (!res.writableEnded)
|
|
333
|
-
res.end();
|
|
334
|
-
}
|
|
335
|
-
function addRequestAbort(req, res, controller) {
|
|
336
|
-
const abort = () => controller.abort();
|
|
337
|
-
req.once('aborted', abort);
|
|
338
|
-
res.once('close', abort);
|
|
339
|
-
return () => {
|
|
340
|
-
req.off('aborted', abort);
|
|
341
|
-
res.off('close', abort);
|
|
342
|
-
};
|
|
343
|
-
}
|
|
344
|
-
export async function proxyHttpRequest(req, res, runtime) {
|
|
345
|
-
const pathname = requestPathname(req);
|
|
346
|
-
const isPublicStaticAsset = (req.method === 'GET' || req.method === 'HEAD') && isPublicStaticAssetPath(pathname);
|
|
347
|
-
const state = currentState(runtime);
|
|
348
|
-
let auth = null;
|
|
349
|
-
try {
|
|
350
|
-
auth = resolveOwnerAuth(req, runtime.proxy, state);
|
|
351
|
-
}
|
|
352
|
-
catch {
|
|
353
|
-
if (!isPublicStaticAsset) {
|
|
354
|
-
if (!res.headersSent)
|
|
355
|
-
closeHttpWithError(res, 403, 'forbidden');
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
if (auth === null && !isPublicStaticAsset) {
|
|
360
|
-
if (!res.headersSent)
|
|
361
|
-
closeHttpWithError(res, 401, 'owner token required');
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
const requestAuth = auth ?? {
|
|
365
|
-
ownerUserId: state.owner.ownerUserId,
|
|
366
|
-
tenantId: state.owner.defaultTenantId,
|
|
367
|
-
source: 'cookie',
|
|
368
|
-
token: '',
|
|
369
|
-
};
|
|
370
|
-
// An owner token in a ?token= query must never persist in the browser URL/history or be used to
|
|
371
|
-
// serve a proxied response. On a GET/HEAD deep link, convert it to the HttpOnly cookie and 303
|
|
372
|
-
// to the same tokenless path (mirroring GET /); reject it on any other method so the client
|
|
373
|
-
// retries via cookie/Bearer. The only legitimate token URL is the one immediately swapped here.
|
|
374
|
-
if (requestAuth.source === 'query') {
|
|
375
|
-
if (req.method === 'GET' || req.method === 'HEAD') {
|
|
376
|
-
setOwnerCookie(res, requestAuth.token, runtime.proxy);
|
|
377
|
-
res.writeHead(303, {
|
|
378
|
-
location: stripTokenFromTarget(requestPath(req)),
|
|
379
|
-
'cache-control': 'no-store',
|
|
380
|
-
'referrer-policy': 'no-referrer',
|
|
381
|
-
});
|
|
382
|
-
res.end();
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
if (!res.headersSent)
|
|
386
|
-
closeHttpWithError(res, 401, 'owner token required');
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
const activity = acquireActivity(requestAuth.tenantId, 'http');
|
|
390
|
-
const controller = new AbortController();
|
|
391
|
-
const cleanup = addRequestAbort(req, res, controller);
|
|
392
|
-
try {
|
|
393
|
-
// Fast path: a home crtr already records as `running` with a known route+token is forwarded
|
|
394
|
-
// directly — the forward itself is the liveness check. Normal GET/asset/SSE traffic must NOT
|
|
395
|
-
// enter the heavy ensureAwake/restore gate (probe + 150s serial guest execs) while the direct
|
|
396
|
-
// home route is serving 200 in ~0.1s. Only a missing/not-running home, or a CLEAR upstream
|
|
397
|
-
// failure against the trusted route, escalates to the deliberate wake path below.
|
|
398
|
-
const running = resolveRunningHome(requestAuth.tenantId);
|
|
399
|
-
if (running !== null) {
|
|
400
|
-
try {
|
|
401
|
-
await forwardUpstream(req, res, running, controller);
|
|
402
|
-
return;
|
|
403
|
-
}
|
|
404
|
-
catch (error) {
|
|
405
|
-
// Reaching here means a genuine upstream/network failure (app 4xx/5xx never throw). If
|
|
406
|
-
// nothing was sent, the client is still here, and the request is safe to replay (no body),
|
|
407
|
-
// the running verdict was stale — invalidate it and escalate to ONE deliberate wake.
|
|
408
|
-
// Anything else (mid-stream failure, aborted, or a body-bearing method we can't replay)
|
|
409
|
-
// rethrows to the handler's error path, which 502s and invalidates the warm verdict.
|
|
410
|
-
const method = req.method ?? 'GET';
|
|
411
|
-
const replayable = method === 'GET' || method === 'HEAD';
|
|
412
|
-
if (res.headersSent || controller.signal.aborted || !replayable)
|
|
413
|
-
throw error;
|
|
414
|
-
logError('hearth wake proxy running-route forward failed; escalating to deliberate wake', {
|
|
415
|
-
path: requestPath(req).replace(/\?.*$/, ''),
|
|
416
|
-
...serializeWakeProxyError(error),
|
|
417
|
-
});
|
|
418
|
-
invalidateWarmVerdict(requestAuth.tenantId);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
// Deliberate wake path: missing/not-running home, or a stale running verdict that just failed.
|
|
422
|
-
// Bounded by wakeTimeoutMs; ensureAwake probes/restores under the per-tenant lock.
|
|
423
|
-
const awakened = await withWakeTimeout(ensureAwake(requestAuth.tenantId), runtime.proxy.wakeTimeoutMs, 'wake proxy request');
|
|
424
|
-
if (controller.signal.aborted)
|
|
425
|
-
throw general('request closed while waking');
|
|
426
|
-
await forwardUpstream(req, res, awakened, controller);
|
|
427
|
-
}
|
|
428
|
-
catch (error) {
|
|
429
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
430
|
-
logError('hearth wake proxy HTTP request failed', { path: requestPath(req).replace(/\?.*$/, ''), ...serializeWakeProxyError(error) });
|
|
431
|
-
// An upstream forward failure (bad gateway / wake timeout) may mean the home died mid-TTL. Drop
|
|
432
|
-
// the cached warm verdict so the next request re-probes/restores instead of serving the stale
|
|
433
|
-
// "serving" answer for the rest of the window. Pure request-validation errors leave it intact.
|
|
434
|
-
if (!message.startsWith('proxy '))
|
|
435
|
-
invalidateWarmVerdict(requestAuth.tenantId);
|
|
436
|
-
if (controller.signal.aborted && /timed out/i.test(message)) {
|
|
437
|
-
closeHttpWithError(res, 504, 'wake timeout');
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
if (!res.headersSent) {
|
|
441
|
-
closeHttpWithError(res, message.startsWith('proxy ') ? 400 : 502, message.startsWith('proxy ') ? 'bad request' : 'bad gateway');
|
|
442
|
-
return;
|
|
443
|
-
}
|
|
444
|
-
try {
|
|
445
|
-
res.destroy();
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
/* ignore */
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
finally {
|
|
452
|
-
cleanup();
|
|
453
|
-
releaseActivity(activity);
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
function toWssUrl(routeUrl, nodeId) {
|
|
457
|
-
const url = new URL(routeUrl);
|
|
458
|
-
url.protocol = 'wss:';
|
|
459
|
-
url.pathname = `/node/${encodeURIComponent(nodeId)}`;
|
|
460
|
-
url.search = '';
|
|
461
|
-
url.hash = '';
|
|
462
|
-
return url.toString();
|
|
463
|
-
}
|
|
464
|
-
function sendableCloseCode(code) {
|
|
465
|
-
// 1005 (no status) and 1006 (abnormal) are reserved and must never be sent; clamp anything
|
|
466
|
-
// outside the sendable ranges back to 1000 so close() does not throw.
|
|
467
|
-
if (!Number.isInteger(code) || code === 1005 || code === 1006 || code < 1000 || code > 4999)
|
|
468
|
-
return 1000;
|
|
469
|
-
return code;
|
|
470
|
-
}
|
|
471
|
-
function sendSocketClose(socket, code, reason) {
|
|
472
|
-
try {
|
|
473
|
-
socket.close(sendableCloseCode(code), reason.slice(0, 123));
|
|
474
|
-
}
|
|
475
|
-
catch {
|
|
476
|
-
try {
|
|
477
|
-
socket.terminate();
|
|
478
|
-
}
|
|
479
|
-
catch {
|
|
480
|
-
/* ignore */
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
|
-
const wsKeepaliveIntervalMs = 25_000;
|
|
485
|
-
function attachWsKeepalive(socket, context, options = {}) {
|
|
486
|
-
const intervalMs = options.intervalMs ?? wsKeepaliveIntervalMs;
|
|
487
|
-
let alive = true;
|
|
488
|
-
let stopped = false;
|
|
489
|
-
let timer = null;
|
|
490
|
-
const stop = () => {
|
|
491
|
-
if (stopped)
|
|
492
|
-
return;
|
|
493
|
-
stopped = true;
|
|
494
|
-
if (timer !== null)
|
|
495
|
-
clearInterval(timer);
|
|
496
|
-
socket.off('pong', onPong);
|
|
497
|
-
};
|
|
498
|
-
const onPong = () => {
|
|
499
|
-
alive = true;
|
|
500
|
-
};
|
|
501
|
-
const terminateStaleSocket = (message) => {
|
|
502
|
-
logError(message, { ...context, readyState: socket.readyState });
|
|
503
|
-
options.onStale?.();
|
|
504
|
-
try {
|
|
505
|
-
socket.terminate();
|
|
506
|
-
}
|
|
507
|
-
catch {
|
|
508
|
-
/* ignore */
|
|
509
|
-
}
|
|
510
|
-
stop();
|
|
511
|
-
};
|
|
512
|
-
const pingOrTerminate = () => {
|
|
513
|
-
if (stopped || socket.readyState !== WebSocket.OPEN)
|
|
514
|
-
return;
|
|
515
|
-
if (!alive) {
|
|
516
|
-
terminateStaleSocket('hearth wake proxy ws keepalive stale');
|
|
517
|
-
return;
|
|
518
|
-
}
|
|
519
|
-
alive = false;
|
|
520
|
-
try {
|
|
521
|
-
socket.ping();
|
|
522
|
-
}
|
|
523
|
-
catch {
|
|
524
|
-
terminateStaleSocket('hearth wake proxy ws keepalive ping failed');
|
|
525
|
-
}
|
|
526
|
-
};
|
|
527
|
-
socket.on('pong', onPong);
|
|
528
|
-
timer = setInterval(pingOrTerminate, intervalMs);
|
|
529
|
-
timer.unref?.();
|
|
530
|
-
return stop;
|
|
531
|
-
}
|
|
532
|
-
function createUpgradeServer() {
|
|
533
|
-
return new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
|
|
534
|
-
}
|
|
535
|
-
async function proxyWebSocketBrowser(browserWs, nodeId, tenantId, runtime, deps = {}) {
|
|
536
|
-
const ensureAwakeImpl = deps.ensureAwake ?? ensureAwake;
|
|
537
|
-
const createUpstream = deps.createUpstream ??
|
|
538
|
-
((url, relayToken) => new WebSocket(url, { headers: { authorization: `Bearer ${relayToken}` }, perMessageDeflate: false }));
|
|
539
|
-
const activity = acquireActivity(tenantId, 'ws');
|
|
540
|
-
const handshakeAt = Date.now();
|
|
541
|
-
let upstream = null;
|
|
542
|
-
let settled = false;
|
|
543
|
-
let teardownStarted = false;
|
|
544
|
-
let upstreamOpenAt = null;
|
|
545
|
-
let browserClose = null;
|
|
546
|
-
let upstreamClose = null;
|
|
547
|
-
const queued = [];
|
|
548
|
-
const release = () => {
|
|
549
|
-
if (settled)
|
|
550
|
-
return;
|
|
551
|
-
settled = true;
|
|
552
|
-
releaseActivity(activity);
|
|
553
|
-
};
|
|
554
|
-
let stopBrowserKeepalive = () => { };
|
|
555
|
-
let stopUpstreamKeepalive = () => { };
|
|
556
|
-
const stopKeepalives = () => {
|
|
557
|
-
stopBrowserKeepalive();
|
|
558
|
-
stopUpstreamKeepalive();
|
|
559
|
-
};
|
|
560
|
-
const beginTeardown = () => {
|
|
561
|
-
if (teardownStarted)
|
|
562
|
-
return;
|
|
563
|
-
teardownStarted = true;
|
|
564
|
-
stopKeepalives();
|
|
565
|
-
release();
|
|
566
|
-
};
|
|
567
|
-
stopBrowserKeepalive = attachWsKeepalive(browserWs, { nodeId, tenantId, leg: 'browser' }, { onStale: beginTeardown });
|
|
568
|
-
const logClose = (leg, code, reason) => {
|
|
569
|
-
logError('hearth wake proxy ws close', {
|
|
570
|
-
nodeId,
|
|
571
|
-
tenantId,
|
|
572
|
-
leg,
|
|
573
|
-
code,
|
|
574
|
-
reason,
|
|
575
|
-
handshakeToUpstreamOpenMs: upstreamOpenAt === null ? null : upstreamOpenAt - handshakeAt,
|
|
576
|
-
browserCloseCode: browserClose?.code ?? null,
|
|
577
|
-
browserCloseReason: browserClose?.reason ?? null,
|
|
578
|
-
upstreamCloseCode: upstreamClose?.code ?? null,
|
|
579
|
-
upstreamCloseReason: upstreamClose?.reason ?? null,
|
|
580
|
-
browserReadyState: browserWs.readyState,
|
|
581
|
-
upstreamReadyState: upstream?.readyState ?? 'none',
|
|
582
|
-
teardownStarted,
|
|
583
|
-
});
|
|
584
|
-
};
|
|
585
|
-
const closeBoth = (code, reason) => {
|
|
586
|
-
beginTeardown();
|
|
587
|
-
if (browserWs.readyState === WebSocket.OPEN || browserWs.readyState === WebSocket.CONNECTING) {
|
|
588
|
-
sendSocketClose(browserWs, code, reason);
|
|
589
|
-
}
|
|
590
|
-
if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
|
|
591
|
-
sendSocketClose(upstream, code, reason);
|
|
592
|
-
}
|
|
593
|
-
};
|
|
594
|
-
browserWs.on('message', (data, isBinary) => {
|
|
595
|
-
if (upstream === null || upstream.readyState !== WebSocket.OPEN) {
|
|
596
|
-
queued.push({ data, isBinary });
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
try {
|
|
600
|
-
upstream.send(data, { binary: isBinary });
|
|
601
|
-
}
|
|
602
|
-
catch {
|
|
603
|
-
closeBoth(1011, 'failed to relay browser frame');
|
|
604
|
-
}
|
|
605
|
-
});
|
|
606
|
-
browserWs.on('close', (code, reason) => {
|
|
607
|
-
const closeReason = reason.toString('utf8').slice(0, 123);
|
|
608
|
-
browserClose = { code, reason: closeReason };
|
|
609
|
-
logClose('browser', code, closeReason);
|
|
610
|
-
beginTeardown();
|
|
611
|
-
if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
|
|
612
|
-
sendSocketClose(upstream, code, closeReason);
|
|
613
|
-
}
|
|
614
|
-
});
|
|
615
|
-
browserWs.on('error', (error) => {
|
|
616
|
-
logError('hearth wake proxy browser websocket failed', { nodeId, error: redact(error instanceof Error ? error.message : String(error)) });
|
|
617
|
-
closeBoth(1011, 'browser websocket error');
|
|
618
|
-
});
|
|
619
|
-
try {
|
|
620
|
-
const awakened = await withWakeTimeout(ensureAwakeImpl(tenantId), runtime.proxy.wakeTimeoutMs, 'wake proxy websocket');
|
|
621
|
-
if (browserWs.readyState !== WebSocket.OPEN) {
|
|
622
|
-
beginTeardown();
|
|
623
|
-
return;
|
|
624
|
-
}
|
|
625
|
-
upstream = createUpstream(toWssUrl(awakened.routeUrl, nodeId), awakened.relayToken);
|
|
626
|
-
upstream.on('open', () => {
|
|
627
|
-
if (teardownStarted || browserWs.readyState !== WebSocket.OPEN) {
|
|
628
|
-
beginTeardown();
|
|
629
|
-
if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
|
|
630
|
-
sendSocketClose(upstream, 1000, 'browser websocket closed before upstream open');
|
|
631
|
-
}
|
|
632
|
-
return;
|
|
633
|
-
}
|
|
634
|
-
upstreamOpenAt = Date.now();
|
|
635
|
-
logError('hearth wake proxy ws upstream open', {
|
|
636
|
-
nodeId,
|
|
637
|
-
tenantId,
|
|
638
|
-
handshakeToUpstreamOpenMs: upstreamOpenAt - handshakeAt,
|
|
639
|
-
browserReadyState: browserWs.readyState,
|
|
640
|
-
upstreamReadyState: upstream.readyState,
|
|
641
|
-
});
|
|
642
|
-
stopUpstreamKeepalive = attachWsKeepalive(upstream, { nodeId, tenantId, leg: 'upstream' }, { onStale: beginTeardown });
|
|
643
|
-
for (const frame of queued.splice(0, queued.length)) {
|
|
644
|
-
try {
|
|
645
|
-
upstream.send(frame.data, { binary: frame.isBinary });
|
|
646
|
-
}
|
|
647
|
-
catch {
|
|
648
|
-
closeBoth(1011, 'failed to flush buffered browser frame');
|
|
649
|
-
return;
|
|
650
|
-
}
|
|
651
|
-
}
|
|
652
|
-
});
|
|
653
|
-
upstream.on('message', (data, isBinary) => {
|
|
654
|
-
if (browserWs.readyState !== WebSocket.OPEN)
|
|
655
|
-
return;
|
|
656
|
-
browserWs.send(data, { binary: isBinary });
|
|
657
|
-
});
|
|
658
|
-
upstream.on('close', (code, reason) => {
|
|
659
|
-
const closeReason = reason.toString('utf8').slice(0, 123);
|
|
660
|
-
upstreamClose = { code, reason: closeReason };
|
|
661
|
-
logClose('upstream', code, closeReason);
|
|
662
|
-
beginTeardown();
|
|
663
|
-
if (browserWs.readyState === WebSocket.OPEN || browserWs.readyState === WebSocket.CONNECTING) {
|
|
664
|
-
sendSocketClose(browserWs, code, closeReason);
|
|
665
|
-
}
|
|
666
|
-
});
|
|
667
|
-
upstream.on('error', (error) => {
|
|
668
|
-
logError('hearth wake proxy WS upstream failed', { nodeId, error: redact(error instanceof Error ? error.message : String(error)) });
|
|
669
|
-
// The upstream leg failed — the home may be down; re-probe on the next connect.
|
|
670
|
-
invalidateWarmVerdict(tenantId);
|
|
671
|
-
closeBoth(1011, 'upstream websocket error');
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
|
-
catch (error) {
|
|
675
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
676
|
-
logError('hearth wake proxy websocket wake failed', { nodeId, ...serializeWakeProxyError(error) });
|
|
677
|
-
invalidateWarmVerdict(tenantId);
|
|
678
|
-
closeBoth(/timed out/i.test(message) ? 1013 : 1011, /timed out/i.test(message) ? 'wake timeout' : 'wake failed');
|
|
679
|
-
}
|
|
680
|
-
}
|
|
681
|
-
export function proxyUpgrade(req, socket, head, runtime) {
|
|
682
|
-
let nodeId = null;
|
|
683
|
-
try {
|
|
684
|
-
const rawTarget = validateOriginFormRequestTarget(req.url);
|
|
685
|
-
const pathname = new URL(rawTarget, 'http://127.0.0.1').pathname;
|
|
686
|
-
nodeId = extractNodeId(pathname);
|
|
687
|
-
}
|
|
688
|
-
catch (error) {
|
|
689
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
690
|
-
socket.write(`HTTP/1.1 ${message.startsWith('proxy ') ? '400 Bad Request' : '403 Forbidden'}\r\nConnection: close\r\n\r\n`);
|
|
691
|
-
socket.destroy();
|
|
692
|
-
return;
|
|
693
|
-
}
|
|
694
|
-
if (nodeId === null) {
|
|
695
|
-
socket.write('HTTP/1.1 404 Not Found\r\nConnection: close\r\n\r\n');
|
|
696
|
-
socket.destroy();
|
|
697
|
-
return;
|
|
698
|
-
}
|
|
699
|
-
let auth;
|
|
700
|
-
try {
|
|
701
|
-
const state = currentState(runtime);
|
|
702
|
-
auth = resolveOwnerAuth(req, runtime.proxy, state);
|
|
703
|
-
}
|
|
704
|
-
catch {
|
|
705
|
-
auth = null;
|
|
706
|
-
}
|
|
707
|
-
if (auth === null) {
|
|
708
|
-
socket.write('HTTP/1.1 403 Forbidden\r\nConnection: close\r\n\r\n');
|
|
709
|
-
socket.destroy();
|
|
710
|
-
return;
|
|
711
|
-
}
|
|
712
|
-
const wss = createUpgradeServer();
|
|
713
|
-
wss.handleUpgrade(req, socket, head, (browserWs) => {
|
|
714
|
-
void proxyWebSocketBrowser(browserWs, nodeId, auth.tenantId, runtime);
|
|
715
|
-
});
|
|
716
|
-
}
|