@intentic/sandbox-contract 1.310.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 +43 -159
- 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 +15 -0
- package/dist/contracts/accounts.contract.d.ts.map +1 -1
- package/dist/contracts/agent.contract.d.ts +15 -0
- package/dist/contracts/agent.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.d.ts +1228 -32
- package/dist/contracts/agents.contract.d.ts.map +1 -1
- package/dist/contracts/agents.contract.js +42 -3
- package/dist/contracts/agents.contract.js.map +1 -1
- package/dist/contracts/capabilities.contract.d.ts +2 -0
- package/dist/contracts/capabilities.contract.d.ts.map +1 -1
- package/dist/contracts/device.contract.d.ts +2 -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 +111 -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 +16 -0
- package/dist/contracts/settings.contract.d.ts.map +1 -1
- package/dist/contracts/system.contract.d.ts +154 -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/agent-events.d.ts +20 -0
- package/dist/events/agent-events.d.ts.map +1 -1
- package/dist/events/agent-events.js +6 -1
- package/dist/events/agent-events.js.map +1 -1
- 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 +114 -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/transcript.d.ts +5 -0
- package/dist/events/transcript.d.ts.map +1 -1
- package/dist/events/transcript.js +9 -1
- package/dist/events/transcript.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 +137 -0
- package/dist/front/generated/wire.d.ts.map +1 -0
- package/dist/front/generated/wire.js +2 -0
- package/dist/front/generated/wire.js.map +1 -0
- 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 +1773 -96
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -1
- package/dist/index.js.map +1 -1
- package/dist/policy/command-run.d.ts +1 -1
- package/dist/policy/command-run.d.ts.map +1 -1
- package/dist/policy/command-run.js +7 -4
- package/dist/policy/command-run.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 +145 -3
- package/dist/schemas/agents.d.ts.map +1 -1
- package/dist/schemas/agents.js +43 -9
- package/dist/schemas/agents.js.map +1 -1
- package/dist/schemas/automations.d.ts +43 -0
- package/dist/schemas/automations.d.ts.map +1 -1
- package/dist/schemas/capabilities.d.ts +3 -0
- package/dist/schemas/capabilities.d.ts.map +1 -1
- package/dist/schemas/capabilities.js +4 -0
- package/dist/schemas/capabilities.js.map +1 -1
- package/dist/schemas/devices.d.ts +34 -6
- package/dist/schemas/devices.d.ts.map +1 -1
- package/dist/schemas/devices.js +8 -6
- package/dist/schemas/devices.js.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/keep-warm.d.ts +68 -0
- package/dist/schemas/keep-warm.d.ts.map +1 -0
- package/dist/schemas/keep-warm.js +74 -0
- package/dist/schemas/keep-warm.js.map +1 -0
- 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 +15 -0
- package/dist/schemas/providers/provider-oauth.d.ts.map +1 -1
- package/dist/schemas/providers/provider-oauth.js +4 -0
- package/dist/schemas/providers/provider-oauth.js.map +1 -1
- package/dist/schemas/providers/usage.d.ts +6 -0
- package/dist/schemas/providers/usage.d.ts.map +1 -1
- package/dist/schemas/providers/usage.js +4 -0
- package/dist/schemas/providers/usage.js.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 +8 -0
- package/dist/schemas/settings.d.ts.map +1 -1
- package/dist/schemas/settings.js +33 -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 +36 -4
- package/dist/state/definition.d.ts.map +1 -1
- package/dist/state/definition.js.map +1 -1
- package/dist/state/fix-stance.d.ts.map +1 -1
- package/dist/state/fix-stance.js +1 -0
- package/dist/state/fix-stance.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 +6 -0
- package/dist/state/runtime-state.d.ts.map +1 -1
- package/dist/state/runtime-state.js +2 -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 +3 -0
- package/dist/text/transcript-fold.d.ts.map +1 -1
- package/dist/text/transcript-fold.js +30 -8
- package/dist/text/transcript-fold.js.map +1 -1
- package/dist/time/zone.d.ts +1 -0
- package/dist/time/zone.d.ts.map +1 -1
- package/dist/time/zone.js +5 -0
- package/dist/time/zone.js.map +1 -1
- package/package.json +34 -22
- 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 +50 -3
- 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/agent-events.ts +9 -1
- 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/transcript.ts +14 -3
- package/src/events/verify-nudge.ts +4 -3
- package/src/front/generated/wire.ts +104 -0
- 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/ids/hostnames.fixture.json +20 -0
- package/src/ids/hostnames.test.ts +20 -0
- package/src/index.ts +8 -1
- package/src/policy/command-run.test.ts +35 -0
- package/src/policy/command-run.ts +10 -5
- 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 +66 -10
- package/src/schemas/capabilities.ts +6 -0
- package/src/schemas/devices.ts +16 -9
- package/src/schemas/git/git.ts +5 -0
- package/src/schemas/hosts.ts +8 -0
- package/src/schemas/keep-warm.test.ts +68 -0
- package/src/schemas/keep-warm.ts +124 -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/providers/provider-oauth.ts +6 -0
- package/src/schemas/providers/usage.ts +7 -0
- package/src/schemas/settings-history.ts +61 -0
- package/src/schemas/settings.ts +70 -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/fix-stance.test.ts +14 -0
- package/src/state/fix-stance.ts +2 -0
- package/src/state/history-state.ts +15 -0
- package/src/state/runtime-state.ts +8 -0
- package/src/state/workspace-state.ts +35 -1
- package/src/text/transcript-fold.test.ts +53 -1
- package/src/text/transcript-fold.ts +53 -10
- package/src/time/zone.ts +7 -0
- package/src/webext/index.ts +1 -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 -285
- 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 -535
- package/src/protocol/ingress-protocol.ts +0 -448
- 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,41 +0,0 @@
|
|
|
1
|
-
export const PING_INTERVAL_MS = 15_000;
|
|
2
|
-
export const DEAD_AFTER_MS = 45_000;
|
|
3
|
-
export const createHeartbeat = (options) => {
|
|
4
|
-
const now = options.now ?? Date.now;
|
|
5
|
-
const deadAfterMs = options.deadAfterMs ?? DEAD_AFTER_MS;
|
|
6
|
-
let lastSeen = now();
|
|
7
|
-
let stopped = false;
|
|
8
|
-
return {
|
|
9
|
-
saw: () => {
|
|
10
|
-
lastSeen = now();
|
|
11
|
-
},
|
|
12
|
-
tick: () => {
|
|
13
|
-
if (stopped) {
|
|
14
|
-
return;
|
|
15
|
-
}
|
|
16
|
-
if (now() - lastSeen > deadAfterMs) {
|
|
17
|
-
stopped = true;
|
|
18
|
-
options.onDead();
|
|
19
|
-
return;
|
|
20
|
-
}
|
|
21
|
-
options.ping();
|
|
22
|
-
},
|
|
23
|
-
stop: () => {
|
|
24
|
-
stopped = true;
|
|
25
|
-
},
|
|
26
|
-
alive: () => !stopped,
|
|
27
|
-
};
|
|
28
|
-
};
|
|
29
|
-
export const startHeartbeat = (options) => {
|
|
30
|
-
const heartbeat = createHeartbeat(options);
|
|
31
|
-
const timer = setInterval(() => heartbeat.tick(), options.intervalMs ?? PING_INTERVAL_MS);
|
|
32
|
-
timer.unref();
|
|
33
|
-
return {
|
|
34
|
-
...heartbeat,
|
|
35
|
-
stop: () => {
|
|
36
|
-
clearInterval(timer);
|
|
37
|
-
heartbeat.stop();
|
|
38
|
-
},
|
|
39
|
-
};
|
|
40
|
-
};
|
|
41
|
-
//# sourceMappingURL=tunnel-heartbeat.js.map
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"tunnel-heartbeat.js","sourceRoot":"","sources":["../../src/protocol/tunnel-heartbeat.ts"],"names":[],"mappings":"AAKA,MAAM,CAAC,MAAM,gBAAgB,GAAG,MAAM,CAAC;AACvC,MAAM,CAAC,MAAM,aAAa,GAAG,MAAM,CAAC;AAkBpC,MAAM,CAAC,MAAM,eAAe,GAAG,CAAC,OAAyB,EAAa,EAAE;IACpE,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,IAAI,IAAI,CAAC,GAAG,CAAC;IACpC,MAAM,WAAW,GAAG,OAAO,CAAC,WAAW,IAAI,aAAa,CAAC;IACzD,IAAI,QAAQ,GAAG,GAAG,EAAE,CAAC;IACrB,IAAI,OAAO,GAAG,KAAK,CAAC;IAEpB,OAAO;QACH,GAAG,EAAE,GAAG,EAAE;YACN,QAAQ,GAAG,GAAG,EAAE,CAAC;QACrB,CAAC;QACD,IAAI,EAAE,GAAG,EAAE;YACP,IAAI,OAAO,EAAE,CAAC;gBACV,OAAO;YACX,CAAC;YAGD,IAAI,GAAG,EAAE,GAAG,QAAQ,GAAG,WAAW,EAAE,CAAC;gBACjC,OAAO,GAAG,IAAI,CAAC;gBACf,OAAO,CAAC,MAAM,EAAE,CAAC;gBACjB,OAAO;YACX,CAAC;YACD,OAAO,CAAC,IAAI,EAAE,CAAC;QACnB,CAAC;QACD,IAAI,EAAE,GAAG,EAAE;YACP,OAAO,GAAG,IAAI,CAAC;QACnB,CAAC;QACD,KAAK,EAAE,GAAG,EAAE,CAAC,CAAC,OAAO;KACxB,CAAC;AACN,CAAC,CAAC;AAGF,MAAM,CAAC,MAAM,cAAc,GAAG,CAAC,OAA4D,EAAa,EAAE;IACtG,MAAM,SAAS,GAAG,eAAe,CAAC,OAAO,CAAC,CAAC;IAC3C,MAAM,KAAK,GAAG,WAAW,CAAC,GAAG,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,EAAE,OAAO,CAAC,UAAU,IAAI,gBAAgB,CAAC,CAAC;IAC1F,KAAK,CAAC,KAAK,EAAE,CAAC;IACd,OAAO;QACH,GAAG,SAAS;QACZ,IAAI,EAAE,GAAG,EAAE;YACP,aAAa,CAAC,KAAK,CAAC,CAAC;YACrB,SAAS,CAAC,IAAI,EAAE,CAAC;QACrB,CAAC;KACJ,CAAC;AACN,CAAC,CAAC"}
|
|
@@ -1,535 +0,0 @@
|
|
|
1
|
-
import { createHash, randomBytes } from "node:crypto";
|
|
2
|
-
import { createServer, type IncomingMessage, request as h1Request, type Server, type ServerResponse } from "node:http";
|
|
3
|
-
import type { AddressInfo } from "node:net";
|
|
4
|
-
import { type Duplex, duplexPair } from "node:stream";
|
|
5
|
-
import { waitFor } from "@intentic/testing/bun";
|
|
6
|
-
import { INITIAL_WINDOW_SIZE, openIngressSession, serveIngressSession } from "./ingress-protocol.js";
|
|
7
|
-
|
|
8
|
-
// Drives the full ingress-to-daemon chain (front server, duplex pair, target) through node's real http client, in
|
|
9
|
-
// process, so streaming, half-close and header identity are pinned as bytes, not shapes.
|
|
10
|
-
|
|
11
|
-
const listen = async (server: Server): Promise<number> => {
|
|
12
|
-
await new Promise<void>((resolve) => void server.listen(0, "127.0.0.1", resolve));
|
|
13
|
-
return (server.address() as AddressInfo).port;
|
|
14
|
-
};
|
|
15
|
-
|
|
16
|
-
// Coordinates a fixed order between two halves without measuring time: the near end waits on the far end's signal; an
|
|
17
|
-
// unopened gate fails as the suite's own hang timeout.
|
|
18
|
-
const gate = <T = void>(): { readonly open: (value: T) => void; readonly opened: Promise<T> } => {
|
|
19
|
-
let open = (_value: T): void => {};
|
|
20
|
-
const opened = new Promise<T>((resolve) => {
|
|
21
|
-
open = resolve;
|
|
22
|
-
});
|
|
23
|
-
return { open, opened };
|
|
24
|
-
};
|
|
25
|
-
|
|
26
|
-
const HOST = "sandbox-0123456789ab.sbx.test";
|
|
27
|
-
|
|
28
|
-
// What the target saw, mirrored back as its own response, so assertions read a real server's view rather than the
|
|
29
|
-
// proxy's bookkeeping.
|
|
30
|
-
interface Seen {
|
|
31
|
-
readonly method: string;
|
|
32
|
-
readonly url: string;
|
|
33
|
-
readonly host: string;
|
|
34
|
-
readonly connection: string;
|
|
35
|
-
readonly headerNames: readonly string[];
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const bodyOf = async (message: IncomingMessage): Promise<Buffer> => {
|
|
39
|
-
const chunks: Buffer[] = [];
|
|
40
|
-
for await (const chunk of message) {
|
|
41
|
-
chunks.push(chunk as Buffer);
|
|
42
|
-
}
|
|
43
|
-
return Buffer.concat(chunks);
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
const digest = (bytes: Buffer): string => createHash("sha256").update(bytes).digest("hex");
|
|
47
|
-
|
|
48
|
-
const firstChunk = gate();
|
|
49
|
-
const secondSent = gate();
|
|
50
|
-
const uploadStarted = gate();
|
|
51
|
-
|
|
52
|
-
const floodReleased = gate();
|
|
53
|
-
|
|
54
|
-
// One entry per live /flood response, opened once that response's write queue is non-empty.
|
|
55
|
-
const floodPressure: Promise<void>[] = [];
|
|
56
|
-
|
|
57
|
-
// 64KB per write, keeping node's write queue non-empty so a reset lands on an unfinished write.
|
|
58
|
-
const FLOOD_CHUNK = Buffer.alloc(64 * 1024, 7);
|
|
59
|
-
// 1MB per write: 64 of them outrun the socket buffers on both loopback hops.
|
|
60
|
-
const STALL_CHUNK = Buffer.alloc(1024 * 1024, 3);
|
|
61
|
-
|
|
62
|
-
type Route = (request: IncomingMessage, response: ServerResponse) => void | Promise<void>;
|
|
63
|
-
|
|
64
|
-
// One route per property under test, not a branch of one handler, so each behavior has its own function.
|
|
65
|
-
const routes: Record<string, Route> = {
|
|
66
|
-
"/seen": (request, response) => {
|
|
67
|
-
const seen: Seen = {
|
|
68
|
-
method: request.method ?? "",
|
|
69
|
-
url: request.url ?? "",
|
|
70
|
-
host: request.headers.host ?? "",
|
|
71
|
-
connection: request.headers.connection ?? "",
|
|
72
|
-
headerNames: Object.keys(request.headers).sort(),
|
|
73
|
-
};
|
|
74
|
-
response.writeHead(201, { "content-type": "application/json", "x-target": "yes" });
|
|
75
|
-
response.end(JSON.stringify(seen));
|
|
76
|
-
},
|
|
77
|
-
"/echo": async (request, response) => {
|
|
78
|
-
const bytes = await bodyOf(request);
|
|
79
|
-
response.writeHead(200, { "content-type": "application/octet-stream", "x-sha256": digest(bytes) });
|
|
80
|
-
response.end(bytes);
|
|
81
|
-
},
|
|
82
|
-
// Second write waits for the client to read the first; a proxy that buffers the whole response deadlocks here.
|
|
83
|
-
"/drip": async (_request, response) => {
|
|
84
|
-
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
85
|
-
response.write("one");
|
|
86
|
-
await firstChunk.opened;
|
|
87
|
-
response.write("two");
|
|
88
|
-
response.end();
|
|
89
|
-
secondSent.open();
|
|
90
|
-
},
|
|
91
|
-
// Mirror of /drip: the first body chunk must arrive here before the client sends the rest.
|
|
92
|
-
"/slurp": async (request, response) => {
|
|
93
|
-
request.once("data", () => uploadStarted.open());
|
|
94
|
-
const bytes = await bodyOf(request);
|
|
95
|
-
response.writeHead(200, { "content-type": "text/plain" });
|
|
96
|
-
response.end(bytes.toString("utf8"));
|
|
97
|
-
},
|
|
98
|
-
// Never answered; asserts a client abort surfaces as "aborted", not the "ended" of a completed exchange.
|
|
99
|
-
"/hangup": (_request, response) => {
|
|
100
|
-
response.writeHead(200, { "content-type": "text/event-stream" });
|
|
101
|
-
response.write("open");
|
|
102
|
-
response.on("close", () => cancelled.open(response.writableEnded ? "ended" : "aborted"));
|
|
103
|
-
},
|
|
104
|
-
// Far more than every buffer between here and a reader that has stopped reading, so the stream's window fills.
|
|
105
|
-
"/stall": (_request, response) => {
|
|
106
|
-
response.writeHead(200, { "content-type": "application/octet-stream" });
|
|
107
|
-
let left = 64;
|
|
108
|
-
const pump = (): void => {
|
|
109
|
-
while (left > 0 && response.write(STALL_CHUNK)) {
|
|
110
|
-
left -= 1;
|
|
111
|
-
}
|
|
112
|
-
if (left === 0) {
|
|
113
|
-
response.end();
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
response.on("drain", pump);
|
|
117
|
-
pump();
|
|
118
|
-
},
|
|
119
|
-
// Large enough that writes are still pending when the client resets mid-stream.
|
|
120
|
-
"/flood": async (_request, response) => {
|
|
121
|
-
const pressured = gate();
|
|
122
|
-
floodPressure.push(pressured.opened);
|
|
123
|
-
response.writeHead(200, { "content-type": "application/octet-stream" });
|
|
124
|
-
// One byte now, the flood only once every stream has had its byte: a stream's DATA head-of-line blocks a later
|
|
125
|
-
// stream's HEADERS on the one h2 session, which starves the last of eight for seconds on a loaded machine.
|
|
126
|
-
response.write(FLOOD_CHUNK.subarray(0, 1));
|
|
127
|
-
await floodReleased.opened;
|
|
128
|
-
const pump = (): void => {
|
|
129
|
-
while (response.write(FLOOD_CHUNK) && !response.writableEnded) {
|
|
130
|
-
// Writes until the queue backs up; `pressured` then says the shutdown has something to land on.
|
|
131
|
-
}
|
|
132
|
-
pressured.open();
|
|
133
|
-
};
|
|
134
|
-
response.on("drain", pump);
|
|
135
|
-
pump();
|
|
136
|
-
},
|
|
137
|
-
};
|
|
138
|
-
|
|
139
|
-
const target = createServer((request: IncomingMessage, response: ServerResponse) => {
|
|
140
|
-
const route = routes[(request.url ?? "").split("?")[0] ?? ""];
|
|
141
|
-
if (route === undefined) {
|
|
142
|
-
response.writeHead(404, { "content-type": "text/plain" });
|
|
143
|
-
response.end("no such route");
|
|
144
|
-
return;
|
|
145
|
-
}
|
|
146
|
-
void route(request, response);
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
const cancelled = gate<string>();
|
|
150
|
-
|
|
151
|
-
// Hand-written HTTP/1.1 upgrade carrying the 101 head plus the raw bytes after it. `/refuse` answers instead of
|
|
152
|
-
// upgrading.
|
|
153
|
-
target.on("upgrade", (request: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
154
|
-
if ((request.url ?? "") === "/refuse") {
|
|
155
|
-
socket.end("HTTP/1.1 404 Not Found\r\nContent-Type: text/plain\r\nContent-Length: 7\r\n\r\nno dice");
|
|
156
|
-
return;
|
|
157
|
-
}
|
|
158
|
-
socket.write(
|
|
159
|
-
[
|
|
160
|
-
"HTTP/1.1 101 Switching Protocols",
|
|
161
|
-
"Upgrade: websocket",
|
|
162
|
-
"Connection: Upgrade",
|
|
163
|
-
// A value only this server can produce; proves the head travelled rather than being reconstructed.
|
|
164
|
-
`Sec-WebSocket-Accept: ${digest(Buffer.from(String(request.headers["sec-websocket-key"])))}`,
|
|
165
|
-
`X-Seen-Host: ${String(request.headers.host)}`,
|
|
166
|
-
`X-Seen-Path: ${String(request.url)}`,
|
|
167
|
-
"",
|
|
168
|
-
"",
|
|
169
|
-
].join("\r\n"),
|
|
170
|
-
);
|
|
171
|
-
if (head.length > 0) {
|
|
172
|
-
socket.write(head);
|
|
173
|
-
}
|
|
174
|
-
socket.on("data", (chunk: Buffer) => void socket.write(Buffer.concat([Buffer.from("echo:"), chunk])));
|
|
175
|
-
// A far-end FIN must arrive here or the close handshake never completes.
|
|
176
|
-
socket.on("end", () => void socket.end("bye"));
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// One session; every request is routed through it.
|
|
180
|
-
const front = async (
|
|
181
|
-
targetPort: number,
|
|
182
|
-
): Promise<{ readonly server: Server; readonly poison: (bytes: Buffer) => void; readonly close: () => void }> => {
|
|
183
|
-
const [edgeSide, daemonSide] = duplexPair();
|
|
184
|
-
const daemon = await serveIngressSession(daemonSide, { targetPort });
|
|
185
|
-
const session = await openIngressSession(edgeSide);
|
|
186
|
-
const server = createServer((request, response) => {
|
|
187
|
-
void session.forwardRequest(request, response).catch(() => {
|
|
188
|
-
if (!response.headersSent) {
|
|
189
|
-
response.writeHead(502, { "content-type": "application/json" });
|
|
190
|
-
response.end(JSON.stringify({ error: "sandbox unreachable" }));
|
|
191
|
-
return;
|
|
192
|
-
}
|
|
193
|
-
response.destroy();
|
|
194
|
-
});
|
|
195
|
-
});
|
|
196
|
-
server.on("upgrade", (request, socket, head) => {
|
|
197
|
-
void session.forwardUpgrade(request, socket, head).catch(() => {
|
|
198
|
-
socket.end("HTTP/1.1 502 Bad Gateway\r\nContent-Length: 0\r\nConnection: close\r\n\r\n");
|
|
199
|
-
});
|
|
200
|
-
});
|
|
201
|
-
return {
|
|
202
|
-
server,
|
|
203
|
-
// Writes garbage onto the wire the edge reads, simulating a wedged or corrupted peer.
|
|
204
|
-
poison: (bytes: Buffer) => void daemonSide.write(bytes),
|
|
205
|
-
close: () => {
|
|
206
|
-
session.close();
|
|
207
|
-
daemon.close();
|
|
208
|
-
server.close();
|
|
209
|
-
},
|
|
210
|
-
};
|
|
211
|
-
};
|
|
212
|
-
|
|
213
|
-
let edge: Awaited<ReturnType<typeof front>>;
|
|
214
|
-
let edgePort = 0;
|
|
215
|
-
|
|
216
|
-
// Node-internal assertion failures surface only as an uncaughtException with none of our frames on the stack; caught
|
|
217
|
-
// here or not at all.
|
|
218
|
-
const uncaught: string[] = [];
|
|
219
|
-
|
|
220
|
-
beforeAll(async () => {
|
|
221
|
-
process.on("uncaughtException", (error: NodeJS.ErrnoException) => void uncaught.push(error.code ?? error.message));
|
|
222
|
-
const targetPort = await listen(target);
|
|
223
|
-
edge = await front(targetPort);
|
|
224
|
-
edgePort = await listen(edge.server);
|
|
225
|
-
});
|
|
226
|
-
|
|
227
|
-
afterAll(() => {
|
|
228
|
-
edge.close();
|
|
229
|
-
target.close();
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
const call = (
|
|
233
|
-
path: string,
|
|
234
|
-
options: { readonly method?: string; readonly body?: Buffer; readonly headers?: Record<string, string> } = {},
|
|
235
|
-
): Promise<{ status: number; headers: NodeJS.Dict<string | string[]>; body: Buffer }> =>
|
|
236
|
-
new Promise((resolve, reject) => {
|
|
237
|
-
const request = h1Request(
|
|
238
|
-
{ host: "127.0.0.1", port: edgePort, path, method: options.method ?? "GET", headers: { host: HOST, ...options.headers } },
|
|
239
|
-
(response) => {
|
|
240
|
-
void bodyOf(response).then((body) => resolve({ status: response.statusCode ?? 0, headers: response.headers, body }));
|
|
241
|
-
},
|
|
242
|
-
);
|
|
243
|
-
request.on("error", reject);
|
|
244
|
-
request.end(options.body);
|
|
245
|
-
});
|
|
246
|
-
|
|
247
|
-
test("a request round-trips with its authority, path and method, and no hop-by-hop header crosses", async () => {
|
|
248
|
-
const answer = await call("/seen?q=1", {
|
|
249
|
-
method: "PUT",
|
|
250
|
-
// Three headers that must not cross a hop, plus two that must survive it.
|
|
251
|
-
headers: { "x-custom": "kept", "x-forwarded-proto": "https", connection: "close", upgrade: "h2c", "keep-alive": "timeout=99" },
|
|
252
|
-
body: Buffer.from("hi"),
|
|
253
|
-
});
|
|
254
|
-
|
|
255
|
-
expect(answer.status).toBe(201);
|
|
256
|
-
expect(answer.headers["x-target"]).toBe("yes");
|
|
257
|
-
const seen = JSON.parse(answer.body.toString("utf8")) as Seen;
|
|
258
|
-
expect(seen).toMatchObject({ method: "PUT", url: "/seen?q=1", host: HOST });
|
|
259
|
-
expect(seen.headerNames).toContain("x-custom");
|
|
260
|
-
expect(seen.headerNames).toContain("x-forwarded-proto");
|
|
261
|
-
expect(seen.connection).toBe("keep-alive");
|
|
262
|
-
expect(seen.headerNames).not.toContain("upgrade");
|
|
263
|
-
expect(seen.headerNames).not.toContain("keep-alive");
|
|
264
|
-
expect(seen.headerNames).not.toContain("transfer-encoding");
|
|
265
|
-
});
|
|
266
|
-
|
|
267
|
-
test("a multi-megabyte body survives in both directions, byte for byte", async () => {
|
|
268
|
-
const payload = randomBytes(4 * 1024 * 1024);
|
|
269
|
-
const answer = await call("/echo", { method: "POST", body: payload });
|
|
270
|
-
|
|
271
|
-
expect(answer.status).toBe(200);
|
|
272
|
-
expect(answer.headers["x-sha256"]).toBe(digest(payload));
|
|
273
|
-
expect(digest(answer.body)).toBe(digest(payload));
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test("a response is streamed, not buffered: the client reads chunk one before the target writes chunk two", async () => {
|
|
277
|
-
const chunks: string[] = [];
|
|
278
|
-
const done = new Promise<void>((resolve, reject) => {
|
|
279
|
-
const request = h1Request({ host: "127.0.0.1", port: edgePort, path: "/drip", headers: { host: HOST } }, (response) => {
|
|
280
|
-
response.on("data", (chunk: Buffer) => {
|
|
281
|
-
chunks.push(chunk.toString("utf8"));
|
|
282
|
-
firstChunk.open();
|
|
283
|
-
});
|
|
284
|
-
response.on("end", resolve);
|
|
285
|
-
response.on("error", reject);
|
|
286
|
-
});
|
|
287
|
-
request.on("error", reject);
|
|
288
|
-
request.end();
|
|
289
|
-
});
|
|
290
|
-
await done;
|
|
291
|
-
await secondSent.opened;
|
|
292
|
-
|
|
293
|
-
expect(chunks.join("")).toBe("onetwo");
|
|
294
|
-
expect(chunks.length).toBeGreaterThan(1);
|
|
295
|
-
});
|
|
296
|
-
|
|
297
|
-
test("a reader that stops reading holds its own stream, not the session: a sibling request still answers", async () => {
|
|
298
|
-
const stalled = await new Promise<IncomingMessage>((resolve, reject) => {
|
|
299
|
-
const request = h1Request({ host: "127.0.0.1", port: edgePort, path: "/stall", headers: { host: HOST } }, (response) => {
|
|
300
|
-
// Reads one chunk to prove the stream flows, then stops for good, as a backgrounded tab or a slow link would.
|
|
301
|
-
response.once("data", () => {
|
|
302
|
-
response.pause();
|
|
303
|
-
resolve(response);
|
|
304
|
-
});
|
|
305
|
-
});
|
|
306
|
-
request.on("error", reject);
|
|
307
|
-
request.end();
|
|
308
|
-
});
|
|
309
|
-
try {
|
|
310
|
-
const answer = await call("/seen");
|
|
311
|
-
expect(answer.status).toBe(201);
|
|
312
|
-
} finally {
|
|
313
|
-
stalled.destroy();
|
|
314
|
-
}
|
|
315
|
-
});
|
|
316
|
-
|
|
317
|
-
// Two duplex pairs joined by a relay whose edge-to-daemon half can be held: with nothing coming back, the daemon can
|
|
318
|
-
// send only the window it was granted up front, so the bytes that arrive measure that window without a clock.
|
|
319
|
-
const heldLink = (): { readonly edgeSide: Duplex; readonly daemonSide: Duplex; readonly hold: () => void } => {
|
|
320
|
-
const [edgeSide, edgeRelay] = duplexPair();
|
|
321
|
-
const [relayDaemon, daemonSide] = duplexPair();
|
|
322
|
-
let held = false;
|
|
323
|
-
edgeRelay.on("data", (chunk: Buffer) => {
|
|
324
|
-
if (!held) {
|
|
325
|
-
relayDaemon.write(chunk);
|
|
326
|
-
}
|
|
327
|
-
});
|
|
328
|
-
relayDaemon.on("data", (chunk: Buffer) => void edgeRelay.write(chunk));
|
|
329
|
-
return { edgeSide, daemonSide, hold: () => void (held = true) };
|
|
330
|
-
};
|
|
331
|
-
|
|
332
|
-
test("one round trip carries a whole stream window, not the protocol's 64 KB shared by every stream", async () => {
|
|
333
|
-
const link = heldLink();
|
|
334
|
-
const daemon = await serveIngressSession(link.daemonSide, { targetPort: await listen(target) });
|
|
335
|
-
const session = await openIngressSession(link.edgeSide);
|
|
336
|
-
const server = createServer((request, response) => void session.forwardRequest(request, response).catch(() => response.destroy()));
|
|
337
|
-
const port = await listen(server);
|
|
338
|
-
let received = 0;
|
|
339
|
-
const request = h1Request({ host: "127.0.0.1", port, path: "/stall", headers: { host: HOST } }, (response) => {
|
|
340
|
-
// From here the request has reached the daemon; every WINDOW_UPDATE the edge sends from now on is swallowed.
|
|
341
|
-
link.hold();
|
|
342
|
-
response.on("data", (chunk: Buffer) => void (received += chunk.length));
|
|
343
|
-
});
|
|
344
|
-
request.on("error", () => undefined);
|
|
345
|
-
request.end();
|
|
346
|
-
try {
|
|
347
|
-
await waitFor(() => expect(received).toBeGreaterThanOrEqual(INITIAL_WINDOW_SIZE / 2), { timeout: 10_000 });
|
|
348
|
-
} finally {
|
|
349
|
-
request.destroy();
|
|
350
|
-
session.close();
|
|
351
|
-
daemon.close();
|
|
352
|
-
server.close();
|
|
353
|
-
}
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
test("a request body is streamed: the target reads the first chunk before the client sends the rest", async () => {
|
|
357
|
-
const answered = new Promise<string>((resolve, reject) => {
|
|
358
|
-
const request = h1Request(
|
|
359
|
-
{ host: "127.0.0.1", port: edgePort, path: "/slurp", method: "POST", headers: { host: HOST } },
|
|
360
|
-
(response) => void bodyOf(response).then((body) => resolve(body.toString("utf8"))),
|
|
361
|
-
);
|
|
362
|
-
request.on("error", reject);
|
|
363
|
-
request.write("one");
|
|
364
|
-
void uploadStarted.opened.then(() => request.end("two"));
|
|
365
|
-
});
|
|
366
|
-
|
|
367
|
-
expect(await answered).toBe("onetwo");
|
|
368
|
-
});
|
|
369
|
-
|
|
370
|
-
test("an upgrade splices raw bytes, carries the far end's own handshake head, and passes a half-close through", async () => {
|
|
371
|
-
const key = "dGhlIHNhbXBsZSBub25jZQ==";
|
|
372
|
-
const upgraded = await new Promise<{ status: number; headers: NodeJS.Dict<string | string[]>; socket: Duplex }>((resolve, reject) => {
|
|
373
|
-
const request = h1Request({
|
|
374
|
-
host: "127.0.0.1",
|
|
375
|
-
port: edgePort,
|
|
376
|
-
path: "/socket",
|
|
377
|
-
headers: { host: HOST, connection: "Upgrade", upgrade: "websocket", "sec-websocket-key": key },
|
|
378
|
-
// Its own connection, as a browser's WebSocket always is; bun's h1 client never reports a 101 on a pooled socket.
|
|
379
|
-
agent: false,
|
|
380
|
-
});
|
|
381
|
-
request.on("upgrade", (response, socket, head) => {
|
|
382
|
-
expect(head.length).toBe(0);
|
|
383
|
-
resolve({ status: response.statusCode ?? 0, headers: response.headers, socket });
|
|
384
|
-
});
|
|
385
|
-
request.on("response", (response) => reject(new Error(`the upgrade was answered with ${String(response.statusCode)}`)));
|
|
386
|
-
request.on("error", reject);
|
|
387
|
-
request.end();
|
|
388
|
-
});
|
|
389
|
-
|
|
390
|
-
expect(upgraded.status).toBe(101);
|
|
391
|
-
expect(upgraded.headers["sec-websocket-accept"]).toBe(digest(Buffer.from(key)));
|
|
392
|
-
expect(upgraded.headers["x-seen-host"]).toBe(HOST);
|
|
393
|
-
expect(upgraded.headers["x-seen-path"]).toBe("/socket");
|
|
394
|
-
|
|
395
|
-
const spliced = new Promise<string>((resolve) => {
|
|
396
|
-
let read = "";
|
|
397
|
-
upgraded.socket.on("data", (chunk: Buffer) => {
|
|
398
|
-
read += chunk.toString("utf8");
|
|
399
|
-
});
|
|
400
|
-
upgraded.socket.on("end", () => resolve(read));
|
|
401
|
-
});
|
|
402
|
-
upgraded.socket.write("abc");
|
|
403
|
-
upgraded.socket.end();
|
|
404
|
-
|
|
405
|
-
expect(await spliced).toBe("echo:abcbye");
|
|
406
|
-
});
|
|
407
|
-
|
|
408
|
-
test("a local server that declines to upgrade answers the browser itself", async () => {
|
|
409
|
-
const declined = await new Promise<{ status: number; body: Buffer }>((resolve, reject) => {
|
|
410
|
-
const request = h1Request({
|
|
411
|
-
host: "127.0.0.1",
|
|
412
|
-
port: edgePort,
|
|
413
|
-
path: "/refuse",
|
|
414
|
-
headers: { host: HOST, connection: "Upgrade", upgrade: "websocket", "sec-websocket-key": "x" },
|
|
415
|
-
agent: false,
|
|
416
|
-
});
|
|
417
|
-
request.on("upgrade", () => reject(new Error("the target refused, so nothing should have been spliced")));
|
|
418
|
-
request.on("response", (response) => void bodyOf(response).then((body) => resolve({ status: response.statusCode ?? 0, body })));
|
|
419
|
-
request.on("error", reject);
|
|
420
|
-
request.end();
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
expect(declined.status).toBe(404);
|
|
424
|
-
expect(declined.body.toString("utf8")).toBe("no dice");
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
test("a browser that gives up cancels the stream all the way to the target", async () => {
|
|
428
|
-
const request = h1Request({ host: "127.0.0.1", port: edgePort, path: "/hangup", headers: { host: HOST } });
|
|
429
|
-
await new Promise<void>((resolve, reject) => {
|
|
430
|
-
request.on("response", (response) => {
|
|
431
|
-
response.once("data", () => resolve());
|
|
432
|
-
response.on("error", () => resolve());
|
|
433
|
-
});
|
|
434
|
-
request.on("error", reject);
|
|
435
|
-
request.end();
|
|
436
|
-
});
|
|
437
|
-
request.destroy();
|
|
438
|
-
|
|
439
|
-
await expect(cancelled.opened).resolves.toBe("aborted");
|
|
440
|
-
});
|
|
441
|
-
|
|
442
|
-
test("a tunnel whose target is not listening fails the exchange rather than answering for it", async () => {
|
|
443
|
-
const dead = createServer();
|
|
444
|
-
const deadPort = await listen(dead);
|
|
445
|
-
await new Promise<void>((resolve) => void dead.close(() => resolve()));
|
|
446
|
-
|
|
447
|
-
const [edgeSide, daemonSide] = duplexPair();
|
|
448
|
-
const daemon = await serveIngressSession(daemonSide, { targetPort: deadPort });
|
|
449
|
-
const session = await openIngressSession(edgeSide);
|
|
450
|
-
// True if a status was already written before the rejection handler runs.
|
|
451
|
-
const said = gate<boolean>();
|
|
452
|
-
const server = createServer((request, response) => {
|
|
453
|
-
void session.forwardRequest(request, response).catch(() => {
|
|
454
|
-
said.open(response.headersSent);
|
|
455
|
-
response.writeHead(502, { "content-type": "application/json" });
|
|
456
|
-
response.end(JSON.stringify({ error: "sandbox unreachable" }));
|
|
457
|
-
});
|
|
458
|
-
});
|
|
459
|
-
const port = await listen(server);
|
|
460
|
-
|
|
461
|
-
const answer = await new Promise<number>((resolve, reject) => {
|
|
462
|
-
const request = h1Request({ host: "127.0.0.1", port, path: "/anything", headers: { host: HOST } }, (response) =>
|
|
463
|
-
resolve(response.statusCode ?? 0),
|
|
464
|
-
);
|
|
465
|
-
request.on("error", reject);
|
|
466
|
-
request.end();
|
|
467
|
-
});
|
|
468
|
-
|
|
469
|
-
expect(answer).toBe(502);
|
|
470
|
-
await expect(said.opened).resolves.toBe(false);
|
|
471
|
-
session.close();
|
|
472
|
-
daemon.close();
|
|
473
|
-
server.close();
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
test("a shutdown landing on top of pending writes neither crashes nor wedges the session", async () => {
|
|
477
|
-
const own = await front((target.address() as AddressInfo).port);
|
|
478
|
-
const ownPort = await listen(own.server);
|
|
479
|
-
|
|
480
|
-
// All eight stay live: resetting some first would drain the pending-write pressure the shutdown needs to land on.
|
|
481
|
-
const flooding = Array.from({ length: 8 }, () => {
|
|
482
|
-
const opened = gate();
|
|
483
|
-
const request = h1Request({ host: "127.0.0.1", port: ownPort, path: "/flood", headers: { host: HOST } }, (response) => {
|
|
484
|
-
response.once("data", () => opened.open());
|
|
485
|
-
});
|
|
486
|
-
request.on("error", () => opened.open());
|
|
487
|
-
request.end();
|
|
488
|
-
return opened.opened;
|
|
489
|
-
});
|
|
490
|
-
|
|
491
|
-
// Every stream open before any of them floods, and every one of them backed up before the shutdown lands.
|
|
492
|
-
await Promise.all(flooding);
|
|
493
|
-
floodReleased.open();
|
|
494
|
-
await Promise.all(floodPressure);
|
|
495
|
-
|
|
496
|
-
own.close();
|
|
497
|
-
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
498
|
-
own.server.close();
|
|
499
|
-
|
|
500
|
-
// If the shutdown frame can't be written, this hangs; the suite's timeout is the failure signal, not an assertion.
|
|
501
|
-
expect(uncaught).toStrictEqual([]);
|
|
502
|
-
const after = await call("/seen");
|
|
503
|
-
expect(after.status).toBe(201);
|
|
504
|
-
});
|
|
505
|
-
|
|
506
|
-
test("a poisoned session dies alone and leaves another tunnel serving", async () => {
|
|
507
|
-
const targetPort = (target.address() as AddressInfo).port;
|
|
508
|
-
const poisoned = await front(targetPort);
|
|
509
|
-
const poisonedPort = await listen(poisoned.server);
|
|
510
|
-
const healthy = await front(targetPort);
|
|
511
|
-
const healthyPort = await listen(healthy.server);
|
|
512
|
-
|
|
513
|
-
const through = (port: number): Promise<number> =>
|
|
514
|
-
new Promise((resolve, reject) => {
|
|
515
|
-
const request = h1Request({ host: "127.0.0.1", port, path: "/seen", headers: { host: HOST } }, (response) => {
|
|
516
|
-
void bodyOf(response).then(() => resolve(response.statusCode ?? 0));
|
|
517
|
-
});
|
|
518
|
-
request.on("error", reject);
|
|
519
|
-
request.end();
|
|
520
|
-
});
|
|
521
|
-
|
|
522
|
-
expect(await through(poisonedPort)).toBe(201);
|
|
523
|
-
expect(await through(healthyPort)).toBe(201);
|
|
524
|
-
|
|
525
|
-
// Not a valid h2 preface; the session must fail rather than parse it.
|
|
526
|
-
poisoned.poison(Buffer.from("this is not a PRI * HTTP/2.0 preface, nor anything else nghttp2 accepts"));
|
|
527
|
-
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
528
|
-
|
|
529
|
-
expect(await through(poisonedPort)).toBe(502);
|
|
530
|
-
expect(uncaught).toStrictEqual([]);
|
|
531
|
-
expect(await through(healthyPort)).toBe(201);
|
|
532
|
-
|
|
533
|
-
poisoned.close();
|
|
534
|
-
healthy.close();
|
|
535
|
-
});
|