@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.
Files changed (70) hide show
  1. package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +10 -5
  2. package/dist/clients/attach/attach-cmd.js +732 -704
  3. package/dist/core/__tests__/fixtures/fake-engine.js +18 -8
  4. package/dist/core/__tests__/flagship-lifecycle.test.js +4 -1
  5. package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +1 -1
  6. package/dist/core/__tests__/full/detach-focus.test.js +15 -1
  7. package/dist/core/__tests__/live-mutation-verbs.test.js +8 -2
  8. package/dist/core/__tests__/live-mutation.test.js +7 -2
  9. package/dist/core/hearth/config.js +3 -6
  10. package/dist/core/hearth/index.d.ts +0 -1
  11. package/dist/core/hearth/index.js +0 -1
  12. package/dist/core/hearth/model-auth-guest.js +9 -14
  13. package/dist/core/hearth/providers/blaxel.js +3 -3
  14. package/dist/core/hearth/types.d.ts +0 -1
  15. package/dist/web-client/assets/index-DUThOUzU.css +2 -0
  16. package/dist/web-client/assets/index-IAJVtuVe.js +80 -0
  17. package/dist/web-client/index.html +2 -2
  18. package/package.json +2 -3
  19. package/dist/core/hearth/registry.d.ts +0 -15
  20. package/dist/core/hearth/registry.js +0 -180
  21. package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.d.ts +0 -1
  22. package/dist/hearth/wake-proxy/__tests__/anthropic-oauth.test.js +0 -289
  23. package/dist/hearth/wake-proxy/__tests__/config-timeout.test.d.ts +0 -1
  24. package/dist/hearth/wake-proxy/__tests__/config-timeout.test.js +0 -34
  25. package/dist/hearth/wake-proxy/__tests__/guest-source.test.d.ts +0 -1
  26. package/dist/hearth/wake-proxy/__tests__/guest-source.test.js +0 -203
  27. package/dist/hearth/wake-proxy/__tests__/hardening.test.d.ts +0 -1
  28. package/dist/hearth/wake-proxy/__tests__/hardening.test.js +0 -59
  29. package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.d.ts +0 -1
  30. package/dist/hearth/wake-proxy/__tests__/hearth-status-route.test.js +0 -372
  31. package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.d.ts +0 -1
  32. package/dist/hearth/wake-proxy/__tests__/http-running-route-fast-path.test.js +0 -258
  33. package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.d.ts +0 -1
  34. package/dist/hearth/wake-proxy/__tests__/model-auth-routes.test.js +0 -437
  35. package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.d.ts +0 -1
  36. package/dist/hearth/wake-proxy/__tests__/static-asset-allowlist.test.js +0 -15
  37. package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.d.ts +0 -1
  38. package/dist/hearth/wake-proxy/__tests__/warm-path-concurrency.test.js +0 -141
  39. package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.d.ts +0 -1
  40. package/dist/hearth/wake-proxy/__tests__/ws-teardown-accounting.test.js +0 -143
  41. package/dist/hearth/wake-proxy/anthropic-oauth.d.ts +0 -58
  42. package/dist/hearth/wake-proxy/anthropic-oauth.js +0 -189
  43. package/dist/hearth/wake-proxy/auth.d.ts +0 -22
  44. package/dist/hearth/wake-proxy/auth.js +0 -128
  45. package/dist/hearth/wake-proxy/config.d.ts +0 -2
  46. package/dist/hearth/wake-proxy/config.js +0 -151
  47. package/dist/hearth/wake-proxy/guest-source.d.ts +0 -14
  48. package/dist/hearth/wake-proxy/guest-source.js +0 -130
  49. package/dist/hearth/wake-proxy/hearth-status.d.ts +0 -44
  50. package/dist/hearth/wake-proxy/hearth-status.js +0 -267
  51. package/dist/hearth/wake-proxy/home.d.ts +0 -61
  52. package/dist/hearth/wake-proxy/home.js +0 -333
  53. package/dist/hearth/wake-proxy/main.d.ts +0 -1
  54. package/dist/hearth/wake-proxy/main.js +0 -134
  55. package/dist/hearth/wake-proxy/model-auth.d.ts +0 -13
  56. package/dist/hearth/wake-proxy/model-auth.js +0 -345
  57. package/dist/hearth/wake-proxy/proxy.d.ts +0 -35
  58. package/dist/hearth/wake-proxy/proxy.js +0 -716
  59. package/dist/hearth/wake-proxy/public-source-gate.d.ts +0 -9
  60. package/dist/hearth/wake-proxy/public-source-gate.js +0 -409
  61. package/dist/hearth/wake-proxy/redact.d.ts +0 -1
  62. package/dist/hearth/wake-proxy/redact.js +0 -17
  63. package/dist/hearth/wake-proxy/server.d.ts +0 -11
  64. package/dist/hearth/wake-proxy/server.js +0 -142
  65. package/dist/hearth/wake-proxy/state.d.ts +0 -18
  66. package/dist/hearth/wake-proxy/state.js +0 -342
  67. package/dist/hearth/wake-proxy/types.d.ts +0 -76
  68. package/dist/hearth/wake-proxy/types.js +0 -1
  69. package/dist/web-client/assets/index-BRKxe-hy.js +0 -80
  70. 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
- }