@intentic/sandbox-contract 1.311.0 → 1.312.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -4
- package/dist/chores/chores.js +6 -6
- package/dist/chores/chores.js.map +1 -1
- package/dist/chores/fix-deps.d.ts.map +1 -1
- package/dist/chores/fix-deps.js +3 -2
- package/dist/chores/fix-deps.js.map +1 -1
- package/dist/chores/prompt.d.ts.map +1 -1
- package/dist/chores/prompt.js +3 -2
- package/dist/chores/prompt.js.map +1 -1
- package/dist/contracts/accounts.contract.d.ts +12 -0
- package/dist/contracts/accounts.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +601 -0
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +30 -2
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/device.contract.d.ts +1 -0
- package/dist/contracts/device.contract.d.ts.map +1 -1
- package/dist/contracts/extensions.contract.d.ts +2 -0
- package/dist/contracts/extensions.contract.d.ts.map +1 -1
- package/dist/contracts/offload.contract.d.ts +107 -0
- package/dist/contracts/offload.contract.d.ts.map +1 -0
- package/dist/contracts/offload.contract.js +53 -0
- package/dist/contracts/offload.contract.js.map +1 -0
- package/dist/contracts/ports.contract.d.ts +5 -0
- package/dist/contracts/ports.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.d.ts +66 -0
- package/dist/contracts/runner.contract.d.ts.map +1 -1
- package/dist/contracts/runner.contract.js +3 -1
- package/dist/contracts/runner.contract.js.map +1 -1
- package/dist/contracts/settings.contract.d.ts +8 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +95 -0
- package/dist/contracts/system.contract.d.ts.map +1 -1
- package/dist/contracts/translator.contract.d.ts +16 -0
- package/dist/contracts/translator.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.d.ts +146 -0
- package/dist/contracts/workspace.contract.d.ts.map +1 -1
- package/dist/contracts/workspace.contract.js +40 -3
- package/dist/contracts/workspace.contract.js.map +1 -1
- package/dist/documents/conversions.d.ts +81 -0
- package/dist/documents/conversions.d.ts.map +1 -0
- package/dist/documents/conversions.js +213 -0
- package/dist/documents/conversions.js.map +1 -0
- package/dist/documents/index.d.ts +3 -0
- package/dist/documents/index.d.ts.map +1 -0
- package/dist/documents/index.js +3 -0
- package/dist/documents/index.js.map +1 -0
- package/dist/documents/passthrough.d.ts +13 -0
- package/dist/documents/passthrough.d.ts.map +1 -0
- package/dist/documents/passthrough.js +101 -0
- package/dist/documents/passthrough.js.map +1 -0
- package/dist/events/land-breakage.d.ts +3 -0
- package/dist/events/land-breakage.d.ts.map +1 -1
- package/dist/events/land-breakage.js +3 -0
- package/dist/events/land-breakage.js.map +1 -1
- package/dist/events/land-conflict.d.ts +3 -0
- package/dist/events/land-conflict.d.ts.map +1 -0
- package/dist/events/land-conflict.js +4 -0
- package/dist/events/land-conflict.js.map +1 -0
- package/dist/events/system-events.d.ts +66 -0
- package/dist/events/system-events.d.ts.map +1 -1
- package/dist/events/system-events.js +3 -1
- package/dist/events/system-events.js.map +1 -1
- package/dist/events/verify-nudge.d.ts.map +1 -1
- package/dist/events/verify-nudge.js.map +1 -1
- package/dist/front/generated/wire.d.ts +63 -0
- package/dist/front/generated/wire.d.ts.map +1 -1
- package/dist/front/terminal-frames.d.ts +17 -0
- package/dist/front/terminal-frames.d.ts.map +1 -0
- package/dist/front/terminal-frames.js +79 -0
- package/dist/front/terminal-frames.js.map +1 -0
- package/dist/ids/conversation-ids.d.ts +2 -0
- package/dist/ids/conversation-ids.d.ts.map +1 -1
- package/dist/ids/conversation-ids.js +2 -0
- package/dist/ids/conversation-ids.js.map +1 -1
- package/dist/index.d.ts +1002 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/protocol/container-requirements.d.ts +0 -1
- package/dist/protocol/container-requirements.d.ts.map +1 -1
- package/dist/protocol/container-requirements.js +1 -2
- package/dist/protocol/container-requirements.js.map +1 -1
- package/dist/protocol/ingress-contract.d.ts +0 -11
- package/dist/protocol/ingress-contract.d.ts.map +1 -1
- package/dist/protocol/raw-routes.d.ts +36 -4
- package/dist/protocol/raw-routes.d.ts.map +1 -1
- package/dist/protocol/raw-routes.js +18 -13
- package/dist/protocol/raw-routes.js.map +1 -1
- package/dist/protocol/route-meta.d.ts +2 -0
- package/dist/protocol/route-meta.d.ts.map +1 -1
- package/dist/protocol/route-meta.js.map +1 -1
- package/dist/protocol/runner-protocol.d.ts +34 -0
- package/dist/protocol/runner-protocol.d.ts.map +1 -1
- package/dist/protocol/runner-protocol.js +33 -0
- package/dist/protocol/runner-protocol.js.map +1 -1
- package/dist/protocol/tunnel-lanes.d.ts +10 -0
- package/dist/protocol/tunnel-lanes.d.ts.map +1 -0
- package/dist/protocol/tunnel-lanes.js +16 -0
- package/dist/protocol/tunnel-lanes.js.map +1 -0
- package/dist/schemas/activity.d.ts.map +1 -1
- package/dist/schemas/activity.js.map +1 -1
- package/dist/schemas/agents.d.ts +73 -0
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +33 -2
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +19 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/devices.d.ts +2 -0
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/extension-updates.d.ts +4 -0
- package/dist/schemas/extension-updates.d.ts.map +1 -1
- package/dist/schemas/git/git.d.ts +16 -0
- package/dist/schemas/git/git.d.ts.map +1 -1
- package/dist/schemas/git/git.js +1 -0
- package/dist/schemas/git/git.js.map +1 -1
- package/dist/schemas/hosts.d.ts +8 -0
- package/dist/schemas/hosts.d.ts.map +1 -1
- package/dist/schemas/hosts.js +3 -0
- package/dist/schemas/hosts.js.map +1 -1
- package/dist/schemas/metrics.d.ts +7 -0
- package/dist/schemas/metrics.d.ts.map +1 -1
- package/dist/schemas/metrics.js +1 -0
- package/dist/schemas/metrics.js.map +1 -1
- package/dist/schemas/offload.d.ts +62 -0
- package/dist/schemas/offload.d.ts.map +1 -0
- package/dist/schemas/offload.js +23 -0
- package/dist/schemas/offload.js.map +1 -0
- package/dist/schemas/personas.js +3 -3
- package/dist/schemas/personas.js.map +1 -1
- package/dist/schemas/ports.d.ts +10 -0
- package/dist/schemas/ports.d.ts.map +1 -1
- package/dist/schemas/ports.js +8 -0
- package/dist/schemas/ports.js.map +1 -1
- package/dist/schemas/providers/plan-limits.d.ts +29 -0
- package/dist/schemas/providers/plan-limits.d.ts.map +1 -1
- package/dist/schemas/providers/plan-limits.js +7 -0
- package/dist/schemas/providers/plan-limits.js.map +1 -1
- package/dist/schemas/providers/provider-oauth.d.ts +12 -0
- package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/settings-history.d.ts +28 -0
- package/dist/schemas/settings-history.d.ts.map +1 -0
- package/dist/schemas/settings-history.js +39 -0
- package/dist/schemas/settings-history.js.map +1 -0
- package/dist/schemas/settings.d.ts +4 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +10 -3
- package/dist/schemas/settings.js.map +1 -1
- package/dist/schemas/system.d.ts +24 -0
- package/dist/schemas/system.d.ts.map +1 -1
- package/dist/schemas/system.js +15 -0
- package/dist/schemas/system.js.map +1 -1
- package/dist/schemas/workspace/mainline.d.ts +371 -0
- package/dist/schemas/workspace/mainline.d.ts.map +1 -0
- package/dist/schemas/workspace/mainline.js +134 -0
- package/dist/schemas/workspace/mainline.js.map +1 -0
- package/dist/schemas/workspace/workspace-tree.d.ts +23 -0
- package/dist/schemas/workspace/workspace-tree.d.ts.map +1 -1
- package/dist/schemas/workspace/workspace-tree.js +34 -0
- package/dist/schemas/workspace/workspace-tree.js.map +1 -1
- package/dist/state/definition.d.ts +20 -4
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/definition.js.map +1 -1
- package/dist/state/history-state.d.ts.map +1 -1
- package/dist/state/history-state.js +5 -0
- package/dist/state/history-state.js.map +1 -1
- package/dist/state/runtime-state.d.ts +3 -0
- package/dist/state/runtime-state.d.ts.map +1 -1
- package/dist/state/runtime-state.js +1 -0
- package/dist/state/runtime-state.js.map +1 -1
- package/dist/state/workspace-state.d.ts +22 -1
- package/dist/state/workspace-state.d.ts.map +1 -1
- package/dist/state/workspace-state.js +27 -1
- package/dist/state/workspace-state.js.map +1 -1
- package/dist/text/transcript-fold.d.ts +1 -0
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +10 -1
- package/dist/text/transcript-fold.js.map +1 -1
- package/package.json +28 -27
- package/src/chores/chores.ts +6 -6
- package/src/chores/fix-deps.ts +3 -2
- package/src/chores/prompt.ts +3 -2
- package/src/contracts/agents.contract.ts +36 -1
- package/src/contracts/offload.contract.ts +60 -0
- package/src/contracts/runner.contract.ts +7 -1
- package/src/contracts/workspace.contract.ts +54 -2
- package/src/documents/conversions.ts +402 -0
- package/src/documents/index.ts +5 -0
- package/src/documents/passthrough.ts +139 -0
- package/src/events/land-breakage.ts +12 -0
- package/src/events/land-conflict.ts +12 -0
- package/src/events/system-events.ts +9 -3
- package/src/events/verify-nudge.ts +4 -3
- package/src/front/generated/wire.ts +35 -4
- package/src/front/terminal-frames.fixture.json +55 -0
- package/src/front/terminal-frames.test.ts +63 -0
- package/src/front/terminal-frames.ts +102 -0
- package/src/ids/conversation-ids.ts +7 -0
- package/src/index.ts +7 -1
- package/src/protocol/container-requirements.test.ts +2 -3
- package/src/protocol/container-requirements.ts +1 -7
- package/src/protocol/ingress-contract.fixture.json +77 -0
- package/src/protocol/ingress-contract.test.ts +18 -26
- package/src/protocol/ingress-contract.ts +3 -22
- package/src/protocol/raw-routes.ts +21 -14
- package/src/protocol/route-meta.ts +4 -0
- package/src/protocol/runner-protocol.ts +53 -0
- package/src/protocol/tunnel-lanes.json +136 -0
- package/src/protocol/tunnel-lanes.test.ts +30 -0
- package/src/protocol/tunnel-lanes.ts +32 -0
- package/src/schemas/activity.ts +3 -1
- package/src/schemas/agents.ts +52 -3
- package/src/schemas/git/git.ts +5 -0
- package/src/schemas/hosts.ts +8 -0
- package/src/schemas/metrics.ts +2 -0
- package/src/schemas/offload.ts +44 -0
- package/src/schemas/personas.ts +3 -3
- package/src/schemas/ports.ts +10 -0
- package/src/schemas/providers/plan-limits.ts +13 -0
- package/src/schemas/settings-history.ts +61 -0
- package/src/schemas/settings.ts +40 -19
- package/src/schemas/system.ts +17 -0
- package/src/schemas/workspace/mainline.ts +201 -0
- package/src/schemas/workspace/workspace-tree.ts +47 -0
- package/src/state/definition.ts +3 -2
- package/src/state/history-state.ts +15 -0
- package/src/state/runtime-state.ts +4 -0
- package/src/state/workspace-state.ts +35 -1
- package/src/text/transcript-fold.test.ts +33 -0
- package/src/text/transcript-fold.ts +14 -1
- package/dist/protocol/ingress-protocol.d.ts +0 -30
- package/dist/protocol/ingress-protocol.d.ts.map +0 -1
- package/dist/protocol/ingress-protocol.js +0 -300
- package/dist/protocol/ingress-protocol.js.map +0 -1
- package/dist/protocol/terminal-protocol.d.ts +0 -18
- package/dist/protocol/terminal-protocol.d.ts.map +0 -1
- package/dist/protocol/terminal-protocol.js +0 -2
- package/dist/protocol/terminal-protocol.js.map +0 -1
- package/dist/protocol/tunnel-heartbeat.d.ts +0 -19
- package/dist/protocol/tunnel-heartbeat.d.ts.map +0 -1
- package/dist/protocol/tunnel-heartbeat.js +0 -41
- package/dist/protocol/tunnel-heartbeat.js.map +0 -1
- package/src/protocol/ingress-protocol.test.ts +0 -579
- package/src/protocol/ingress-protocol.ts +0 -469
- package/src/protocol/terminal-protocol.ts +0 -13
- package/src/protocol/tunnel-heartbeat.test.ts +0 -75
- package/src/protocol/tunnel-heartbeat.ts +0 -67
|
@@ -1,469 +0,0 @@
|
|
|
1
|
-
import { Agent, request as h1Request, type IncomingMessage, type OutgoingHttpHeaders, type ServerResponse } from "node:http";
|
|
2
|
-
import {
|
|
3
|
-
type ClientHttp2Session,
|
|
4
|
-
connect as h2Connect,
|
|
5
|
-
constants,
|
|
6
|
-
createServer as createH2Server,
|
|
7
|
-
type IncomingHttpHeaders,
|
|
8
|
-
type ServerHttp2Stream,
|
|
9
|
-
} from "node:http2";
|
|
10
|
-
import { type AddressInfo, createServer as createNetServer, connect as netConnect, type Socket } from "node:net";
|
|
11
|
-
import { Duplex } from "node:stream";
|
|
12
|
-
|
|
13
|
-
// Both halves of the ingress tunnel's h2 session over any node Duplex, in one file so the wire has one owner: the edge's,
|
|
14
|
-
// and a daemon half its suites dial (a sandbox runs the Rust one, _sandbox/front tunnel.rs, held to this by its test).
|
|
15
|
-
// Routing is per h2 stream, since one edge connection can interleave several sandboxes' hosts.
|
|
16
|
-
|
|
17
|
-
// Header hygiene
|
|
18
|
-
|
|
19
|
-
// Headers that must not cross a hop: h2 throws on these, and `host` is replaced by :authority.
|
|
20
|
-
const HOP_BY_HOP = new Set([
|
|
21
|
-
"connection",
|
|
22
|
-
"host",
|
|
23
|
-
"http2-settings",
|
|
24
|
-
"keep-alive",
|
|
25
|
-
"proxy-authenticate",
|
|
26
|
-
"proxy-authorization",
|
|
27
|
-
"proxy-connection",
|
|
28
|
-
"te",
|
|
29
|
-
"trailer",
|
|
30
|
-
"transfer-encoding",
|
|
31
|
-
"upgrade",
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
// Headers that survive a hop: pseudo-headers and HOP_BY_HOP removed, values unchanged, including array values for a
|
|
35
|
-
// repeated field like `set-cookie`.
|
|
36
|
-
const endToEnd = (headers: IncomingHttpHeaders): OutgoingHttpHeaders => {
|
|
37
|
-
const kept: OutgoingHttpHeaders = {};
|
|
38
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
39
|
-
if (value !== undefined && !name.startsWith(":") && !HOP_BY_HOP.has(name)) {
|
|
40
|
-
kept[name] = value;
|
|
41
|
-
}
|
|
42
|
-
}
|
|
43
|
-
return kept;
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
// The upgrade envelope
|
|
47
|
-
|
|
48
|
-
// A websocket upgrade rides a CONNECT stream, where the headers an upgrade needs (Connection, Upgrade,
|
|
49
|
-
// Sec-WebSocket-Key) are exactly the ones h2 forbids. The whole h1 request head travels prefixed under `x-ingress-*`
|
|
50
|
-
// and is rebuilt verbatim on the far side, so no header is individually allowlisted.
|
|
51
|
-
const UPGRADE_METHOD_HEADER = "x-ingress-method";
|
|
52
|
-
const UPGRADE_PATH_HEADER = "x-ingress-path";
|
|
53
|
-
const UPGRADE_HEADER_PREFIX = "x-ingress-h-";
|
|
54
|
-
|
|
55
|
-
const upgradeEnvelope = (request: IncomingMessage, authority: string): OutgoingHttpHeaders => {
|
|
56
|
-
const envelope: OutgoingHttpHeaders = {
|
|
57
|
-
[constants.HTTP2_HEADER_METHOD]: constants.HTTP2_METHOD_CONNECT,
|
|
58
|
-
[constants.HTTP2_HEADER_AUTHORITY]: authority,
|
|
59
|
-
[UPGRADE_METHOD_HEADER]: request.method ?? "GET",
|
|
60
|
-
[UPGRADE_PATH_HEADER]: request.url ?? "/",
|
|
61
|
-
};
|
|
62
|
-
for (const [name, value] of Object.entries(request.headers)) {
|
|
63
|
-
if (value !== undefined) {
|
|
64
|
-
envelope[`${UPGRADE_HEADER_PREFIX}${name}`] = value;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
return envelope;
|
|
68
|
-
};
|
|
69
|
-
|
|
70
|
-
// Rebuilds the h1 head an envelope carried; anything without the `x-ingress-h-` prefix is dropped as not part of the
|
|
71
|
-
// original request.
|
|
72
|
-
const upgradeHead = (headers: IncomingHttpHeaders): { method: string; path: string; headers: OutgoingHttpHeaders } => {
|
|
73
|
-
const rebuilt: OutgoingHttpHeaders = {};
|
|
74
|
-
for (const [name, value] of Object.entries(headers)) {
|
|
75
|
-
if (value !== undefined && name.startsWith(UPGRADE_HEADER_PREFIX)) {
|
|
76
|
-
rebuilt[name.slice(UPGRADE_HEADER_PREFIX.length)] = value;
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return {
|
|
80
|
-
method: single(headers[UPGRADE_METHOD_HEADER]) ?? "GET",
|
|
81
|
-
path: single(headers[UPGRADE_PATH_HEADER]) ?? "/",
|
|
82
|
-
headers: rebuilt,
|
|
83
|
-
};
|
|
84
|
-
};
|
|
85
|
-
|
|
86
|
-
// First value of a header that should never repeat; a repeated one is taken as its first value.
|
|
87
|
-
const single = (value: string | string[] | undefined): string | undefined => (Array.isArray(value) ? value[0] : value);
|
|
88
|
-
|
|
89
|
-
// Serializes a response head verbatim from `rawHeaders` (order and spelling preserved) since it is written raw onto the
|
|
90
|
-
// browser's socket; latin1, since header values are opaque octets, not utf-8 text.
|
|
91
|
-
const serializeHead = (response: IncomingMessage, drop: ReadonlySet<string>, add: readonly string[]): Buffer => {
|
|
92
|
-
const lines = [`HTTP/${response.httpVersion} ${String(response.statusCode)} ${response.statusMessage ?? ""}`];
|
|
93
|
-
for (let index = 0; index + 1 < response.rawHeaders.length; index += 2) {
|
|
94
|
-
const name = response.rawHeaders[index] as string;
|
|
95
|
-
if (!drop.has(name.toLowerCase())) {
|
|
96
|
-
lines.push(`${name}: ${response.rawHeaders[index + 1] as string}`);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
return Buffer.from(`${[...lines, ...add].join("\r\n")}\r\n\r\n`, "latin1");
|
|
100
|
-
};
|
|
101
|
-
|
|
102
|
-
// A non-101 answer on a CONNECT stream needs re-framing: `transfer-encoding` is dropped since the body is already
|
|
103
|
-
// de-chunked, and `connection: close` delimits it instead.
|
|
104
|
-
const DECLINED_UPGRADE_DROP = new Set(["connection", "keep-alive", "transfer-encoding"]);
|
|
105
|
-
const DECLINED_UPGRADE_ADD = ["connection: close"];
|
|
106
|
-
const NOTHING_DROPPED: ReadonlySet<string> = new Set();
|
|
107
|
-
|
|
108
|
-
// Session tuning
|
|
109
|
-
|
|
110
|
-
// Session memory budget shared by every stream on the tunnel; exhausting it kills the whole session, not one request.
|
|
111
|
-
const MAX_SESSION_MEMORY_MB = 128;
|
|
112
|
-
|
|
113
|
-
// Per-stream receive window, larger than h2's 64KB default since this session crosses the internet.
|
|
114
|
-
export const INITIAL_WINDOW_SIZE = 1024 * 1024;
|
|
115
|
-
|
|
116
|
-
// Session-wide receive window. SETTINGS only sizes streams: without this every stream shares h2's 65,535 bytes per
|
|
117
|
-
// round trip. Held to twice the stream window, since Bun's client stops updating a stream's window past that ratio,
|
|
118
|
-
// and near the bandwidth-delay product, since a larger one queues a keystroke behind a download on a slow link.
|
|
119
|
-
export const CONNECTION_WINDOW_SIZE = 2 * INITIAL_WINDOW_SIZE;
|
|
120
|
-
|
|
121
|
-
// Concurrent stream ceiling per sandbox, raised since streams here can stay open for a session's whole life.
|
|
122
|
-
const PEER_MAX_CONCURRENT_STREAMS = 256;
|
|
123
|
-
|
|
124
|
-
// Fixed loopback address; no stream over this tunnel can be pointed anywhere but this container's own listener.
|
|
125
|
-
const LOOPBACK = "127.0.0.1";
|
|
126
|
-
|
|
127
|
-
// The splice
|
|
128
|
-
|
|
129
|
-
// Pipes both directions; `pipe` carries end-of-stream each way on its own. A failure on either side destroys both,
|
|
130
|
-
// since `destroy` is idempotent and the two handlers won't loop.
|
|
131
|
-
const splice = (left: Duplex, right: Duplex): void => {
|
|
132
|
-
left.pipe(right);
|
|
133
|
-
right.pipe(left);
|
|
134
|
-
const fail = (): void => {
|
|
135
|
-
left.destroy();
|
|
136
|
-
right.destroy();
|
|
137
|
-
};
|
|
138
|
-
left.on("error", fail);
|
|
139
|
-
right.on("error", fail);
|
|
140
|
-
// A side that closes without finishing its writes (a reset, a dropped socket) takes the other side down too;
|
|
141
|
-
// `writableFinished` distinguishes that from a graceful FIN already forwarded by the pipe.
|
|
142
|
-
left.on("close", () => {
|
|
143
|
-
if (!right.writableFinished) {
|
|
144
|
-
right.destroy();
|
|
145
|
-
}
|
|
146
|
-
});
|
|
147
|
-
right.on("close", () => {
|
|
148
|
-
if (!left.writableFinished) {
|
|
149
|
-
left.destroy();
|
|
150
|
-
}
|
|
151
|
-
});
|
|
152
|
-
};
|
|
153
|
-
|
|
154
|
-
// The loopback bridge
|
|
155
|
-
|
|
156
|
-
// Bridges the caller's Duplex onto a real net.Socket: node's Duplex-over-h2 wrapper allows one write in flight, so a
|
|
157
|
-
// same-turn second write throws ERR_INTERNAL_ASSERTION and kills the process. The accepted socket is checked against
|
|
158
|
-
// the one just dialed.
|
|
159
|
-
const loopbackBridge = async (): Promise<{ session: Socket; tunnel: Socket }> => {
|
|
160
|
-
const listener = createNetServer();
|
|
161
|
-
try {
|
|
162
|
-
await new Promise<void>((resolve, reject) => {
|
|
163
|
-
listener.once("error", reject);
|
|
164
|
-
listener.listen(0, LOOPBACK, resolve);
|
|
165
|
-
});
|
|
166
|
-
const accepted = new Promise<Socket>((resolve, reject) => {
|
|
167
|
-
listener.once("connection", resolve);
|
|
168
|
-
listener.once("error", reject);
|
|
169
|
-
});
|
|
170
|
-
const tunnel = netConnect((listener.address() as AddressInfo).port, LOOPBACK);
|
|
171
|
-
await new Promise<void>((resolve, reject) => {
|
|
172
|
-
tunnel.once("connect", resolve);
|
|
173
|
-
tunnel.once("error", reject);
|
|
174
|
-
});
|
|
175
|
-
const session = await accepted;
|
|
176
|
-
if (session.remotePort !== tunnel.localPort) {
|
|
177
|
-
session.destroy();
|
|
178
|
-
tunnel.destroy();
|
|
179
|
-
throw new Error("the loopback bridge accepted a connection that was not its own");
|
|
180
|
-
}
|
|
181
|
-
// Disables Nagle: both ends are local, and frames here are already sized by h2.
|
|
182
|
-
session.setNoDelay(true);
|
|
183
|
-
tunnel.setNoDelay(true);
|
|
184
|
-
return { session, tunnel };
|
|
185
|
-
} finally {
|
|
186
|
-
listener.close();
|
|
187
|
-
}
|
|
188
|
-
};
|
|
189
|
-
|
|
190
|
-
// The ingress half: an h2 client over the tunnel
|
|
191
|
-
|
|
192
|
-
export interface IngressSession {
|
|
193
|
-
// Forwards one edge request down the tunnel. Resolves once the response is fully written; rejects on failure, and
|
|
194
|
-
// the caller reads `response.headersSent` to tell an untouched response (write your own 502) from one truncated
|
|
195
|
-
// mid-body.
|
|
196
|
-
readonly forwardRequest: (request: IncomingMessage, response: ServerResponse) => Promise<void>;
|
|
197
|
-
// Same for an upgrade, given the hijacked socket and any bytes already read past the request head. Nothing is
|
|
198
|
-
// written to `socket` until the far end accepts, so a rejection leaves it untouched for the caller to answer.
|
|
199
|
-
readonly forwardUpgrade: (request: IncomingMessage, socket: Duplex, head: Buffer) => Promise<void>;
|
|
200
|
-
// Ends the session and its bridge, which ends the caller's duplex and closes the WebSocket.
|
|
201
|
-
readonly close: () => void;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
// Opens the ingress side of a tunnel; async since the bridge itself is. The connect URL is a placeholder — this session
|
|
205
|
-
// has no host of its own, every request supplies the authority it's routed by.
|
|
206
|
-
export const openIngressSession = async (duplex: Duplex): Promise<IngressSession> => {
|
|
207
|
-
const bridge = await loopbackBridge();
|
|
208
|
-
splice(bridge.tunnel, duplex);
|
|
209
|
-
const session: ClientHttp2Session = h2Connect("http://tunnel.invalid", {
|
|
210
|
-
createConnection: () => bridge.session,
|
|
211
|
-
maxSessionMemory: MAX_SESSION_MEMORY_MB,
|
|
212
|
-
peerMaxConcurrentStreams: PEER_MAX_CONCURRENT_STREAMS,
|
|
213
|
-
settings: { initialWindowSize: INITIAL_WINDOW_SIZE },
|
|
214
|
-
});
|
|
215
|
-
// Without a handler here, a dead or hostile peer raises an uncaught exception and takes every other sandbox's
|
|
216
|
-
// tunnel down with it. Destroying the duplex closes the WebSocket, which the reconnect loop waits on.
|
|
217
|
-
session.on("error", () => duplex.destroy());
|
|
218
|
-
session.on("close", () => duplex.destroy());
|
|
219
|
-
session.once("connect", () => session.setLocalWindowSize(CONNECTION_WINDOW_SIZE));
|
|
220
|
-
|
|
221
|
-
// Authority to route the stream by. The ingress already refuses any Host that doesn't own a sandbox, so a request
|
|
222
|
-
// with no Host here is a caller bug, not a routing decision.
|
|
223
|
-
const authorityOf = (request: IncomingMessage): string => {
|
|
224
|
-
const host = request.headers.host;
|
|
225
|
-
if (host === undefined || host === "") {
|
|
226
|
-
throw new Error("an ingress request is routed by its Host header, and this one has none");
|
|
227
|
-
}
|
|
228
|
-
return host;
|
|
229
|
-
};
|
|
230
|
-
|
|
231
|
-
const forwardRequest = (request: IncomingMessage, response: ServerResponse): Promise<void> =>
|
|
232
|
-
new Promise<void>((resolve, reject) => {
|
|
233
|
-
const authority = authorityOf(request);
|
|
234
|
-
// `endStream: false` for every method, not only ones that may carry a body: the request is piped
|
|
235
|
-
// regardless, and an already-ended GET closes the stream with an empty frame.
|
|
236
|
-
const stream = session.request(
|
|
237
|
-
{
|
|
238
|
-
[constants.HTTP2_HEADER_METHOD]: request.method ?? "GET",
|
|
239
|
-
[constants.HTTP2_HEADER_PATH]: request.url ?? "/",
|
|
240
|
-
[constants.HTTP2_HEADER_AUTHORITY]: authority,
|
|
241
|
-
// The edge is HTTPS-only, so this is what the browser used; nothing here routes on it.
|
|
242
|
-
[constants.HTTP2_HEADER_SCHEME]: "https",
|
|
243
|
-
...endToEnd(request.headers),
|
|
244
|
-
},
|
|
245
|
-
{ endStream: false },
|
|
246
|
-
);
|
|
247
|
-
request.pipe(stream);
|
|
248
|
-
|
|
249
|
-
// The browser giving up must reach the daemon: without this, a closed tab leaves the h2 stream, the
|
|
250
|
-
// daemon's own request, and any long-lived generator (SSE) running for nobody. RST_STREAM(CANCEL) unwinds
|
|
251
|
-
// all three.
|
|
252
|
-
response.on("close", () => {
|
|
253
|
-
if (!response.writableFinished) {
|
|
254
|
-
stream.close(constants.NGHTTP2_CANCEL);
|
|
255
|
-
}
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
stream.on("response", (headers) => {
|
|
259
|
-
if (response.destroyed) {
|
|
260
|
-
stream.close(constants.NGHTTP2_CANCEL);
|
|
261
|
-
return;
|
|
262
|
-
}
|
|
263
|
-
response.writeHead(Number(headers[constants.HTTP2_HEADER_STATUS] ?? 502), endToEnd(headers));
|
|
264
|
-
stream.pipe(response);
|
|
265
|
-
});
|
|
266
|
-
stream.on("error", reject);
|
|
267
|
-
// The response resolves on `finish`, once node has flushed it to the browser, not merely when the h2 stream
|
|
268
|
-
// closes.
|
|
269
|
-
response.on("finish", resolve);
|
|
270
|
-
stream.on("close", () => {
|
|
271
|
-
if (!response.writableEnded) {
|
|
272
|
-
reject(new Error("the tunnel closed the stream before the response was complete"));
|
|
273
|
-
}
|
|
274
|
-
});
|
|
275
|
-
});
|
|
276
|
-
|
|
277
|
-
const forwardUpgrade = (request: IncomingMessage, socket: Duplex, head: Buffer): Promise<void> =>
|
|
278
|
-
new Promise<void>((resolve, reject) => {
|
|
279
|
-
const stream = session.request(upgradeEnvelope(request, authorityOf(request)), { endStream: false });
|
|
280
|
-
stream.on("error", reject);
|
|
281
|
-
stream.once("response", (headers) => {
|
|
282
|
-
const status = Number(headers[constants.HTTP2_HEADER_STATUS]);
|
|
283
|
-
if (status !== constants.HTTP_STATUS_OK) {
|
|
284
|
-
// The far end refused the upgrade; nothing has reached the browser yet, so the caller still owns
|
|
285
|
-
// the socket.
|
|
286
|
-
stream.close(constants.NGHTTP2_CANCEL);
|
|
287
|
-
reject(new Error(`the tunnel refused an upgrade with :status ${String(status)}`));
|
|
288
|
-
return;
|
|
289
|
-
}
|
|
290
|
-
// Bytes the client already sent past its request head; dropped, the handshake completes and then hangs
|
|
291
|
-
// on a frame nobody has.
|
|
292
|
-
if (head.length > 0) {
|
|
293
|
-
stream.write(head);
|
|
294
|
-
}
|
|
295
|
-
splice(stream, socket);
|
|
296
|
-
resolve();
|
|
297
|
-
});
|
|
298
|
-
});
|
|
299
|
-
|
|
300
|
-
return {
|
|
301
|
-
forwardRequest,
|
|
302
|
-
forwardUpgrade,
|
|
303
|
-
// Graceful GOAWAY first, then the bridge's far half, which the session itself knows nothing about.
|
|
304
|
-
close: () => {
|
|
305
|
-
session.close(() => bridge.tunnel.destroy());
|
|
306
|
-
bridge.tunnel.end();
|
|
307
|
-
},
|
|
308
|
-
};
|
|
309
|
-
};
|
|
310
|
-
|
|
311
|
-
// The daemon half the edge's suites dial: an h2 server over the tunnel, onto a local listener
|
|
312
|
-
|
|
313
|
-
export interface ServeIngressSessionOptions {
|
|
314
|
-
// The daemon's own listener; every stream lands there as a plain HTTP/1.1 request or upgrade.
|
|
315
|
-
readonly targetPort: number;
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
export interface IngressSessionServer {
|
|
319
|
-
// Sends GOAWAY over the bridge, which ends the caller's duplex once it's done writing the shutdown frame. The
|
|
320
|
-
// WebSocket itself belongs to whoever dialed it.
|
|
321
|
-
readonly close: () => void;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// One h2 server per session, bound to nothing; it exists only to be handed a connection. The h1 hop to the loopback
|
|
325
|
-
// listener runs over a keep-alive agent, so steady traffic reuses sockets; the upgrade path does not.
|
|
326
|
-
export const serveIngressSession = async (duplex: Duplex, options: ServeIngressSessionOptions): Promise<IngressSessionServer> => {
|
|
327
|
-
const bridge = await loopbackBridge();
|
|
328
|
-
splice(bridge.tunnel, duplex);
|
|
329
|
-
const agent = new Agent({ keepAlive: true, maxSockets: PEER_MAX_CONCURRENT_STREAMS });
|
|
330
|
-
const server = createH2Server({
|
|
331
|
-
maxSessionMemory: MAX_SESSION_MEMORY_MB,
|
|
332
|
-
settings: { initialWindowSize: INITIAL_WINDOW_SIZE },
|
|
333
|
-
});
|
|
334
|
-
|
|
335
|
-
// A failed session is news for the reconnect loop, not an uncaught exception that takes the whole daemon down.
|
|
336
|
-
// Destroying the duplex closes the WebSocket the loop waits on; `clientError` should never fire but isn't worth
|
|
337
|
-
// crashing over either.
|
|
338
|
-
server.on("sessionError", () => duplex.destroy());
|
|
339
|
-
server.on("clientError", () => duplex.destroy());
|
|
340
|
-
server.on("error", () => duplex.destroy());
|
|
341
|
-
server.on("session", (session) => session.setLocalWindowSize(CONNECTION_WINDOW_SIZE));
|
|
342
|
-
|
|
343
|
-
// The loopback's answer can outlive its stream when the session dies, and respond/write on a dead stream throws
|
|
344
|
-
// ERR_HTTP2_INVALID_STREAM from an event callback, where nothing catches it.
|
|
345
|
-
const gone = (stream: ServerHttp2Stream): boolean => stream.destroyed || stream.closed;
|
|
346
|
-
|
|
347
|
-
const forwardToLoopback = (stream: ServerHttp2Stream, headers: IncomingHttpHeaders): void => {
|
|
348
|
-
const local = h1Request({
|
|
349
|
-
host: LOOPBACK,
|
|
350
|
-
port: options.targetPort,
|
|
351
|
-
method: single(headers[constants.HTTP2_HEADER_METHOD]) ?? "GET",
|
|
352
|
-
path: single(headers[constants.HTTP2_HEADER_PATH]) ?? "/",
|
|
353
|
-
// The Host the request was made to, restored where an h1 server reads it; this alone tells a preview, a
|
|
354
|
-
// port and the daemon apart.
|
|
355
|
-
headers: { host: single(headers[constants.HTTP2_HEADER_AUTHORITY]) ?? "", ...endToEnd(headers) },
|
|
356
|
-
agent,
|
|
357
|
-
});
|
|
358
|
-
stream.pipe(local);
|
|
359
|
-
local.on("response", (response) => {
|
|
360
|
-
if (gone(stream)) {
|
|
361
|
-
response.destroy();
|
|
362
|
-
return;
|
|
363
|
-
}
|
|
364
|
-
stream.respond({ [constants.HTTP2_HEADER_STATUS]: response.statusCode ?? 502, ...endToEnd(response.headers) });
|
|
365
|
-
response.pipe(stream);
|
|
366
|
-
});
|
|
367
|
-
// The listener isn't answering; closed with an error code rather than a 502 of our own, since the ingress owns
|
|
368
|
-
// what an unreachable sandbox looks like.
|
|
369
|
-
local.on("error", () => stream.close(constants.NGHTTP2_INTERNAL_ERROR));
|
|
370
|
-
stream.on("error", () => local.destroy());
|
|
371
|
-
// The edge cancelled (RST_STREAM); stop generating a response nobody reads. `aborted` is the only event that
|
|
372
|
-
// means this — on a reset, node fires `aborted` before `finish` and `close`, so a
|
|
373
|
-
// `writableEnded`/`writableFinished` guard there would miss it.
|
|
374
|
-
stream.on("aborted", () => local.destroy());
|
|
375
|
-
// `aborted` fires only while the stream's writable side is open; `close` covers every other way it dies, and a
|
|
376
|
-
// request whose answer already completed ignores the destroy.
|
|
377
|
-
stream.on("close", () => local.destroy());
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
const spliceUpgrade = (stream: ServerHttp2Stream, headers: IncomingHttpHeaders): void => {
|
|
381
|
-
const head = upgradeHead(headers);
|
|
382
|
-
// `agent: false`: an upgraded connection stops being HTTP once accepted and can never return to the keep-alive
|
|
383
|
-
// pool the request path above uses.
|
|
384
|
-
const local = h1Request({ host: LOOPBACK, port: options.targetPort, method: head.method, path: head.path, headers: head.headers, agent: false });
|
|
385
|
-
local.on("upgrade", (response, socket, first) => {
|
|
386
|
-
if (gone(stream)) {
|
|
387
|
-
socket.destroy();
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
stream.respond({ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_OK });
|
|
391
|
-
// Response head written verbatim (see serializeHead), then any bytes already sent past it, then the pipe.
|
|
392
|
-
stream.write(serializeHead(response, NOTHING_DROPPED, []));
|
|
393
|
-
if (first.length > 0) {
|
|
394
|
-
stream.write(first);
|
|
395
|
-
}
|
|
396
|
-
splice(stream, socket);
|
|
397
|
-
});
|
|
398
|
-
// The local server declined to upgrade; its answer goes to the browser with framing that matches the already
|
|
399
|
-
// de-chunked body.
|
|
400
|
-
local.on("response", (response) => {
|
|
401
|
-
if (gone(stream)) {
|
|
402
|
-
response.destroy();
|
|
403
|
-
return;
|
|
404
|
-
}
|
|
405
|
-
stream.respond({ [constants.HTTP2_HEADER_STATUS]: constants.HTTP_STATUS_OK });
|
|
406
|
-
stream.write(serializeHead(response, DECLINED_UPGRADE_DROP, DECLINED_UPGRADE_ADD));
|
|
407
|
-
response.pipe(stream);
|
|
408
|
-
});
|
|
409
|
-
local.on("error", () => stream.close(constants.NGHTTP2_CONNECT_ERROR));
|
|
410
|
-
// Before the handshake answers, nothing else ties the loopback request to the stream; once spliced, a no-op.
|
|
411
|
-
stream.on("close", () => local.destroy());
|
|
412
|
-
// An upgrade request carries no body; the client is waiting on the handshake before it says anything else.
|
|
413
|
-
local.end();
|
|
414
|
-
};
|
|
415
|
-
|
|
416
|
-
server.on("stream", (stream, headers) => {
|
|
417
|
-
if (headers[constants.HTTP2_HEADER_METHOD] === constants.HTTP2_METHOD_CONNECT) {
|
|
418
|
-
spliceUpgrade(stream, headers);
|
|
419
|
-
return;
|
|
420
|
-
}
|
|
421
|
-
forwardToLoopback(stream, headers);
|
|
422
|
-
});
|
|
423
|
-
|
|
424
|
-
// Creates a server-side session without a listener: the server never binds, it is simply handed a connection.
|
|
425
|
-
server.emit("connection", bridge.session);
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
close: () => {
|
|
429
|
-
agent.destroy();
|
|
430
|
-
server.close();
|
|
431
|
-
bridge.tunnel.end();
|
|
432
|
-
},
|
|
433
|
-
};
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
// Reimplements `ws`'s createWebSocketStream, which Bun does not support, so both runtimes get the same Duplex.
|
|
437
|
-
// Preserves byte safety: the write callback fires only once the socket has taken the frame, and a closed socket ends
|
|
438
|
-
// the readable side with `push(null)`.
|
|
439
|
-
export interface TunnelWebSocket {
|
|
440
|
-
readonly send: (data: Buffer, options: { binary: boolean }, callback: (error?: Error) => void) => void;
|
|
441
|
-
readonly close: () => void;
|
|
442
|
-
readonly on: {
|
|
443
|
-
(event: `message`, listener: (data: unknown) => void): unknown;
|
|
444
|
-
(event: `close`, listener: () => void): unknown;
|
|
445
|
-
(event: `error`, listener: (error: Error) => void): unknown;
|
|
446
|
-
};
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
export const webSocketDuplex = (socket: TunnelWebSocket): Duplex => {
|
|
450
|
-
const duplex = new Duplex({
|
|
451
|
-
read: () => {},
|
|
452
|
-
write: (chunk: Buffer, _encoding, callback) => {
|
|
453
|
-
socket.send(chunk, { binary: true }, (error) => callback(error ?? null));
|
|
454
|
-
},
|
|
455
|
-
// Half-closing a WebSocket isn't possible; ending the writable side closes the socket outright.
|
|
456
|
-
final: (callback) => {
|
|
457
|
-
socket.close();
|
|
458
|
-
callback();
|
|
459
|
-
},
|
|
460
|
-
});
|
|
461
|
-
// `message` carries a Buffer for binary frames; a stray text frame arrives as a string and is coerced rather than
|
|
462
|
-
// dropped.
|
|
463
|
-
socket.on(`message`, (data: unknown) => {
|
|
464
|
-
duplex.push(Buffer.isBuffer(data) ? data : Buffer.from(data as string));
|
|
465
|
-
});
|
|
466
|
-
socket.on(`close`, () => duplex.push(null));
|
|
467
|
-
socket.on(`error`, (error: Error) => duplex.destroy(error));
|
|
468
|
-
return duplex;
|
|
469
|
-
};
|
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
// The /system/terminal WebSocket wire protocol, shared by the daemon and browser terminal so the two can't drift. Plain
|
|
2
|
-
// types, not zod, since this is a per-keystroke hot path nothing validates. Pane bytes travel as raw binary frames (so
|
|
3
|
-
// a split UTF-8 char decodes whole); everything else is JSON.
|
|
4
|
-
|
|
5
|
-
// `ping` is the client's 30s keepalive against tunnel idle-reaping; `pong` answers it, so silence beyond the interval
|
|
6
|
-
// means half-open.
|
|
7
|
-
export type TerminalClientMessage =
|
|
8
|
-
| { readonly type: "input"; readonly data: string }
|
|
9
|
-
| { readonly type: "resize"; readonly cols: number; readonly rows: number }
|
|
10
|
-
| { readonly type: "ping" };
|
|
11
|
-
|
|
12
|
-
// `exit` is terminal (no reconnect) when the tmux client ends; `reason` is tmux's own words, if any, for the dead pane.
|
|
13
|
-
export type TerminalServerMessage = { readonly type: "exit"; readonly code: number; readonly reason?: string } | { readonly type: "pong" };
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
import { createHeartbeat, DEAD_AFTER_MS } from "./tunnel-heartbeat.js";
|
|
2
|
-
|
|
3
|
-
// The clock is injected, so these read as "what happens after N seconds of silence" rather than as timer
|
|
4
|
-
// plumbing — which is the only question the heartbeat answers.
|
|
5
|
-
const world = (deadAfterMs = DEAD_AFTER_MS) => {
|
|
6
|
-
let clock = 0;
|
|
7
|
-
const ping = jest.fn();
|
|
8
|
-
const onDead = jest.fn();
|
|
9
|
-
const heartbeat = createHeartbeat({ ping, onDead, deadAfterMs, now: () => clock });
|
|
10
|
-
return { heartbeat, ping, onDead, advance: (ms: number) => (clock += ms) };
|
|
11
|
-
};
|
|
12
|
-
|
|
13
|
-
describe(`createHeartbeat`, () => {
|
|
14
|
-
test(`pings a peer that is still answering`, () => {
|
|
15
|
-
const { heartbeat, ping, onDead } = world();
|
|
16
|
-
heartbeat.tick();
|
|
17
|
-
heartbeat.tick();
|
|
18
|
-
|
|
19
|
-
expect(ping).toHaveBeenCalledTimes(2);
|
|
20
|
-
expect(onDead).toHaveBeenCalledTimes(0);
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
// Three missed intervals, not one: a single dropped pong is ordinary on a congested link, and tearing down
|
|
24
|
-
// a working tunnel for it is the more expensive mistake.
|
|
25
|
-
test(`survives a lost pong inside the dead window`, () => {
|
|
26
|
-
const { heartbeat, onDead, advance } = world();
|
|
27
|
-
advance(DEAD_AFTER_MS - 1);
|
|
28
|
-
heartbeat.tick();
|
|
29
|
-
|
|
30
|
-
expect(onDead).toHaveBeenCalledTimes(0);
|
|
31
|
-
expect(heartbeat.alive()).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
|
|
34
|
-
test(`declares a peer dead once it has been silent past the window`, () => {
|
|
35
|
-
const { heartbeat, ping, onDead, advance } = world();
|
|
36
|
-
advance(DEAD_AFTER_MS + 1);
|
|
37
|
-
heartbeat.tick();
|
|
38
|
-
|
|
39
|
-
expect(onDead).toHaveBeenCalledTimes(1);
|
|
40
|
-
// Not pinged on the way out: a peer that has gone quiet is not sent one more frame it will never answer.
|
|
41
|
-
expect(ping).toHaveBeenCalledTimes(0);
|
|
42
|
-
expect(heartbeat.alive()).toBe(false);
|
|
43
|
-
});
|
|
44
|
-
|
|
45
|
-
// Any frame counts as life, not just a pong: a tunnel carrying traffic is alive by definition.
|
|
46
|
-
test(`a frame from the peer restarts the window`, () => {
|
|
47
|
-
const { heartbeat, onDead, advance } = world();
|
|
48
|
-
advance(DEAD_AFTER_MS - 1);
|
|
49
|
-
heartbeat.saw();
|
|
50
|
-
advance(DEAD_AFTER_MS - 1);
|
|
51
|
-
heartbeat.tick();
|
|
52
|
-
|
|
53
|
-
expect(onDead).toHaveBeenCalledTimes(0);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
// The socket is torn down once; a tick that raced the teardown must not report it dead a second time.
|
|
57
|
-
test(`reports death once, and never after stopping`, () => {
|
|
58
|
-
const { heartbeat, onDead, advance } = world();
|
|
59
|
-
advance(DEAD_AFTER_MS + 1);
|
|
60
|
-
heartbeat.tick();
|
|
61
|
-
heartbeat.tick();
|
|
62
|
-
|
|
63
|
-
expect(onDead).toHaveBeenCalledTimes(1);
|
|
64
|
-
});
|
|
65
|
-
|
|
66
|
-
test(`a stopped heartbeat neither pings nor fires`, () => {
|
|
67
|
-
const { heartbeat, ping, onDead, advance } = world();
|
|
68
|
-
heartbeat.stop();
|
|
69
|
-
advance(DEAD_AFTER_MS + 1);
|
|
70
|
-
heartbeat.tick();
|
|
71
|
-
|
|
72
|
-
expect(ping).toHaveBeenCalledTimes(0);
|
|
73
|
-
expect(onDead).toHaveBeenCalledTimes(0);
|
|
74
|
-
});
|
|
75
|
-
});
|
|
@@ -1,67 +0,0 @@
|
|
|
1
|
-
// Detects a tunnel peer TCP won't report as gone (a killed container, a dropped NAT mapping, a host that slept), from
|
|
2
|
-
// both ends: the edge forgets a silent sandbox and the sandbox redials a silent edge, and only each end can do its half.
|
|
3
|
-
// A state machine, not a timer, so `tick` (one interval) can be driven directly by tests.
|
|
4
|
-
|
|
5
|
-
// Ping every 15s, dead after three missed intervals (45s); one miss alone is ordinary on a congested link.
|
|
6
|
-
export const PING_INTERVAL_MS = 15_000;
|
|
7
|
-
export const DEAD_AFTER_MS = 45_000;
|
|
8
|
-
|
|
9
|
-
export interface HeartbeatOptions {
|
|
10
|
-
readonly ping: () => void;
|
|
11
|
-
readonly onDead: () => void;
|
|
12
|
-
readonly now?: () => number;
|
|
13
|
-
readonly deadAfterMs?: number;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface Heartbeat {
|
|
17
|
-
// Peer said something; any frame counts, since a busy tunnel is alive even if it drops a pong.
|
|
18
|
-
readonly saw: () => void;
|
|
19
|
-
// One interval elapsed: ping the peer, or declare it dead.
|
|
20
|
-
readonly tick: () => void;
|
|
21
|
-
readonly stop: () => void;
|
|
22
|
-
readonly alive: () => boolean;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export const createHeartbeat = (options: HeartbeatOptions): Heartbeat => {
|
|
26
|
-
const now = options.now ?? Date.now;
|
|
27
|
-
const deadAfterMs = options.deadAfterMs ?? DEAD_AFTER_MS;
|
|
28
|
-
let lastSeen = now();
|
|
29
|
-
let stopped = false;
|
|
30
|
-
|
|
31
|
-
return {
|
|
32
|
-
saw: () => {
|
|
33
|
-
lastSeen = now();
|
|
34
|
-
},
|
|
35
|
-
tick: () => {
|
|
36
|
-
if (stopped) {
|
|
37
|
-
return;
|
|
38
|
-
}
|
|
39
|
-
// Declared dead before pinging: a peer already quiet gets no extra frame, and the deadline window stays
|
|
40
|
-
// exact.
|
|
41
|
-
if (now() - lastSeen > deadAfterMs) {
|
|
42
|
-
stopped = true;
|
|
43
|
-
options.onDead();
|
|
44
|
-
return;
|
|
45
|
-
}
|
|
46
|
-
options.ping();
|
|
47
|
-
},
|
|
48
|
-
stop: () => {
|
|
49
|
-
stopped = true;
|
|
50
|
-
},
|
|
51
|
-
alive: () => !stopped,
|
|
52
|
-
};
|
|
53
|
-
};
|
|
54
|
-
|
|
55
|
-
// Wired to a real clock; unrefed so a heartbeat never keeps the process alive.
|
|
56
|
-
export const startHeartbeat = (options: HeartbeatOptions & { readonly intervalMs?: number }): Heartbeat => {
|
|
57
|
-
const heartbeat = createHeartbeat(options);
|
|
58
|
-
const timer = setInterval(() => heartbeat.tick(), options.intervalMs ?? PING_INTERVAL_MS);
|
|
59
|
-
timer.unref();
|
|
60
|
-
return {
|
|
61
|
-
...heartbeat,
|
|
62
|
-
stop: () => {
|
|
63
|
-
clearInterval(timer);
|
|
64
|
-
heartbeat.stop();
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
};
|