@crouton-kit/crouter 0.3.39 → 0.3.41

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 (188) hide show
  1. package/dist/build-root.js +1 -0
  2. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +472 -9
  3. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +284 -76
  4. package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/strip-skills-docs.ts +31 -24
  5. package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +52 -15
  6. package/dist/clients/attach/attach-cmd.js +706 -733
  7. package/dist/clients/attach/chat-view.js +11 -0
  8. package/dist/clients/web/server.js +5 -28
  9. package/dist/clients/web/web-cmd.js +1 -1
  10. package/dist/commands/__tests__/revive-now-gate.test.js +60 -0
  11. package/dist/commands/canvas-rebuild-index.js +5 -5
  12. package/dist/commands/node-snapshot.js +9 -1
  13. package/dist/commands/node.js +37 -17
  14. package/dist/commands/push.js +8 -0
  15. package/dist/commands/revive.d.ts +10 -0
  16. package/dist/commands/revive.js +28 -10
  17. package/dist/commands/sys/__tests__/setup-core.test.js +19 -0
  18. package/dist/commands/sys/doctor.js +1 -1
  19. package/dist/commands/sys/setup-core.js +3 -2
  20. package/dist/commands/sys/setup.js +1 -1
  21. package/dist/commands/worktree.d.ts +2 -0
  22. package/dist/commands/worktree.js +94 -0
  23. package/dist/core/__tests__/boot.test.js +4 -4
  24. package/dist/core/__tests__/canvas.test.js +19 -7
  25. package/dist/core/__tests__/child-followup.test.js +15 -5
  26. package/dist/core/__tests__/daemon-boot.test.js +6 -1
  27. package/dist/core/__tests__/daemon-wedge.test.js +18 -1
  28. package/dist/core/__tests__/fault-classifier.test.js +30 -0
  29. package/dist/core/__tests__/fixtures/fake-engine.d.ts +13 -0
  30. package/dist/core/__tests__/fixtures/fake-engine.js +20 -0
  31. package/dist/core/__tests__/full/spike-harness.test.js +8 -5
  32. package/dist/core/__tests__/grace-clock.test.js +18 -2
  33. package/dist/core/__tests__/host-teardown-process-group.test.js +465 -0
  34. package/dist/core/__tests__/prune-to-limit.test.js +14 -0
  35. package/dist/core/__tests__/review-model-floor.test.js +32 -0
  36. package/dist/core/__tests__/revive.test.js +18 -4
  37. package/dist/core/__tests__/session-cycles.test.js +77 -0
  38. package/dist/core/__tests__/worktree.test.js +85 -0
  39. package/dist/core/canvas/boot.js +12 -7
  40. package/dist/core/canvas/canvas.d.ts +27 -8
  41. package/dist/core/canvas/canvas.js +54 -26
  42. package/dist/core/canvas/db.js +14 -0
  43. package/dist/core/canvas/history.js +1 -0
  44. package/dist/core/canvas/paths.d.ts +10 -9
  45. package/dist/core/canvas/paths.js +10 -9
  46. package/dist/core/canvas/pid.d.ts +155 -1
  47. package/dist/core/canvas/pid.js +306 -1
  48. package/dist/core/canvas/status-glyph.d.ts +7 -0
  49. package/dist/core/canvas/status-glyph.js +10 -1
  50. package/dist/core/canvas/types.d.ts +34 -0
  51. package/dist/core/fault-classifier.js +5 -1
  52. package/dist/core/runtime/bearings.js +4 -0
  53. package/dist/core/runtime/branded-host.d.ts +7 -0
  54. package/dist/core/runtime/branded-host.js +44 -17
  55. package/dist/core/runtime/broker-sdk.js +28 -68
  56. package/dist/core/runtime/broker.js +35 -4
  57. package/dist/core/runtime/host.d.ts +3 -3
  58. package/dist/core/runtime/host.js +148 -33
  59. package/dist/core/runtime/launch.d.ts +15 -15
  60. package/dist/core/runtime/launch.js +58 -4
  61. package/dist/core/runtime/naming.js +3 -2
  62. package/dist/core/runtime/nodes.d.ts +3 -1
  63. package/dist/core/runtime/nodes.js +1 -0
  64. package/dist/core/runtime/pi-cli.d.ts +2 -0
  65. package/dist/core/runtime/pi-cli.js +51 -0
  66. package/dist/core/runtime/placement.d.ts +1 -1
  67. package/dist/core/runtime/placement.js +5 -2
  68. package/dist/core/runtime/recap.js +2 -1
  69. package/dist/core/runtime/revive.d.ts +21 -15
  70. package/dist/core/runtime/revive.js +87 -52
  71. package/dist/core/runtime/session-cycles.d.ts +30 -0
  72. package/dist/core/runtime/session-cycles.js +77 -0
  73. package/dist/core/runtime/spawn.d.ts +4 -0
  74. package/dist/core/runtime/spawn.js +152 -100
  75. package/dist/core/runtime/tmux.d.ts +7 -10
  76. package/dist/core/runtime/tmux.js +9 -11
  77. package/dist/core/worktree.d.ts +35 -0
  78. package/dist/core/worktree.js +158 -0
  79. package/dist/daemon/crtrd.d.ts +17 -8
  80. package/dist/daemon/crtrd.js +191 -40
  81. package/dist/index.d.ts +3 -0
  82. package/dist/index.js +7 -0
  83. package/dist/pi-extensions/canvas-context-intro.d.ts +1 -0
  84. package/dist/pi-extensions/canvas-context-intro.js +34 -23
  85. package/dist/pi-extensions/canvas-stophook.js +11 -5
  86. package/dist/web-client/assets/{index-CbO8L0mN.js → index-B00YpRQ1.js} +20 -20
  87. package/dist/web-client/assets/index-DrkcvANq.css +2 -0
  88. package/dist/web-client/index.html +2 -2
  89. package/docs/compat/hearth-crtr-v1.md +191 -0
  90. package/docs/public-api.md +75 -0
  91. package/package.json +3 -4
  92. package/dist/core/__tests__/hearth-bootstrap.test.js +0 -136
  93. package/dist/core/hearth/__tests__/model-auth-guest.test.js +0 -151
  94. package/dist/core/hearth/config.d.ts +0 -3
  95. package/dist/core/hearth/config.js +0 -108
  96. package/dist/core/hearth/guest-env.d.ts +0 -9
  97. package/dist/core/hearth/guest-env.js +0 -27
  98. package/dist/core/hearth/index.d.ts +0 -4
  99. package/dist/core/hearth/index.js +0 -4
  100. package/dist/core/hearth/model-auth-guest.d.ts +0 -8
  101. package/dist/core/hearth/model-auth-guest.js +0 -430
  102. package/dist/core/hearth/provider.d.ts +0 -36
  103. package/dist/core/hearth/provider.js +0 -10
  104. package/dist/core/hearth/providers/__tests__/sweep-and-release.test.js +0 -254
  105. package/dist/core/hearth/providers/blaxel-bootstrap.d.ts +0 -12
  106. package/dist/core/hearth/providers/blaxel-bootstrap.js +0 -147
  107. package/dist/core/hearth/providers/blaxel-home.d.ts +0 -54
  108. package/dist/core/hearth/providers/blaxel-home.js +0 -386
  109. package/dist/core/hearth/providers/blaxel.d.ts +0 -36
  110. package/dist/core/hearth/providers/blaxel.js +0 -364
  111. package/dist/core/hearth/providers/types.d.ts +0 -93
  112. package/dist/core/hearth/types.d.ts +0 -155
  113. package/dist/hearth/control-plane/__tests__/error-serialization.test.js +0 -29
  114. package/dist/hearth/control-plane/__tests__/node-message.test.js +0 -60
  115. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.d.ts +0 -1
  116. package/dist/hearth/control-plane/__tests__/oauth-serving-marker.test.js +0 -44
  117. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.d.ts +0 -1
  118. package/dist/hearth/control-plane/__tests__/rate-limit-recurrence.test.js +0 -49
  119. package/dist/hearth/control-plane/__tests__/relay-security.test.d.ts +0 -1
  120. package/dist/hearth/control-plane/__tests__/relay-security.test.js +0 -314
  121. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.d.ts +0 -1
  122. package/dist/hearth/control-plane/__tests__/scheduler-scan-loop.test.js +0 -133
  123. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.d.ts +0 -1
  124. package/dist/hearth/control-plane/__tests__/trigger-delivery.test.js +0 -170
  125. package/dist/hearth/control-plane/__tests__/wake-roll.test.d.ts +0 -1
  126. package/dist/hearth/control-plane/__tests__/wake-roll.test.js +0 -230
  127. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.d.ts +0 -1
  128. package/dist/hearth/control-plane/__tests__/webhook-ingress.test.js +0 -167
  129. package/dist/hearth/control-plane/config.d.ts +0 -21
  130. package/dist/hearth/control-plane/config.js +0 -77
  131. package/dist/hearth/control-plane/db.d.ts +0 -30
  132. package/dist/hearth/control-plane/db.js +0 -561
  133. package/dist/hearth/control-plane/hearth-target.d.ts +0 -23
  134. package/dist/hearth/control-plane/hearth-target.js +0 -68
  135. package/dist/hearth/control-plane/ingress/rate-limit.d.ts +0 -24
  136. package/dist/hearth/control-plane/ingress/rate-limit.js +0 -100
  137. package/dist/hearth/control-plane/ingress/route-store.d.ts +0 -31
  138. package/dist/hearth/control-plane/ingress/route-store.js +0 -61
  139. package/dist/hearth/control-plane/ingress/webhook-delivery-store.d.ts +0 -41
  140. package/dist/hearth/control-plane/ingress/webhook-delivery-store.js +0 -69
  141. package/dist/hearth/control-plane/ingress/webhook-route.d.ts +0 -55
  142. package/dist/hearth/control-plane/ingress/webhook-route.js +0 -285
  143. package/dist/hearth/control-plane/main.d.ts +0 -1
  144. package/dist/hearth/control-plane/main.js +0 -88
  145. package/dist/hearth/control-plane/node-message.d.ts +0 -31
  146. package/dist/hearth/control-plane/node-message.js +0 -98
  147. package/dist/hearth/control-plane/register.d.ts +0 -15
  148. package/dist/hearth/control-plane/register.js +0 -34
  149. package/dist/hearth/control-plane/registry.d.ts +0 -22
  150. package/dist/hearth/control-plane/registry.js +0 -168
  151. package/dist/hearth/control-plane/relay.d.ts +0 -44
  152. package/dist/hearth/control-plane/relay.js +0 -711
  153. package/dist/hearth/control-plane/scheduler/fire-store.d.ts +0 -36
  154. package/dist/hearth/control-plane/scheduler/fire-store.js +0 -73
  155. package/dist/hearth/control-plane/scheduler/recurrence.d.ts +0 -7
  156. package/dist/hearth/control-plane/scheduler/recurrence.js +0 -58
  157. package/dist/hearth/control-plane/scheduler/scan-loop.d.ts +0 -38
  158. package/dist/hearth/control-plane/scheduler/scan-loop.js +0 -138
  159. package/dist/hearth/control-plane/scheduler/schedule-store.d.ts +0 -32
  160. package/dist/hearth/control-plane/scheduler/schedule-store.js +0 -66
  161. package/dist/hearth/control-plane/secrets.d.ts +0 -31
  162. package/dist/hearth/control-plane/secrets.js +0 -134
  163. package/dist/hearth/control-plane/server.d.ts +0 -27
  164. package/dist/hearth/control-plane/server.js +0 -482
  165. package/dist/hearth/control-plane/serving.d.ts +0 -15
  166. package/dist/hearth/control-plane/serving.js +0 -106
  167. package/dist/hearth/control-plane/session.d.ts +0 -68
  168. package/dist/hearth/control-plane/session.js +0 -273
  169. package/dist/hearth/control-plane/triggers/acl.d.ts +0 -14
  170. package/dist/hearth/control-plane/triggers/acl.js +0 -52
  171. package/dist/hearth/control-plane/triggers/audit-store.d.ts +0 -38
  172. package/dist/hearth/control-plane/triggers/audit-store.js +0 -79
  173. package/dist/hearth/control-plane/triggers/deliver.d.ts +0 -43
  174. package/dist/hearth/control-plane/triggers/deliver.js +0 -76
  175. package/dist/hearth/control-plane/triggers/envelope.d.ts +0 -29
  176. package/dist/hearth/control-plane/triggers/envelope.js +0 -38
  177. package/dist/hearth/control-plane/types.d.ts +0 -86
  178. package/dist/hearth/control-plane/types.js +0 -1
  179. package/dist/hearth/control-plane/wake.d.ts +0 -86
  180. package/dist/hearth/control-plane/wake.js +0 -550
  181. package/dist/web-client/assets/index-DwO46Cs5.css +0 -2
  182. /package/dist/{core/__tests__/hearth-bootstrap.test.d.ts → commands/__tests__/revive-now-gate.test.d.ts} +0 -0
  183. /package/dist/{core/hearth/__tests__/model-auth-guest.test.d.ts → commands/sys/__tests__/setup-core.test.d.ts} +0 -0
  184. /package/dist/core/{hearth/providers/__tests__/sweep-and-release.test.d.ts → __tests__/fault-classifier.test.d.ts} +0 -0
  185. /package/dist/core/{hearth/providers/types.js → __tests__/host-teardown-process-group.test.d.ts} +0 -0
  186. /package/dist/core/{hearth/types.js → __tests__/review-model-floor.test.d.ts} +0 -0
  187. /package/dist/{hearth/control-plane/__tests__/error-serialization.test.d.ts → core/__tests__/session-cycles.test.d.ts} +0 -0
  188. /package/dist/{hearth/control-plane/__tests__/node-message.test.d.ts → core/__tests__/worktree.test.d.ts} +0 -0
@@ -1,711 +0,0 @@
1
- // control-plane/relay.ts — the CP→home HTTP+WS relay engine (P4.3 / part-plan
2
- // T3.2). Implements the `RelayDispatcher` seam `server.ts` defines: every
3
- // call here already passed `respondDynamicLane`'s/`handleUpgrade`'s
4
- // session+registry gate (P4.1) — `home` is the registry row resolved FRESH
5
- // from the caller's own session this request, never from anything client-
6
- // supplied (R-CP-2). This module's only job is wake (P4.2's `ensureAwake`)
7
- // plus the actual byte/frame relay to `home.preview_endpoint`, generalizing
8
- // M0 `wake-proxy/proxy.ts` from a single-tenant/single-home proxy onto the
9
- // CP's per-request resolved `Home` row.
10
- //
11
- // Re-binding (R-CP-6 / criterion 5): the upstream origin is always
12
- // `home.preview_endpoint` (resolved by `ensureAwake`, never the request URL)
13
- // and the WS node target is always `home.home_agent_target` — both read off
14
- // the `home` row this function was called with. A client cannot widen or
15
- // redirect the route by supplying `?target=` or a different `/node/<id>`
16
- // path segment: this design's home model is a single resident root per home
17
- // (design.md F1 — `home_agent_target` is the only legitimate relay target),
18
- // and home selection itself is entirely session-derived upstream of this
19
- // module (server.ts resolves `home` from the session on every call, never
20
- // from the client). Re-deriving the target from the client path here would
21
- // only reintroduce the cross-home surface this design avoids; a session for
22
- // home A can therefore never reach home B's broker through this relay no
23
- // matter what `?target=`/path id it sends — home B's `preview_endpoint` is
24
- // never even looked up for that session.
25
- //
26
- // Two credentials, two legs (S4): the relay token is resolved by
27
- // `ensureAwake` (via `secrets.ts`, from `relay_auth_ref`) and injected as the
28
- // CP→home `Authorization: Bearer` header; the browser/worker leg's own
29
- // Authorization/Cookie/Proxy-* headers are stripped before the request ever
30
- // reaches the upstream fetch/WS call (R-CP-3). The browser/worker leg never
31
- // sees or reflects the relay token.
32
- import { Readable } from 'node:stream';
33
- import { WebSocket, WebSocketServer } from 'ws';
34
- import { BROKER_READ_CAPS } from '../../core/runtime/broker-protocol.js';
35
- import { general } from '../../core/errors.js';
36
- import { loadControlPlaneConfig } from './config.js';
37
- import { ensureAwake, invalidateWarmVerdict, resolveRunning } from './wake.js';
38
- import { beginHttp, beginWs, endHttp, endWs } from './serving.js';
39
- import { redactSecrets } from './secrets.js';
40
- import { tenantSessionAuthRedactedMessage } from './session.js';
41
- const hopByHopHeaders = new Set([
42
- 'connection',
43
- 'keep-alive',
44
- 'proxy-authenticate',
45
- 'proxy-authorization',
46
- 'proxy-connection',
47
- 'te',
48
- 'trailer',
49
- 'transfer-encoding',
50
- 'upgrade',
51
- ]);
52
- function redact(message) {
53
- return redactSecrets(tenantSessionAuthRedactedMessage(message));
54
- }
55
- function redactDeep(value) {
56
- if (typeof value === 'string')
57
- return redact(value);
58
- if (Array.isArray(value))
59
- return value.map((entry) => redactDeep(entry));
60
- if (value !== null && typeof value === 'object') {
61
- return Object.fromEntries(Object.entries(value).map(([key, entry]) => [key, redactDeep(entry)]));
62
- }
63
- return value;
64
- }
65
- /** Log a relay-side failure server-side only — never sent to the browser
66
- * (callers always respond with a generic status + short body). Every field
67
- * is redacted so a resolved relay token, a session cookie/bearer value, or
68
- * an `Authorization:`/`?token=` fragment captured in an upstream error
69
- * message can never reach stderr unredacted (criterion 7). */
70
- function logError(message, details = {}) {
71
- process.stderr.write(`${JSON.stringify({ level: 'error', message, details: redactDeep(details) })}\n`);
72
- }
73
- /** Serialize a non-`Error` rejection for a log line without collapsing it to `"[object Object]"`
74
- * (`String(error)` on a plain object) — recreate-race-fix.md §3.6. Falls back to `String` only
75
- * if the value itself isn't JSON-serializable (a circular structure, a BigInt, etc). */
76
- export function serializeRelayError(error) {
77
- try {
78
- // `JSON.stringify` returns `undefined` (does not throw) for `undefined`, a function, or a
79
- // symbol. Falling through to that here would hand the `message.startsWith('relay ')`/
80
- // `/timed out/i` control-flow checks right after this an `undefined` string and THROW
81
- // inside the catch block — exactly the control-flow change this rider promised not to make.
82
- const json = JSON.stringify(error);
83
- return json === undefined ? String(error) : json;
84
- }
85
- catch {
86
- return String(error);
87
- }
88
- }
89
- function requestPath(req) {
90
- return req.url ?? '/';
91
- }
92
- function decodePathForValidation(raw) {
93
- for (let i = 0; i < raw.length; i += 1) {
94
- const ch = raw[i];
95
- if (ch === '%') {
96
- const a = raw[i + 1];
97
- const b = raw[i + 2];
98
- if (a === undefined || b === undefined || !/[0-9A-Fa-f]/.test(a) || !/[0-9A-Fa-f]/.test(b)) {
99
- throw general('relay rejected malformed percent-encoding in request target', { requestUrl: '[redacted]' });
100
- }
101
- const decoded = Number.parseInt(`${a}${b}`, 16);
102
- if (decoded <= 0x1f || decoded === 0x7f || decoded === 0x5c) {
103
- throw general('relay rejected control or backslash characters in request target', { requestUrl: '[redacted]' });
104
- }
105
- i += 2;
106
- continue;
107
- }
108
- if (ch === '\\' || ch === '\u0000' || ch < ' ' || ch === '\u007f') {
109
- throw general('relay rejected control characters in request target', { requestUrl: '[redacted]' });
110
- }
111
- }
112
- }
113
- /** Origin-form-only request target validation (criterion 4): rejects
114
- * scheme-relative (`//evil`) and any target that doesn't start with `/`,
115
- * and rejects control/backslash characters (raw or percent-encoded) before
116
- * the target is ever joined onto the upstream origin. */
117
- function validateOriginFormRequestTarget(rawRequestUrl) {
118
- const raw = rawRequestUrl === undefined || rawRequestUrl === '' ? '/' : rawRequestUrl;
119
- if (!raw.startsWith('/')) {
120
- throw general('relay only accepts origin-form request targets', { requestUrl: raw.replace(/\?.*$/, '') });
121
- }
122
- if (raw.startsWith('//')) {
123
- throw general('relay rejected scheme-relative request target', { requestUrl: raw.replace(/\?.*$/, '') });
124
- }
125
- decodePathForValidation(raw);
126
- return raw;
127
- }
128
- /** Join the validated origin-form request target onto `previewEndpoint` and
129
- * reject anything that would escape that origin (criterion 4: "reject
130
- * scheme-relative/absolute external escapes"). */
131
- export function buildUpstreamHttpUrl(previewEndpoint, rawRequestUrl) {
132
- const route = new URL(previewEndpoint);
133
- const raw = validateOriginFormRequestTarget(rawRequestUrl);
134
- const upstream = new URL(raw, route);
135
- if (upstream.origin !== route.origin) {
136
- throw general('relay rejected request target that escapes the upstream origin', { requestUrl: raw.replace(/\?.*$/, '') });
137
- }
138
- upstream.searchParams.delete('token');
139
- return upstream;
140
- }
141
- /** Strip every browser-leg auth header (Authorization/Cookie/Proxy-*) and
142
- * every client-supplied forwarding/host-metadata header (Forwarded,
143
- * X-Forwarded-*, X-Real-IP) before forwarding upstream — the relay is a
144
- * single hop behind the CP, so the home should see no forwarding metadata
145
- * at all rather than an attacker-controlled one — then inject the CP→home
146
- * relay token as the sole Authorization header (criterion 2). */
147
- function filteredRequestHeaders(req, relayToken) {
148
- const headers = new Headers();
149
- for (const [name, value] of Object.entries(req.headers)) {
150
- if (value === undefined)
151
- continue;
152
- const lower = name.toLowerCase();
153
- if (lower === 'host' ||
154
- lower === 'authorization' ||
155
- lower === 'cookie' ||
156
- lower === 'referer' ||
157
- lower === 'referrer' ||
158
- lower === 'forwarded' ||
159
- lower === 'x-real-ip' ||
160
- lower.startsWith('proxy-') ||
161
- lower.startsWith('sec-websocket-') ||
162
- lower.startsWith('x-forwarded-') ||
163
- hopByHopHeaders.has(lower)) {
164
- continue;
165
- }
166
- headers.set(lower, Array.isArray(value) ? value.join(',') : value);
167
- }
168
- headers.set('authorization', `Bearer ${relayToken}`);
169
- headers.set('accept-encoding', 'identity');
170
- return headers;
171
- }
172
- /** Rewrite an upstream redirect to stay same-origin on the CP (criterion 3).
173
- * A relative Location is forwarded verbatim (already same-origin to
174
- * whatever origin the browser/worker considers itself on). An absolute
175
- * Location back to the upstream route origin is rewritten to a CP-relative
176
- * path so the browser never learns/visits the upstream origin directly; any
177
- * other absolute origin is dropped. */
178
- function sanitizeLocation(location, upstreamOrigin) {
179
- if (location.startsWith('/') && !location.startsWith('//') && !location.includes('\\'))
180
- return location;
181
- try {
182
- const abs = new URL(location, upstreamOrigin);
183
- if (abs.origin === upstreamOrigin)
184
- return `${abs.pathname}${abs.search}${abs.hash}`;
185
- }
186
- catch {
187
- /* malformed Location — drop it */
188
- }
189
- return null;
190
- }
191
- function applyResponseHeaders(res, headers, upstreamOrigin) {
192
- headers.forEach((value, key) => {
193
- const lower = key.toLowerCase();
194
- if (lower === 'set-cookie' || lower === 'authorization' || hopByHopHeaders.has(lower) || lower.startsWith('proxy-'))
195
- return;
196
- if (lower === 'location') {
197
- const sanitized = sanitizeLocation(value, upstreamOrigin);
198
- if (sanitized !== null)
199
- res.setHeader('location', sanitized);
200
- return;
201
- }
202
- res.setHeader(lower, value);
203
- });
204
- const contentType = headers.get('content-type');
205
- if (contentType !== null && !res.hasHeader('content-type'))
206
- res.setHeader('content-type', contentType);
207
- }
208
- function tuneStreamingHeaders(res, isEventStream) {
209
- if (!isEventStream)
210
- return;
211
- const cacheControl = res.getHeader('cache-control');
212
- if (typeof cacheControl === 'string') {
213
- if (!/no-transform/i.test(cacheControl))
214
- res.setHeader('cache-control', `${cacheControl}, no-transform`);
215
- }
216
- else if (cacheControl === undefined) {
217
- res.setHeader('cache-control', 'no-cache, no-transform');
218
- }
219
- res.setHeader('connection', 'keep-alive');
220
- res.setHeader('x-accel-buffering', 'no');
221
- res.socket?.setNoDelay(true);
222
- }
223
- async function waitForDrain(res, signal) {
224
- await new Promise((resolve, reject) => {
225
- const cleanup = () => {
226
- res.off('drain', onDrain);
227
- res.off('close', onClose);
228
- res.off('error', onError);
229
- signal.removeEventListener('abort', onAbort);
230
- };
231
- const onDrain = () => {
232
- cleanup();
233
- resolve();
234
- };
235
- const onClose = () => {
236
- cleanup();
237
- reject(new Error('response closed while streaming'));
238
- };
239
- const onError = (error) => {
240
- cleanup();
241
- reject(error instanceof Error ? error : new Error(String(error)));
242
- };
243
- const onAbort = () => {
244
- cleanup();
245
- reject(new Error('response aborted while streaming'));
246
- };
247
- res.once('drain', onDrain);
248
- res.once('close', onClose);
249
- res.once('error', onError);
250
- signal.addEventListener('abort', onAbort, { once: true });
251
- });
252
- }
253
- async function streamResponseBody(body, res, signal) {
254
- const reader = body.getReader();
255
- try {
256
- while (true) {
257
- const { value, done } = await reader.read();
258
- if (done)
259
- return;
260
- if (!res.write(Buffer.from(value))) {
261
- await waitForDrain(res, signal);
262
- }
263
- }
264
- }
265
- finally {
266
- reader.releaseLock();
267
- }
268
- }
269
- async function withTimeout(work, timeoutMs, label) {
270
- if (timeoutMs <= 0)
271
- return work;
272
- let timer = null;
273
- try {
274
- return await Promise.race([
275
- work,
276
- new Promise((_, reject) => {
277
- timer = setTimeout(() => reject(general(`${label} timed out`, { timeoutMs })), timeoutMs);
278
- timer.unref();
279
- }),
280
- ]);
281
- }
282
- finally {
283
- if (timer !== null)
284
- clearTimeout(timer);
285
- }
286
- }
287
- function closeHttpWithError(res, statusCode, message) {
288
- if (!res.headersSent) {
289
- res.writeHead(statusCode, { 'content-type': 'text/plain; charset=utf-8', 'cache-control': 'no-store' });
290
- }
291
- res.end(`${message}\n`);
292
- }
293
- function addRequestAbort(req, res, controller) {
294
- const abort = () => controller.abort();
295
- req.once('aborted', abort);
296
- res.once('close', abort);
297
- return () => {
298
- req.off('aborted', abort);
299
- res.off('close', abort);
300
- };
301
- }
302
- /**
303
- * Forward one HTTP request to the already-awakened home and stream the
304
- * response back. Returns normally once the response is fully relayed;
305
- * throws on a genuine upstream/network failure (connect refused/reset, DNS,
306
- * body-stream error) — an app-level 4xx/5xx is a RESOLVED fetch and is
307
- * relayed verbatim, never thrown here.
308
- */
309
- async function forwardUpstream(req, res, awakened, controller) {
310
- const upstreamUrl = buildUpstreamHttpUrl(awakened.preview_endpoint, requestPath(req));
311
- const method = req.method ?? 'GET';
312
- const canHaveBody = method !== 'GET' && method !== 'HEAD';
313
- const requestInit = {
314
- method,
315
- headers: filteredRequestHeaders(req, awakened.relayToken),
316
- body: canHaveBody ? Readable.toWeb(req) : undefined,
317
- signal: controller.signal,
318
- redirect: 'manual',
319
- };
320
- if (canHaveBody)
321
- requestInit.duplex = 'half';
322
- const response = await fetch(upstreamUrl, requestInit);
323
- const responseHeaders = new Headers(response.headers);
324
- const isEventStream = /text\/event-stream/i.test(responseHeaders.get('content-type') ?? '');
325
- if (isEventStream)
326
- responseHeaders.delete('content-length');
327
- res.statusCode = response.status;
328
- res.statusMessage = response.statusText;
329
- applyResponseHeaders(res, responseHeaders, new URL(awakened.preview_endpoint).origin);
330
- tuneStreamingHeaders(res, isEventStream);
331
- if (response.body === null || method === 'HEAD') {
332
- res.end();
333
- return;
334
- }
335
- res.flushHeaders?.();
336
- await streamResponseBody(response.body, res, controller.signal);
337
- if (!res.writableEnded)
338
- res.end();
339
- }
340
- /** Forward one HTTP request to `home` (criterion 1: only after the caller's
341
- * session/registry gate already resolved `home`, and only after
342
- * `ensureAwake(home.home_id)` succeeds). Mirrors M0 `proxy.ts`'s
343
- * fast-path-then-escalate shape: a fresh warm-verdict cache hit
344
- * (`resolveRunning`, P4.2) is forwarded directly without re-entering the
345
- * wake pipeline; only a missing/stale verdict, or a running route that just
346
- * failed on a safely-replayable method, escalates to a bounded
347
- * `ensureAwake`. */
348
- export async function proxyHttp(req, res, home, deps = {}) {
349
- const ensureAwakeImpl = deps.ensureAwake ?? ensureAwake;
350
- const resolveRunningImpl = deps.resolveRunning ?? resolveRunning;
351
- const invalidate = deps.invalidateWarmVerdict ?? invalidateWarmVerdict;
352
- const config = (deps.loadConfig ?? loadControlPlaneConfig)();
353
- const controller = new AbortController();
354
- const cleanup = addRequestAbort(req, res, controller);
355
- try {
356
- const running = resolveRunningImpl(home.home_id);
357
- if (running !== null) {
358
- try {
359
- // Mark this home as actively serving ONLY around the actual data
360
- // transfer, never around the wake DECISION above it. Bumping the
361
- // counter any earlier (e.g. unconditionally on function
362
- // entry, before `ensureAwake` runs below) would make a home's very
363
- // FIRST request after being suspended see ITS OWN just-begun counter
364
- // and read as "serving" — `isHomeStale && !homeIsServing` would then
365
- // never be true for any home, ever, silently disabling every roll
366
- // (verified live: `beginHttp` then `isHomeServing` in the same tick
367
- // returns true with zero other activity). Scoping begin/end tightly
368
- // around each `forwardUpstream` call still protects the real target
369
- // (a genuinely separate, already-open session on this home) because
370
- // that session's OWN counter is independent and already non-zero.
371
- beginHttp(home.home_id);
372
- try {
373
- await forwardUpstream(req, res, running, controller);
374
- }
375
- finally {
376
- endHttp(home.home_id);
377
- }
378
- return;
379
- }
380
- catch (error) {
381
- const method = req.method ?? 'GET';
382
- const replayable = method === 'GET' || method === 'HEAD';
383
- if (res.headersSent || controller.signal.aborted || !replayable)
384
- throw error;
385
- logError('hearth control plane relay running-route forward failed; escalating to deliberate wake', {
386
- home_id: home.home_id,
387
- path: requestPath(req).replace(/\?.*$/, ''),
388
- error: error instanceof Error ? error.message : String(error),
389
- });
390
- invalidate(home.home_id);
391
- }
392
- }
393
- const awakened = await withTimeout(ensureAwakeImpl(home.home_id), config.wakeTimeoutMs, 'control plane relay wake');
394
- if (controller.signal.aborted)
395
- throw general('request closed while waking');
396
- beginHttp(home.home_id);
397
- try {
398
- await forwardUpstream(req, res, awakened, controller);
399
- }
400
- finally {
401
- endHttp(home.home_id);
402
- }
403
- }
404
- catch (error) {
405
- // A non-`Error` rejection (e.g. a raw object thrown from outside the SDK's recognized error
406
- // shapes) must not serialize to "[object Object]" here — that loss of the real error is what
407
- // cost the recreate-race incident its diagnosis (recreate-race-fix.md §3.6). `message` below
408
- // still drives the control-flow checks that follow (relay-prefixed/timeout messages), which
409
- // only ever come from `Error` instances (`general()`), so this branch change cannot affect them.
410
- const message = error instanceof Error ? error.message : serializeRelayError(error);
411
- logError('hearth control plane relay HTTP request failed', {
412
- home_id: home.home_id,
413
- path: requestPath(req).replace(/\?.*$/, ''),
414
- error: message,
415
- });
416
- if (!message.startsWith('relay '))
417
- invalidate(home.home_id);
418
- if (/timed out/i.test(message)) {
419
- closeHttpWithError(res, 504, 'wake timeout');
420
- return;
421
- }
422
- if (!res.headersSent) {
423
- closeHttpWithError(res, message.startsWith('relay ') ? 400 : 502, message.startsWith('relay ') ? 'bad request' : 'bad gateway');
424
- return;
425
- }
426
- try {
427
- res.destroy();
428
- }
429
- catch {
430
- /* ignore */
431
- }
432
- }
433
- finally {
434
- cleanup();
435
- }
436
- }
437
- // --- WebSocket relay --------------------------------------------------
438
- /** Build the upstream `wss://`/`ws://` URL for the home's single resident-
439
- * root target. `target` is always `home.home_agent_target` (server-derived,
440
- * never the client's request path — see module header). */
441
- function toWssUrl(previewEndpoint, target) {
442
- const url = new URL(previewEndpoint);
443
- url.protocol = url.protocol === 'https:' ? 'wss:' : 'ws:';
444
- url.pathname = `/node/${encodeURIComponent(target)}`;
445
- url.search = '';
446
- url.hash = '';
447
- return url.toString();
448
- }
449
- function sendableCloseCode(code) {
450
- if (!Number.isInteger(code) || code === 1005 || code === 1006 || code < 1000 || code > 4999)
451
- return 1000;
452
- return code;
453
- }
454
- function sendSocketClose(socket, code, reason) {
455
- try {
456
- socket.close(sendableCloseCode(code), reason.slice(0, 123));
457
- }
458
- catch {
459
- try {
460
- socket.terminate();
461
- }
462
- catch {
463
- /* ignore */
464
- }
465
- }
466
- }
467
- const wsKeepaliveIntervalMs = 25_000;
468
- /** 25s ping/pong keepalive (M0 shape, kept verbatim — criterion 4). A socket
469
- * that never pongs back is terminated rather than left to leak. */
470
- function attachWsKeepalive(socket, context, options = {}) {
471
- const intervalMs = options.intervalMs ?? wsKeepaliveIntervalMs;
472
- let alive = true;
473
- let stopped = false;
474
- let timer = null;
475
- const stop = () => {
476
- if (stopped)
477
- return;
478
- stopped = true;
479
- if (timer !== null)
480
- clearInterval(timer);
481
- socket.off('pong', onPong);
482
- };
483
- const onPong = () => {
484
- alive = true;
485
- };
486
- const terminateStaleSocket = (message) => {
487
- logError(message, { ...context, readyState: socket.readyState });
488
- options.onStale?.();
489
- try {
490
- socket.terminate();
491
- }
492
- catch {
493
- /* ignore */
494
- }
495
- stop();
496
- };
497
- const pingOrTerminate = () => {
498
- if (stopped || socket.readyState !== WebSocket.OPEN)
499
- return;
500
- if (!alive) {
501
- terminateStaleSocket('hearth control plane relay ws keepalive stale');
502
- return;
503
- }
504
- alive = false;
505
- try {
506
- socket.ping();
507
- }
508
- catch {
509
- terminateStaleSocket('hearth control plane relay ws keepalive ping failed');
510
- }
511
- };
512
- socket.on('pong', onPong);
513
- timer = setInterval(pingOrTerminate, intervalMs);
514
- timer.unref?.();
515
- return stop;
516
- }
517
- function createUpgradeServer() {
518
- return new WebSocketServer({ noServer: true, maxPayload: BROKER_READ_CAPS.maxLineBytes });
519
- }
520
- /** Bridge one already-upgraded browser WebSocket to the home's resident-root
521
- * broker over `wss://<preview_endpoint>/node/<home_agent_target>`, carrying
522
- * the relay token as the upstream Authorization header (never reflected
523
- * back to the browser leg). Keepalive/teardown/close-code handling mirrors
524
- * M0 `proxy.ts`'s `proxyWebSocketBrowser` (criterion 4). */
525
- async function proxyWebSocketBrowser(browserWs, home, deps = {}) {
526
- const ensureAwakeImpl = deps.ensureAwake ?? ensureAwake;
527
- const config = (deps.loadConfig ?? loadControlPlaneConfig)();
528
- const createUpstream = deps.createUpstream ??
529
- ((url, relayToken) => new WebSocket(url, {
530
- headers: { authorization: `Bearer ${relayToken}` },
531
- perMessageDeflate: false,
532
- handshakeTimeout: config.readyTimeoutMs,
533
- maxPayload: BROKER_READ_CAPS.maxLineBytes,
534
- }));
535
- const handshakeAt = Date.now();
536
- let upstream = null;
537
- let teardownStarted = false;
538
- // Whether THIS bridge has actually incremented `wsOpen` (set true only
539
- // once the upstream leg is being established — see below —
540
- // and guards the `close`-handler `endWs` call so a bridge whose own
541
- // `ensureAwake` failed before ever counting itself can't decrement a
542
- // DIFFERENT, still-live session's counter for the same home).
543
- let wsCounted = false;
544
- let upstreamOpenAt = null;
545
- let browserClose = null;
546
- let upstreamClose = null;
547
- const queued = [];
548
- let stopBrowserKeepalive = () => { };
549
- let stopUpstreamKeepalive = () => { };
550
- const stopKeepalives = () => {
551
- stopBrowserKeepalive();
552
- stopUpstreamKeepalive();
553
- };
554
- const beginTeardown = () => {
555
- if (teardownStarted)
556
- return;
557
- teardownStarted = true;
558
- stopKeepalives();
559
- };
560
- stopBrowserKeepalive = attachWsKeepalive(browserWs, { home_id: home.home_id, leg: 'browser' }, { onStale: beginTeardown });
561
- const logClose = (leg, code, reason) => {
562
- logError('hearth control plane relay ws close', {
563
- home_id: home.home_id,
564
- leg,
565
- code,
566
- reason,
567
- handshakeToUpstreamOpenMs: upstreamOpenAt === null ? null : upstreamOpenAt - handshakeAt,
568
- browserCloseCode: browserClose?.code ?? null,
569
- browserCloseReason: browserClose?.reason ?? null,
570
- upstreamCloseCode: upstreamClose?.code ?? null,
571
- upstreamCloseReason: upstreamClose?.reason ?? null,
572
- browserReadyState: browserWs.readyState,
573
- upstreamReadyState: upstream?.readyState ?? 'none',
574
- teardownStarted,
575
- });
576
- };
577
- const closeBoth = (code, reason) => {
578
- beginTeardown();
579
- if (browserWs.readyState === WebSocket.OPEN || browserWs.readyState === WebSocket.CONNECTING) {
580
- sendSocketClose(browserWs, code, reason);
581
- }
582
- if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
583
- sendSocketClose(upstream, code, reason);
584
- }
585
- };
586
- browserWs.on('message', (data, isBinary) => {
587
- if (upstream === null || upstream.readyState !== WebSocket.OPEN) {
588
- queued.push({ data, isBinary });
589
- return;
590
- }
591
- try {
592
- upstream.send(data, { binary: isBinary });
593
- }
594
- catch {
595
- closeBoth(1011, 'failed to relay browser frame');
596
- }
597
- });
598
- browserWs.on('close', (code, reason) => {
599
- const closeReason = reason.toString('utf8').slice(0, 123);
600
- browserClose = { code, reason: closeReason };
601
- logClose('browser', code, closeReason);
602
- beginTeardown();
603
- if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
604
- sendSocketClose(upstream, code, closeReason);
605
- }
606
- if (wsCounted) {
607
- wsCounted = false;
608
- endWs(home.home_id);
609
- }
610
- });
611
- browserWs.on('error', (error) => {
612
- logError('hearth control plane relay browser websocket failed', { home_id: home.home_id, error: error instanceof Error ? error.message : String(error) });
613
- closeBoth(1011, 'browser websocket error');
614
- });
615
- try {
616
- const awakened = await withTimeout(ensureAwakeImpl(home.home_id), config.wakeTimeoutMs, 'control plane relay websocket wake');
617
- const target = home.home_agent_target?.trim() ?? '';
618
- if (target === '')
619
- throw general(`home ${home.home_id} is missing home_agent_target — cannot relay`, { home_id: home.home_id });
620
- if (browserWs.readyState !== WebSocket.OPEN) {
621
- beginTeardown();
622
- return;
623
- }
624
- // Mark this home as actively serving ONLY once the wake DECISION above
625
- // has already resolved — not on function entry.
626
- // `ensureAwake` (inside the wait above) makes the roll-vs-normal-wake
627
- // call by consulting this very tracker; counting a bridge as "open"
628
- // before that decision runs would make a home's very first WS reconnect
629
- // after being suspended see ITS OWN not-yet-real session and read as
630
- // "serving," permanently blocking the roll this connection was itself
631
- // supposed to trigger (mirrors the identical HTTP hazard in `proxyHttp`
632
- // above — verified live for the HTTP case; same mechanism applies here).
633
- // A genuinely separate, already-open bridge for this home is unaffected:
634
- // its own counter is already live and independent of this one.
635
- beginWs(home.home_id);
636
- wsCounted = true;
637
- upstream = createUpstream(toWssUrl(awakened.preview_endpoint, target), awakened.relayToken);
638
- upstream.on('open', () => {
639
- if (teardownStarted || browserWs.readyState !== WebSocket.OPEN) {
640
- beginTeardown();
641
- if (upstream !== null && (upstream.readyState === WebSocket.OPEN || upstream.readyState === WebSocket.CONNECTING)) {
642
- sendSocketClose(upstream, 1000, 'browser websocket closed before upstream open');
643
- }
644
- return;
645
- }
646
- upstreamOpenAt = Date.now();
647
- stopUpstreamKeepalive = attachWsKeepalive(upstream, { home_id: home.home_id, leg: 'upstream' }, { onStale: beginTeardown });
648
- for (const frame of queued.splice(0, queued.length)) {
649
- try {
650
- upstream.send(frame.data, { binary: frame.isBinary });
651
- }
652
- catch {
653
- closeBoth(1011, 'failed to flush buffered browser frame');
654
- return;
655
- }
656
- }
657
- });
658
- upstream.on('message', (data, isBinary) => {
659
- if (browserWs.readyState !== WebSocket.OPEN)
660
- return;
661
- browserWs.send(data, { binary: isBinary });
662
- });
663
- upstream.on('close', (code, reason) => {
664
- const closeReason = reason.toString('utf8').slice(0, 123);
665
- upstreamClose = { code, reason: closeReason };
666
- logClose('upstream', code, closeReason);
667
- beginTeardown();
668
- if (browserWs.readyState === WebSocket.OPEN || browserWs.readyState === WebSocket.CONNECTING) {
669
- sendSocketClose(browserWs, code, closeReason);
670
- }
671
- });
672
- upstream.on('error', (error) => {
673
- logError('hearth control plane relay ws upstream failed', { home_id: home.home_id, error: error instanceof Error ? error.message : String(error) });
674
- (deps.invalidateWarmVerdict ?? invalidateWarmVerdict)(home.home_id);
675
- closeBoth(1011, 'upstream websocket error');
676
- });
677
- }
678
- catch (error) {
679
- const message = error instanceof Error ? error.message : String(error);
680
- logError('hearth control plane relay websocket wake failed', { home_id: home.home_id, error: message });
681
- (deps.invalidateWarmVerdict ?? invalidateWarmVerdict)(home.home_id);
682
- closeBoth(/timed out/i.test(message) ? 1013 : 1011, /timed out/i.test(message) ? 'wake timeout' : 'wake failed');
683
- }
684
- }
685
- /** `RelayDispatcher.proxyUpgrade`: complete the WS handshake on the already
686
- * session+registry-gated socket (`server.ts`'s `handleUpgrade`), then bridge
687
- * it to the home. Errors after this point close the browser socket in-band
688
- * with a WS close code (1011/1013) rather than a raw TCP reset, so the
689
- * client sees a clean failure (criterion 4: "keep ... close behavior from
690
- * M0 where applicable"). */
691
- export function proxyUpgrade(req, socket, head, home, deps = {}) {
692
- const wss = createUpgradeServer();
693
- wss.handleUpgrade(req, socket, head, (browserWs) => {
694
- void proxyWebSocketBrowser(browserWs, home, deps).catch((error) => {
695
- logError('hearth control plane relay websocket bridge failed', { home_id: home.home_id, error: error instanceof Error ? error.message : String(error) });
696
- try {
697
- browserWs.terminate();
698
- }
699
- catch {
700
- /* ignore */
701
- }
702
- });
703
- });
704
- }
705
- /** The real `RelayDispatcher` `main.ts` wires in once the CP server is built
706
- * — `server.ts`'s default (`notImplementedRelay`) is only ever used when a
707
- * caller (test/smoke) doesn't inject one. */
708
- export const controlPlaneRelay = {
709
- proxyHttp,
710
- proxyUpgrade,
711
- };