@dev.fast/whiteboard 0.3.5-preview.20261021.131 → 0.3.5-preview.20261021.133
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{account-alias-CisiYUZT.js → account-alias-BRRpaO9U.js} +1 -1
- package/dist/{agent-cli-TJ7_5_B3.js → agent-cli-CFX3bHJV.js} +2 -2
- package/dist/agent-cli-D2P4EoAk.js +2 -0
- package/dist/build-info.json +1 -1
- package/dist/{cli-runner-cRY5NOno.js → cli-runner-nE62FMPA.js} +7 -7
- package/dist/cli.js +1 -1
- package/dist/{fuzzy-match-D_x3wumC.js → fuzzy-match-SsirV9Ap.js} +1 -1
- package/dist/{headless-host-CEb5d-A0.js → headless-host-CG1XSd5n.js} +39 -5
- package/dist/{headless-host-BEe-aFtB.js → headless-host-DN6rrMrK.js} +1 -1
- package/dist/{request-origin-icXPv6jh.js → request-origin-BxaWyOpO.js} +4 -0
- package/dist/{review-telemetry-AZ0imsmJ.js → review-telemetry-Vnb2zKyn.js} +1 -1
- package/dist/server/desktop-host.js +115 -19
- package/package.json +2 -2
- package/src/review-stack.ts +58 -8
- package/src/server/desktop-host.ts +14 -1
- package/src/server/desktop-server.ts +12 -17
- package/src/server/hono-http.ts +31 -5
- package/src/server/loopback-server.ts +146 -0
- package/src/ui-telemetry-events.ts +4 -0
- package/dist/agent-cli-BglNRDZN.js +0 -2
|
@@ -4,7 +4,7 @@ import { C as disableTraceMachine, E as traceMachineStatus, G as listTraceReposi
|
|
|
4
4
|
import { I as writeFileAtomicAsync, L as writePrivateJsonAtomic, N as devReviewHome, j as withFileLock } from "./s3-config-CjcLyFV5.js";
|
|
5
5
|
import { i as readReviewPackageVersion } from "./package-paths-BkeQsdaw.js";
|
|
6
6
|
import { n as isFile, t as isDirectory } from "./fs-utils-BMPLt0cr.js";
|
|
7
|
-
import { c as findExecutable, s as executableCommand } from "./fuzzy-match-
|
|
7
|
+
import { c as findExecutable, s as executableCommand } from "./fuzzy-match-SsirV9Ap.js";
|
|
8
8
|
import { createHash } from "node:crypto";
|
|
9
9
|
import { constants } from "node:fs";
|
|
10
10
|
import { access, chmod, lstat, mkdir, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
@@ -4,8 +4,8 @@ import { a as toolResultText, i as connectReviewInstance, s as ReviewApiError }
|
|
|
4
4
|
import { N as devReviewHome } from "./s3-config-CjcLyFV5.js";
|
|
5
5
|
import { n as readBuildCommit } from "./package-paths-BkeQsdaw.js";
|
|
6
6
|
import { b as selectReviewInstance, u as REVIEW_INSTANCE_ENV } from "./server-discovery-DeVTdcqK.js";
|
|
7
|
-
import { n as reviewSessionAgent } from "./review-telemetry-
|
|
8
|
-
import { a as REVIEW_VIA_HEADER, i as REVIEW_THREAD_HEADER, n as REVIEW_AGENT_HEADER, t as ASK_THREAD_ENV } from "./request-origin-
|
|
7
|
+
import { n as reviewSessionAgent } from "./review-telemetry-Vnb2zKyn.js";
|
|
8
|
+
import { a as REVIEW_VIA_HEADER, i as REVIEW_THREAD_HEADER, n as REVIEW_AGENT_HEADER, t as ASK_THREAD_ENV } from "./request-origin-BxaWyOpO.js";
|
|
9
9
|
import { i as publicTool, n as RECOVERY, r as callPublicTool, t as toolFailure } from "./tool-failure-CQhyLht3.js";
|
|
10
10
|
import { existsSync } from "node:fs";
|
|
11
11
|
import path from "node:path";
|
package/dist/build-info.json
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":"0.3.5-preview.20261021.
|
|
1
|
+
{"version":"0.3.5-preview.20261021.133","commit":"6c2c2de6384ef9b8d4f9d6a3c63d9cbbcee0ebe8","dirty":true,"builtAt":"2026-10-21T19:05:08.496Z"}
|
|
@@ -8,11 +8,11 @@ import { C as s3ProfileSchema, E as writeTraceConfigFile, N as devReviewHome, P
|
|
|
8
8
|
import { t as errorMessage } from "./error-message-OtiDonty.js";
|
|
9
9
|
import { i as readReviewPackageVersion, r as readBuildInfo, t as findReviewPackageRoot } from "./package-paths-BkeQsdaw.js";
|
|
10
10
|
import { a as readReviewServerHealth, b as selectReviewInstance, c as reviewServerStateDir, d as ReviewInstanceUnavailableError, f as clearDefaultReviewInstance, g as requireHealthyReviewDesktop, h as readDefaultReviewInstance, i as readReviewServerDiscovery, l as serverNotReady, m as healthyReviewInstance, p as desktopUsesHome, t as DESKTOP_TAKEOVER_SIGNAL, u as REVIEW_INSTANCE_ENV, x as writeDefaultReviewInstance, y as reviewInstanceUnavailable } from "./server-discovery-DeVTdcqK.js";
|
|
11
|
-
import { t as ReviewTelemetry } from "./review-telemetry-
|
|
12
|
-
import { t as fuzzyRank } from "./fuzzy-match-
|
|
13
|
-
import { _ as isInstallTarget, a as isOwnedShim, f as windowsInstallerCommand, g as ALL_INSTALL_TARGETS, h as detectConnectAgents, m as connectAgent, o as pathShimPath, p as scanLegacySkills, t as aliasInstallationToAccount, v as connectPrompts } from "./account-alias-
|
|
11
|
+
import { t as ReviewTelemetry } from "./review-telemetry-Vnb2zKyn.js";
|
|
12
|
+
import { t as fuzzyRank } from "./fuzzy-match-SsirV9Ap.js";
|
|
13
|
+
import { _ as isInstallTarget, a as isOwnedShim, f as windowsInstallerCommand, g as ALL_INSTALL_TARGETS, h as detectConnectAgents, m as connectAgent, o as pathShimPath, p as scanLegacySkills, t as aliasInstallationToAccount, v as connectPrompts } from "./account-alias-BRRpaO9U.js";
|
|
14
14
|
import { n as focusReviewDesktop, r as runReviewAppLaunch } from "./review-app-launcher-DZvG5ZAh.js";
|
|
15
|
-
import { t as reviewAgentCliHelp } from "./agent-cli-
|
|
15
|
+
import { t as reviewAgentCliHelp } from "./agent-cli-CFX3bHJV.js";
|
|
16
16
|
import { existsSync, mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
17
17
|
import { realpath } from "node:fs/promises";
|
|
18
18
|
import path from "node:path";
|
|
@@ -4355,7 +4355,7 @@ async function runReviewCli(input) {
|
|
|
4355
4355
|
process.once("SIGINT", stop);
|
|
4356
4356
|
process.once("SIGTERM", stop);
|
|
4357
4357
|
process.once(DESKTOP_TAKEOVER_SIGNAL, takeover);
|
|
4358
|
-
const { HeadlessServerBusyError, runHeadlessServer } = await import("./headless-host-
|
|
4358
|
+
const { HeadlessServerBusyError, runHeadlessServer } = await import("./headless-host-DN6rrMrK.js");
|
|
4359
4359
|
try {
|
|
4360
4360
|
await runHeadlessServer({
|
|
4361
4361
|
stateDir,
|
|
@@ -4420,7 +4420,7 @@ async function runReviewCli(input) {
|
|
|
4420
4420
|
if (!existsSync(path.join(stateDir, "review-api.db"))) throw new Error(`No saved reviews in ${stateDir}, so there is no server id to reset. Check --state-dir.`);
|
|
4421
4421
|
const inUse = `A Whiteboard server is using ${stateDir}. Stop it first, then run whiteboard server reset-id again.`;
|
|
4422
4422
|
if (await desktopUsesHome(stateDir, env)) return refuse(inUse, options.json);
|
|
4423
|
-
const [{ openReviewProfile }, { withHeadlessServerLock }] = await Promise.all([import("./profile-Dbyidrcr.js"), import("./headless-host-
|
|
4423
|
+
const [{ openReviewProfile }, { withHeadlessServerLock }] = await Promise.all([import("./profile-Dbyidrcr.js"), import("./headless-host-DN6rrMrK.js")]);
|
|
4424
4424
|
const reset = await withHeadlessServerLock(await realpath(stateDir), async () => {
|
|
4425
4425
|
const local = await openReviewProfile(stateDir, { manageWorkspaces: false });
|
|
4426
4426
|
try {
|
|
@@ -4794,7 +4794,7 @@ async function runReviewCli(input) {
|
|
|
4794
4794
|
return commands;
|
|
4795
4795
|
} });
|
|
4796
4796
|
for (const [name, description] of [["api", "Call a JSON review authoring tool on the running server"], ["mcp", "Serve the JSON review authoring tools over stdio MCP"]]) configureOutput(program.command(name).description(description).argument("[args...]", "tool name and JSON input").allowUnknownOption().allowExcessArguments().helpOption(false).passThroughOptions().addHelpText("after", `\n${reviewAgentCliHelp}`), "plain").action(async (args) => {
|
|
4797
|
-
const { runReviewAgentCli } = await import("./agent-cli-
|
|
4797
|
+
const { runReviewAgentCli } = await import("./agent-cli-D2P4EoAk.js");
|
|
4798
4798
|
state.exitCode = await runReviewAgentCli({
|
|
4799
4799
|
...input,
|
|
4800
4800
|
env: authoringEnv(),
|
package/dist/cli.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "./review-home-paths-BwH08ihj.js";
|
|
2
2
|
import { N as devReviewHome } from "./s3-config-CjcLyFV5.js";
|
|
3
3
|
import { n as askAgentIds } from "./thread-state-BPpvKFwQ.js";
|
|
4
|
-
import { t as ASK_THREAD_ENV } from "./request-origin-
|
|
4
|
+
import { t as ASK_THREAD_ENV } from "./request-origin-BxaWyOpO.js";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
6
|
import { constants } from "node:fs";
|
|
7
7
|
import { access, chmod, mkdir, readFile, rename, stat, writeFile } from "node:fs/promises";
|
|
@@ -13,8 +13,8 @@ import { i as readReviewPackageVersion, n as readBuildCommit, t as findReviewPac
|
|
|
13
13
|
import { _ as reviewInstanceIdentity, n as desktopOwnsHome, o as reviewServerDiscoveryPath, p as desktopUsesHome, r as headlessServerLockPath, t as DESKTOP_TAKEOVER_SIGNAL } from "./server-discovery-DeVTdcqK.js";
|
|
14
14
|
import { c as askPicksSchema, d as selectionMarkdown, l as askQuestionSchema, n as askAgentIds, r as askChoiceKinds, s as askPermissionsSchema, t as applyAskChange, u as AgentSelectionSchema } from "./thread-state-BPpvKFwQ.js";
|
|
15
15
|
import { t as openReviewProfile } from "./profile-duqp5uPa.js";
|
|
16
|
-
import { c as REVIEW_APP_SESSION_ID_HEADER, d as sanitizeUiTelemetryEvent, f as cleanTelemetryText, o as reviewRequestOrigin, r as REVIEW_PERSON_HEADER, s as BUNDLE_FRAME_PATTERN, t as ASK_THREAD_ENV, u as isReportableCleanedMessage } from "./request-origin-
|
|
17
|
-
import { a as defaultAskPermissions, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-
|
|
16
|
+
import { c as REVIEW_APP_SESSION_ID_HEADER, d as sanitizeUiTelemetryEvent, f as cleanTelemetryText, o as reviewRequestOrigin, r as REVIEW_PERSON_HEADER, s as BUNDLE_FRAME_PATTERN, t as ASK_THREAD_ENV, u as isReportableCleanedMessage } from "./request-origin-BxaWyOpO.js";
|
|
17
|
+
import { a as defaultAskPermissions, i as askAgents, l as launchAskAgent, n as askAgentSettings, o as detectAskAgents, r as askAgentTakesMcp, t as fuzzyRank } from "./fuzzy-match-SsirV9Ap.js";
|
|
18
18
|
import { a as inspectQuerySchema, i as renderInstructions, o as queryAnchor, r as instructionsQuerySchema, s as readQuerySchemas, t as authoringTools } from "./authoring-tools-BCquUsB3.js";
|
|
19
19
|
import { a as headlessServerOwner, c as stopBackgroundServer, o as recordedBackgroundServer } from "./background-server-Xom8cA_u.js";
|
|
20
20
|
import crypto$1, { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
@@ -28,11 +28,11 @@ import { promisify } from "node:util";
|
|
|
28
28
|
import { setTimeout as setTimeout$1 } from "node:timers/promises";
|
|
29
29
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
30
30
|
import { Writable } from "node:stream";
|
|
31
|
-
import { createServer } from "node:http";
|
|
32
31
|
import { PROTOCOL_VERSION, RequestError, client, methods } from "@agentclientprotocol/sdk";
|
|
33
32
|
import { Hono } from "hono";
|
|
34
33
|
import { stringify } from "smol-toml";
|
|
35
34
|
import { getRequestListener } from "@hono/node-server";
|
|
35
|
+
import { createServer } from "node:http";
|
|
36
36
|
import { streamSSE } from "hono/streaming";
|
|
37
37
|
import { once } from "node:events";
|
|
38
38
|
//#region src/ask/protocol.ts
|
|
@@ -1857,8 +1857,21 @@ var ReviewServerError = class extends Error {
|
|
|
1857
1857
|
};
|
|
1858
1858
|
//#endregion
|
|
1859
1859
|
//#region src/server/hono-http.ts
|
|
1860
|
+
const H2_FORBIDDEN_HEADERS = [
|
|
1861
|
+
"connection",
|
|
1862
|
+
"keep-alive",
|
|
1863
|
+
"proxy-connection",
|
|
1864
|
+
"transfer-encoding",
|
|
1865
|
+
"upgrade"
|
|
1866
|
+
];
|
|
1860
1867
|
function createNodeRequestListener(app) {
|
|
1861
|
-
return getRequestListener(
|
|
1868
|
+
return getRequestListener(async (request, env) => {
|
|
1869
|
+
const response = await app.fetch(request, env);
|
|
1870
|
+
if (env.incoming.httpVersionMajor !== 2 || !H2_FORBIDDEN_HEADERS.some((name) => response.headers.has(name))) return response;
|
|
1871
|
+
const stripped = new Response(response.body, response);
|
|
1872
|
+
for (const name of H2_FORBIDDEN_HEADERS) stripped.headers.delete(name);
|
|
1873
|
+
return stripped;
|
|
1874
|
+
});
|
|
1862
1875
|
}
|
|
1863
1876
|
function jsonResponse(body, status, options = {}) {
|
|
1864
1877
|
const headers = new Headers({ "content-type": options.contentType ?? "application/json; charset=utf-8" });
|
|
@@ -2623,6 +2636,27 @@ async function resolveReviewStackLayers(subject, reviews, options = {}) {
|
|
|
2623
2636
|
};
|
|
2624
2637
|
});
|
|
2625
2638
|
}
|
|
2639
|
+
const STACKS_FRESH_MS = 6e4;
|
|
2640
|
+
const stackReads = /* @__PURE__ */ new WeakMap();
|
|
2641
|
+
function readGitHubStacks(runGitHubApi, host, repository, pullRequestNumber) {
|
|
2642
|
+
const reads = stackReads.get(runGitHubApi) ?? /* @__PURE__ */ new Map();
|
|
2643
|
+
stackReads.set(runGitHubApi, reads);
|
|
2644
|
+
const keyOf = (number) => `${host}/${repository}#${number}`;
|
|
2645
|
+
const cached = reads.get(keyOf(pullRequestNumber));
|
|
2646
|
+
if (cached && Date.now() - cached.at < STACKS_FRESH_MS) return cached.stacks;
|
|
2647
|
+
const read = {
|
|
2648
|
+
at: Date.now(),
|
|
2649
|
+
stacks: runGitHubApi(host, `repos/${repository}/stacks?pull_request=${pullRequestNumber}`).then((text) => GitHubStacksSchema.parse(JSON.parse(text)))
|
|
2650
|
+
};
|
|
2651
|
+
reads.set(keyOf(pullRequestNumber), read);
|
|
2652
|
+
read.stacks.then((stacks) => {
|
|
2653
|
+
const stack = stacks.find((candidate) => candidate.pull_requests.some((pr) => pr.number === pullRequestNumber));
|
|
2654
|
+
for (const pr of stack?.pull_requests ?? []) reads.set(keyOf(pr.number), read);
|
|
2655
|
+
}, () => {
|
|
2656
|
+
if (reads.get(keyOf(pullRequestNumber)) === read) reads.delete(keyOf(pullRequestNumber));
|
|
2657
|
+
});
|
|
2658
|
+
return read.stacks;
|
|
2659
|
+
}
|
|
2626
2660
|
async function gitHubStack(subject, reviews, runGitHubApi) {
|
|
2627
2661
|
const binding = subject.origin?.pullRequestUrl?.match(/^https:\/\/([a-z0-9.-]+)\/([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+)\/pull\/([1-9]\d*)$/);
|
|
2628
2662
|
if (!binding) return [];
|
|
@@ -2631,7 +2665,7 @@ async function gitHubStack(subject, reviews, runGitHubApi) {
|
|
|
2631
2665
|
const repoKey = `https://${host}/${owner}/${repository}`;
|
|
2632
2666
|
let stacks;
|
|
2633
2667
|
try {
|
|
2634
|
-
stacks =
|
|
2668
|
+
stacks = await readGitHubStacks(runGitHubApi, host, `${owner}/${repository}`, pullRequestNumber);
|
|
2635
2669
|
} catch {
|
|
2636
2670
|
return [];
|
|
2637
2671
|
}
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { i as withHeadlessServerLock, n as runHeadlessServer, r as takeOverServerHome, t as HeadlessServerBusyError } from "./headless-host-
|
|
1
|
+
import { i as withHeadlessServerLock, n as runHeadlessServer, r as takeOverServerHome, t as HeadlessServerBusyError } from "./headless-host-CG1XSd5n.js";
|
|
2
2
|
export { HeadlessServerBusyError, runHeadlessServer, takeOverServerHome, withHeadlessServerLock };
|
|
@@ -687,6 +687,10 @@ const UI_TELEMETRY_EVENTS = {
|
|
|
687
687
|
install_location: INSTALL_LOCATION
|
|
688
688
|
}
|
|
689
689
|
},
|
|
690
|
+
loopback_tls_fallback: {
|
|
691
|
+
event: "review_loopback_tls_fallback",
|
|
692
|
+
properties: { error_name: "enum_free_short" }
|
|
693
|
+
},
|
|
690
694
|
error_burst: {
|
|
691
695
|
event: "review_error_burst",
|
|
692
696
|
properties: {
|
|
@@ -3,7 +3,7 @@ import "./src-DYZLCUP_.js";
|
|
|
3
3
|
import "./review-home-paths-BwH08ihj.js";
|
|
4
4
|
import { F as writeFileAtomic, N as devReviewHome, j as withFileLock, k as processIsAlive } from "./s3-config-CjcLyFV5.js";
|
|
5
5
|
import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "./package-paths-BkeQsdaw.js";
|
|
6
|
-
import "./request-origin-
|
|
6
|
+
import "./request-origin-BxaWyOpO.js";
|
|
7
7
|
import { createHmac, randomUUID } from "node:crypto";
|
|
8
8
|
import { z } from "zod";
|
|
9
9
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
@@ -12,10 +12,10 @@ import { t as errorMessage } from "../error-message-OtiDonty.js";
|
|
|
12
12
|
import { i as readReviewPackageVersion, t as findReviewPackageRoot } from "../package-paths-BkeQsdaw.js";
|
|
13
13
|
import { _ as reviewInstanceIdentity, u as REVIEW_INSTANCE_ENV } from "../server-discovery-DeVTdcqK.js";
|
|
14
14
|
import { t as openReviewProfile } from "../profile-duqp5uPa.js";
|
|
15
|
-
import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-
|
|
16
|
-
import { l as REVIEW_TELEMETRY_TABS } from "../request-origin-
|
|
17
|
-
import { c as removeLegacyReviewSkills, d as skipCliInstall, h as detectConnectAgents, i as finishCliInstallUpdate, l as resetCliInstall, m as connectAgent, n as applyCliInstall, o as pathShimPath, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus } from "../account-alias-
|
|
18
|
-
import { C as saveDiffrSummarizer, E as cliAskTools, S as readDiffrConfig, T as testDiffrSummarizer, _ as DEFAULT_MAX_REQUEST_BYTES, a as drainServerCrashReport, b as GlobalReviewDesktopVerbRelay, c as answerErrorsAsJson, d as legacyModeSchema, f as captureSanitizedUiTelemetry, g as readBoundedRequestJson, h as createNodeRequestListener, l as createWhiteboardCore, m as recordClientError, o as installProcessErrorTelemetry, p as clientErrorsForSession, r as takeOverServerHome, s as serverCrashReportPath, u as serverJson, v as HttpJsonError, w as setDiffrConfigValue, x as migrateDiffrConfig, y as ReviewServerError } from "../headless-host-
|
|
15
|
+
import { r as reviewTelemetryChannel, t as ReviewTelemetry } from "../review-telemetry-Vnb2zKyn.js";
|
|
16
|
+
import { l as REVIEW_TELEMETRY_TABS } from "../request-origin-BxaWyOpO.js";
|
|
17
|
+
import { c as removeLegacyReviewSkills, d as skipCliInstall, h as detectConnectAgents, i as finishCliInstallUpdate, l as resetCliInstall, m as connectAgent, n as applyCliInstall, o as pathShimPath, r as declineCliInstall, s as removeCliInstall, t as aliasInstallationToAccount, u as resolveCliInstallStatus } from "../account-alias-BRRpaO9U.js";
|
|
18
|
+
import { C as saveDiffrSummarizer, E as cliAskTools, S as readDiffrConfig, T as testDiffrSummarizer, _ as DEFAULT_MAX_REQUEST_BYTES, a as drainServerCrashReport, b as GlobalReviewDesktopVerbRelay, c as answerErrorsAsJson, d as legacyModeSchema, f as captureSanitizedUiTelemetry, g as readBoundedRequestJson, h as createNodeRequestListener, l as createWhiteboardCore, m as recordClientError, o as installProcessErrorTelemetry, p as clientErrorsForSession, r as takeOverServerHome, s as serverCrashReportPath, u as serverJson, v as HttpJsonError, w as setDiffrConfigValue, x as migrateDiffrConfig, y as ReviewServerError } from "../headless-host-CG1XSd5n.js";
|
|
19
19
|
import { s as readQuerySchemas } from "../authoring-tools-BCquUsB3.js";
|
|
20
20
|
import crypto, { createHash, randomBytes } from "node:crypto";
|
|
21
21
|
import { z } from "zod";
|
|
@@ -28,8 +28,10 @@ import { pathToFileURL } from "node:url";
|
|
|
28
28
|
import { Readable, Writable } from "node:stream";
|
|
29
29
|
import { pipeline } from "node:stream/promises";
|
|
30
30
|
import { gzip, gzipSync } from "node:zlib";
|
|
31
|
-
import { createServer } from "node:http";
|
|
32
31
|
import { Hono } from "hono";
|
|
32
|
+
import { createServer } from "node:http";
|
|
33
|
+
import { createSecureServer } from "node:http2";
|
|
34
|
+
import { createServer as createServer$1 } from "node:net";
|
|
33
35
|
import { Agent, request } from "undici";
|
|
34
36
|
//#region ../review-protocol/src/review-host-status.ts
|
|
35
37
|
const OFFLINE = {
|
|
@@ -766,6 +768,94 @@ function createJsonReviewReporting(store, telemetry, options = {}) {
|
|
|
766
768
|
});
|
|
767
769
|
return app;
|
|
768
770
|
}
|
|
771
|
+
//#endregion
|
|
772
|
+
//#region src/server/loopback-server.ts
|
|
773
|
+
const FIRST_BYTE_TIMEOUT_MS$1 = 1e4;
|
|
774
|
+
const CLOSE_GRACE_MS = 1e3;
|
|
775
|
+
/**
|
|
776
|
+
* Plain HTTP, or with `tls` one port for both: a TLS ClientHello starts with
|
|
777
|
+
* 0x16, a plain HTTP request never does.
|
|
778
|
+
*/
|
|
779
|
+
function createLoopbackServer(listener, tls, log) {
|
|
780
|
+
const plain = createServer(listener);
|
|
781
|
+
const plainOnly = {
|
|
782
|
+
server: plain,
|
|
783
|
+
secure: false,
|
|
784
|
+
close: () => closeServer(plain)
|
|
785
|
+
};
|
|
786
|
+
if (!tls) return plainOnly;
|
|
787
|
+
let secure;
|
|
788
|
+
try {
|
|
789
|
+
secure = createSecureServer({
|
|
790
|
+
...tls,
|
|
791
|
+
allowHTTP1: true,
|
|
792
|
+
maxSessionMemory: 1024
|
|
793
|
+
}, listener);
|
|
794
|
+
} catch (error) {
|
|
795
|
+
log?.(`TLS unavailable, serving plain HTTP only: ${String(error)}`);
|
|
796
|
+
return plainOnly;
|
|
797
|
+
}
|
|
798
|
+
plain.emit("listening");
|
|
799
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
800
|
+
const pending = /* @__PURE__ */ new Set();
|
|
801
|
+
const sessions = /* @__PURE__ */ new Set();
|
|
802
|
+
secure.on("session", (session) => {
|
|
803
|
+
sessions.add(session);
|
|
804
|
+
session.once("close", () => sessions.delete(session));
|
|
805
|
+
});
|
|
806
|
+
secure.on("tlsClientError", (_error, socket) => socket.destroy());
|
|
807
|
+
const server = createServer$1((socket) => {
|
|
808
|
+
sockets.add(socket);
|
|
809
|
+
pending.add(socket);
|
|
810
|
+
socket.once("close", () => {
|
|
811
|
+
sockets.delete(socket);
|
|
812
|
+
pending.delete(socket);
|
|
813
|
+
});
|
|
814
|
+
socket.on("error", () => socket.destroy());
|
|
815
|
+
const silent = () => socket.destroy();
|
|
816
|
+
socket.setTimeout(FIRST_BYTE_TIMEOUT_MS$1);
|
|
817
|
+
socket.once("timeout", silent);
|
|
818
|
+
socket.once("data", (head) => {
|
|
819
|
+
pending.delete(socket);
|
|
820
|
+
socket.setTimeout(0);
|
|
821
|
+
socket.off("timeout", silent);
|
|
822
|
+
socket.pause();
|
|
823
|
+
socket.unshift(head);
|
|
824
|
+
if (head[0] === 22) secure.emit("connection", socket);
|
|
825
|
+
else {
|
|
826
|
+
plain.emit("connection", socket);
|
|
827
|
+
socket.resume();
|
|
828
|
+
}
|
|
829
|
+
});
|
|
830
|
+
});
|
|
831
|
+
return {
|
|
832
|
+
server,
|
|
833
|
+
secure: true,
|
|
834
|
+
close: async () => {
|
|
835
|
+
const closed = closeServer(server);
|
|
836
|
+
for (const socket of pending) socket.destroy();
|
|
837
|
+
plain.close();
|
|
838
|
+
for (const session of sessions) session.close();
|
|
839
|
+
const force = setTimeout(() => {
|
|
840
|
+
for (const socket of sockets) socket.destroy();
|
|
841
|
+
}, CLOSE_GRACE_MS);
|
|
842
|
+
try {
|
|
843
|
+
await closed;
|
|
844
|
+
} finally {
|
|
845
|
+
clearTimeout(force);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
function closeServer(server) {
|
|
851
|
+
return new Promise((resolve, reject) => {
|
|
852
|
+
if (!server.listening) {
|
|
853
|
+
resolve();
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
server.close((error) => error ? reject(error) : resolve());
|
|
857
|
+
});
|
|
858
|
+
}
|
|
769
859
|
const MAX_RETRY_MS = 3e4;
|
|
770
860
|
const FIRST_BYTE_TIMEOUT_MS = 1e4;
|
|
771
861
|
const NO_ANSWER = `it did not answer within ${FIRST_BYTE_TIMEOUT_MS / 1e3} seconds`;
|
|
@@ -2183,15 +2273,16 @@ function createGlobalReviewServer(input) {
|
|
|
2183
2273
|
if (!request.success) throw new ReviewServerError("Invalid agent.", 400);
|
|
2184
2274
|
return serverJson(200, await connectAgent(request.data.target, { hasShim: existsSync(pathShimPath()) }));
|
|
2185
2275
|
});
|
|
2186
|
-
const
|
|
2276
|
+
const loopback = createLoopbackServer(createNodeRequestListener(app), input.tls, input.log);
|
|
2187
2277
|
return {
|
|
2188
2278
|
discovery,
|
|
2279
|
+
secure: loopback.secure,
|
|
2189
2280
|
get url() {
|
|
2190
2281
|
return urlForBoundPort();
|
|
2191
2282
|
},
|
|
2192
2283
|
listen: async () => {
|
|
2193
2284
|
await migrateDiffrConfig();
|
|
2194
|
-
boundPort = await listen(
|
|
2285
|
+
boundPort = await listen(loopback.server, input.port);
|
|
2195
2286
|
discovery.url = urlForBoundPort();
|
|
2196
2287
|
for (const discoveryPath of discoveryPaths) await writePrivateJsonAtomic(discoveryPath, discovery);
|
|
2197
2288
|
},
|
|
@@ -2202,7 +2293,7 @@ function createGlobalReviewServer(input) {
|
|
|
2202
2293
|
for (const discoveryPath of discoveryPaths) await removeMatchingDiscovery(discoveryPath, discovery);
|
|
2203
2294
|
relay.close();
|
|
2204
2295
|
openWatchdog.dispose();
|
|
2205
|
-
await
|
|
2296
|
+
await loopback.close();
|
|
2206
2297
|
await gateway.close();
|
|
2207
2298
|
await telemetry.shutdown(1500);
|
|
2208
2299
|
},
|
|
@@ -2259,15 +2350,6 @@ function listen(server, port) {
|
|
|
2259
2350
|
});
|
|
2260
2351
|
});
|
|
2261
2352
|
}
|
|
2262
|
-
function closeHttpServer(server) {
|
|
2263
|
-
return new Promise((resolve, reject) => {
|
|
2264
|
-
if (!server.listening) {
|
|
2265
|
-
resolve();
|
|
2266
|
-
return;
|
|
2267
|
-
}
|
|
2268
|
-
server.close((error) => error ? reject(error) : resolve());
|
|
2269
|
-
});
|
|
2270
|
-
}
|
|
2271
2353
|
async function removeMatchingDiscovery(filePath, discovery) {
|
|
2272
2354
|
try {
|
|
2273
2355
|
const current = JSON.parse(await readFile(filePath, "utf8"));
|
|
@@ -2287,6 +2369,7 @@ async function runDesktopHost(env = process.env) {
|
|
|
2287
2369
|
const appPid = requiredPositiveInteger(env.DEV_FAST_REVIEW_APP_PID, "DEV_FAST_REVIEW_APP_PID");
|
|
2288
2370
|
const packageRoot = findReviewPackageRoot(import.meta.url);
|
|
2289
2371
|
const toolingRoot = env.DEV_FAST_REVIEW_TOOLING_ROOT || packageRoot;
|
|
2372
|
+
const tls = takeLoopbackTls(env);
|
|
2290
2373
|
const telemetryEnv = { ...env };
|
|
2291
2374
|
delete telemetryEnv.DEV_FAST_REVIEW_TELEMETRY_DISABLED;
|
|
2292
2375
|
const telemetry = ReviewTelemetry.fromEnv(telemetryEnv, { surface: "desktop" });
|
|
@@ -2309,7 +2392,8 @@ async function runDesktopHost(env = process.env) {
|
|
|
2309
2392
|
token: env.DEV_FAST_REVIEW_SERVER_TOKEN,
|
|
2310
2393
|
instanceId: env.DEV_FAST_REVIEW_INSTANCE_ID,
|
|
2311
2394
|
identity: reviewInstanceIdentity(env),
|
|
2312
|
-
telemetry
|
|
2395
|
+
telemetry,
|
|
2396
|
+
tls
|
|
2313
2397
|
};
|
|
2314
2398
|
const home = devReviewHome(env);
|
|
2315
2399
|
await mkdir(home, { recursive: true });
|
|
@@ -2351,7 +2435,8 @@ async function runDesktopHost(env = process.env) {
|
|
|
2351
2435
|
process.stdout.write(`${JSON.stringify({
|
|
2352
2436
|
event: "ready",
|
|
2353
2437
|
...server.discovery,
|
|
2354
|
-
installationId
|
|
2438
|
+
installationId,
|
|
2439
|
+
...server.secure && { tls: true }
|
|
2355
2440
|
})}\n`);
|
|
2356
2441
|
const stageRustAnalyzer = () => ensureBundledRustAnalyzer({ env }).catch((error) => {
|
|
2357
2442
|
const reason = error instanceof Error ? error.message : String(error);
|
|
@@ -2381,6 +2466,17 @@ async function runDesktopHost(env = process.env) {
|
|
|
2381
2466
|
function isEnabledEnvValue(value) {
|
|
2382
2467
|
return value === "1" || value?.toLowerCase() === "true";
|
|
2383
2468
|
}
|
|
2469
|
+
/** Read once, then dropped so no child process inherits the private key. */
|
|
2470
|
+
function takeLoopbackTls(env) {
|
|
2471
|
+
const key = env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
|
|
2472
|
+
const cert = env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
|
|
2473
|
+
delete env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
|
|
2474
|
+
delete env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
|
|
2475
|
+
return key && cert ? {
|
|
2476
|
+
key,
|
|
2477
|
+
cert
|
|
2478
|
+
} : void 0;
|
|
2479
|
+
}
|
|
2384
2480
|
function requiredPort(value) {
|
|
2385
2481
|
const parsed = Number(value);
|
|
2386
2482
|
if (!Number.isSafeInteger(parsed) || parsed < 0 || parsed > 65535) throw new Error("DEV_FAST_REVIEW_SERVER_PORT must be a port between 0 and 65535.");
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dev.fast/whiteboard",
|
|
3
|
-
"version": "0.3.5-preview.20261021.
|
|
3
|
+
"version": "0.3.5-preview.20261021.133",
|
|
4
4
|
"private": false,
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -88,7 +88,7 @@
|
|
|
88
88
|
"repo": "devdotfast/review",
|
|
89
89
|
"docs": "packages/review/instructions/authoring.md"
|
|
90
90
|
},
|
|
91
|
-
"gitHead": "
|
|
91
|
+
"gitHead": "6c2c2de6384ef9b8d4f9d6a3c63d9cbbcee0ebe8",
|
|
92
92
|
"scripts": {
|
|
93
93
|
"prebuild": "pnpm run build:workspace-deps",
|
|
94
94
|
"build": "node --max-old-space-size=8192 node_modules/tsdown/dist/run.mjs",
|
package/src/review-stack.ts
CHANGED
|
@@ -78,6 +78,58 @@ export async function resolveReviewStackLayers(
|
|
|
78
78
|
});
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
type GitHubStacks = z.infer<typeof GitHubStacksSchema>;
|
|
82
|
+
|
|
83
|
+
// One GitHub answer serves every pull request in a stack for a minute.
|
|
84
|
+
const STACKS_FRESH_MS = 60_000;
|
|
85
|
+
|
|
86
|
+
const stackReads = new WeakMap<
|
|
87
|
+
RunGitHubApi,
|
|
88
|
+
Map<string, { at: number; stacks: Promise<GitHubStacks> }>
|
|
89
|
+
>();
|
|
90
|
+
|
|
91
|
+
function readGitHubStacks(
|
|
92
|
+
runGitHubApi: RunGitHubApi,
|
|
93
|
+
host: string,
|
|
94
|
+
repository: string,
|
|
95
|
+
pullRequestNumber: number,
|
|
96
|
+
): Promise<GitHubStacks> {
|
|
97
|
+
const reads = stackReads.get(runGitHubApi) ?? new Map();
|
|
98
|
+
|
|
99
|
+
stackReads.set(runGitHubApi, reads);
|
|
100
|
+
|
|
101
|
+
const keyOf = (number: number) => `${host}/${repository}#${number}`;
|
|
102
|
+
const cached = reads.get(keyOf(pullRequestNumber));
|
|
103
|
+
|
|
104
|
+
if (cached && Date.now() - cached.at < STACKS_FRESH_MS) return cached.stacks;
|
|
105
|
+
|
|
106
|
+
const read = {
|
|
107
|
+
at: Date.now(),
|
|
108
|
+
stacks: runGitHubApi(
|
|
109
|
+
host,
|
|
110
|
+
`repos/${repository}/stacks?pull_request=${pullRequestNumber}`,
|
|
111
|
+
).then((text) => GitHubStacksSchema.parse(JSON.parse(text))),
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
reads.set(keyOf(pullRequestNumber), read);
|
|
115
|
+
read.stacks.then(
|
|
116
|
+
(stacks) => {
|
|
117
|
+
const stack = stacks.find((candidate) =>
|
|
118
|
+
candidate.pull_requests.some((pr) => pr.number === pullRequestNumber),
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
for (const pr of stack?.pull_requests ?? [])
|
|
122
|
+
reads.set(keyOf(pr.number), read);
|
|
123
|
+
},
|
|
124
|
+
() => {
|
|
125
|
+
if (reads.get(keyOf(pullRequestNumber)) === read)
|
|
126
|
+
reads.delete(keyOf(pullRequestNumber));
|
|
127
|
+
},
|
|
128
|
+
);
|
|
129
|
+
|
|
130
|
+
return read.stacks;
|
|
131
|
+
}
|
|
132
|
+
|
|
81
133
|
async function gitHubStack(
|
|
82
134
|
subject: Subject,
|
|
83
135
|
reviews: readonly Candidate[],
|
|
@@ -91,16 +143,14 @@ async function gitHubStack(
|
|
|
91
143
|
const [, host, owner, repository, number] = binding;
|
|
92
144
|
const pullRequestNumber = Number(number);
|
|
93
145
|
const repoKey = `https://${host}/${owner}/${repository}`;
|
|
94
|
-
let stacks:
|
|
146
|
+
let stacks: GitHubStacks;
|
|
95
147
|
|
|
96
148
|
try {
|
|
97
|
-
stacks =
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
),
|
|
103
|
-
),
|
|
149
|
+
stacks = await readGitHubStacks(
|
|
150
|
+
runGitHubApi,
|
|
151
|
+
host!,
|
|
152
|
+
`${owner}/${repository}`,
|
|
153
|
+
pullRequestNumber,
|
|
104
154
|
);
|
|
105
155
|
} catch {
|
|
106
156
|
// Stack discovery must not make the Review unavailable when GitHub or
|
|
@@ -37,6 +37,7 @@ export async function runDesktopHost(
|
|
|
37
37
|
|
|
38
38
|
const packageRoot = findReviewPackageRoot(import.meta.url);
|
|
39
39
|
const toolingRoot = env.DEV_FAST_REVIEW_TOOLING_ROOT || packageRoot;
|
|
40
|
+
const tls = takeLoopbackTls(env);
|
|
40
41
|
const telemetryEnv = { ...env };
|
|
41
42
|
delete telemetryEnv.DEV_FAST_REVIEW_TELEMETRY_DISABLED;
|
|
42
43
|
|
|
@@ -73,6 +74,7 @@ export async function runDesktopHost(
|
|
|
73
74
|
instanceId: env.DEV_FAST_REVIEW_INSTANCE_ID,
|
|
74
75
|
identity: reviewInstanceIdentity(env),
|
|
75
76
|
telemetry,
|
|
77
|
+
tls,
|
|
76
78
|
};
|
|
77
79
|
|
|
78
80
|
const home = devReviewHome(env);
|
|
@@ -119,7 +121,7 @@ export async function runDesktopHost(
|
|
|
119
121
|
});
|
|
120
122
|
|
|
121
123
|
process.stdout.write(
|
|
122
|
-
`${JSON.stringify({ event: "ready", ...server.discovery, installationId })}\n`,
|
|
124
|
+
`${JSON.stringify({ event: "ready", ...server.discovery, installationId, ...(server.secure && { tls: true }) })}\n`,
|
|
123
125
|
);
|
|
124
126
|
|
|
125
127
|
const stageRustAnalyzer = () =>
|
|
@@ -172,6 +174,17 @@ function isEnabledEnvValue(value: string | undefined): boolean {
|
|
|
172
174
|
return value === "1" || value?.toLowerCase() === "true";
|
|
173
175
|
}
|
|
174
176
|
|
|
177
|
+
/** Read once, then dropped so no child process inherits the private key. */
|
|
178
|
+
function takeLoopbackTls(env: NodeJS.ProcessEnv) {
|
|
179
|
+
const key = env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
|
|
180
|
+
const cert = env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
|
|
181
|
+
|
|
182
|
+
delete env.DEV_FAST_REVIEW_SERVER_TLS_KEY;
|
|
183
|
+
delete env.DEV_FAST_REVIEW_SERVER_TLS_CERT;
|
|
184
|
+
|
|
185
|
+
return key && cert ? { key, cert } : undefined;
|
|
186
|
+
}
|
|
187
|
+
|
|
175
188
|
function requiredPort(value: string | undefined): number {
|
|
176
189
|
const parsed = Number(value);
|
|
177
190
|
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { readFile, rm } from "node:fs/promises";
|
|
4
|
-
import {
|
|
5
|
-
import type { AddressInfo } from "node:net";
|
|
4
|
+
import type { AddressInfo, Server } from "node:net";
|
|
6
5
|
import path from "node:path";
|
|
7
6
|
import { pathToFileURL } from "node:url";
|
|
8
7
|
|
|
@@ -78,6 +77,7 @@ import {
|
|
|
78
77
|
captureReviewUiEvent,
|
|
79
78
|
createJsonReviewReporting,
|
|
80
79
|
} from "./json-review-reporting";
|
|
80
|
+
import { type LoopbackTls, createLoopbackServer } from "./loopback-server";
|
|
81
81
|
import { createReviewGateway } from "./review-gateway";
|
|
82
82
|
import { reviewLifecycleTelemetry } from "./review-lifecycle-telemetry";
|
|
83
83
|
import { ReviewOpenWatchdog } from "./review-open-watchdog";
|
|
@@ -110,6 +110,7 @@ export interface GlobalReviewServerInput {
|
|
|
110
110
|
relay?: ReviewDesktopVerbRelay;
|
|
111
111
|
/** Electron's Review crash dump directory; `/crash-reports` reads only inside it. */
|
|
112
112
|
crashDumpsDir?: string;
|
|
113
|
+
tls?: LoopbackTls;
|
|
113
114
|
onRemoteHostRestarted?(alias: string): void;
|
|
114
115
|
log?(message: string): void;
|
|
115
116
|
}
|
|
@@ -117,6 +118,7 @@ export interface GlobalReviewServerInput {
|
|
|
117
118
|
export interface GlobalReviewServer {
|
|
118
119
|
readonly discovery: ReviewDesktopDiscovery;
|
|
119
120
|
readonly url: string;
|
|
121
|
+
readonly secure: boolean;
|
|
120
122
|
listen(): Promise<void>;
|
|
121
123
|
close(reason?: "app-exit"): Promise<void>;
|
|
122
124
|
setRemoteHosts(hosts: ReviewGatewayHost[]): void;
|
|
@@ -483,16 +485,21 @@ export function createGlobalReviewServer(
|
|
|
483
485
|
);
|
|
484
486
|
});
|
|
485
487
|
|
|
486
|
-
const
|
|
488
|
+
const loopback = createLoopbackServer(
|
|
489
|
+
createNodeRequestListener(app),
|
|
490
|
+
input.tls,
|
|
491
|
+
input.log,
|
|
492
|
+
);
|
|
487
493
|
|
|
488
494
|
return {
|
|
489
495
|
discovery,
|
|
496
|
+
secure: loopback.secure,
|
|
490
497
|
get url() {
|
|
491
498
|
return urlForBoundPort();
|
|
492
499
|
},
|
|
493
500
|
listen: async () => {
|
|
494
501
|
await migrateDiffrConfig();
|
|
495
|
-
boundPort = await listen(
|
|
502
|
+
boundPort = await listen(loopback.server, input.port);
|
|
496
503
|
discovery.url = urlForBoundPort();
|
|
497
504
|
|
|
498
505
|
for (const discoveryPath of discoveryPaths)
|
|
@@ -508,7 +515,7 @@ export function createGlobalReviewServer(
|
|
|
508
515
|
relay.close();
|
|
509
516
|
openWatchdog.dispose();
|
|
510
517
|
|
|
511
|
-
await
|
|
518
|
+
await loopback.close();
|
|
512
519
|
await gateway.close();
|
|
513
520
|
await telemetry.shutdown(1_500);
|
|
514
521
|
},
|
|
@@ -594,18 +601,6 @@ function listen(server: Server, port: number): Promise<number> {
|
|
|
594
601
|
});
|
|
595
602
|
}
|
|
596
603
|
|
|
597
|
-
function closeHttpServer(server: Server): Promise<void> {
|
|
598
|
-
return new Promise((resolve, reject) => {
|
|
599
|
-
if (!server.listening) {
|
|
600
|
-
resolve();
|
|
601
|
-
|
|
602
|
-
return;
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
server.close((error) => (error ? reject(error) : resolve()));
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
|
|
609
604
|
async function removeMatchingDiscovery(
|
|
610
605
|
filePath: string,
|
|
611
606
|
discovery: ReviewDesktopDiscovery,
|
package/src/server/hono-http.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import crypto from "node:crypto";
|
|
2
|
-
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
3
2
|
|
|
4
3
|
import {
|
|
5
4
|
type JsonValue,
|
|
6
5
|
REVIEW_HOST_HEADER,
|
|
7
6
|
parseJsonText,
|
|
8
7
|
} from "@dev.fast/review-protocol";
|
|
9
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
type Http2Bindings,
|
|
10
|
+
type HttpBindings,
|
|
11
|
+
getRequestListener,
|
|
12
|
+
} from "@hono/node-server";
|
|
10
13
|
import { REVIEW_PERSON_HEADER } from "@review/review-api/request-origin";
|
|
11
14
|
import { REVIEW_APP_SESSION_ID_HEADER } from "@review/ui-telemetry-events";
|
|
12
15
|
import type { Hono } from "hono";
|
|
@@ -16,13 +19,36 @@ import { StreamLimitError, readBoundedStream } from "./bounded-stream.js";
|
|
|
16
19
|
import { DEFAULT_MAX_REQUEST_BYTES, HttpJsonError } from "./http-json";
|
|
17
20
|
|
|
18
21
|
export type ReviewHonoEnv = {
|
|
19
|
-
Bindings: HttpBindings;
|
|
22
|
+
Bindings: HttpBindings | Http2Bindings;
|
|
20
23
|
};
|
|
21
24
|
|
|
25
|
+
// HTTP/2 rejects connection-specific headers, such as streamSSE's chunked framing.
|
|
26
|
+
const H2_FORBIDDEN_HEADERS = [
|
|
27
|
+
"connection",
|
|
28
|
+
"keep-alive",
|
|
29
|
+
"proxy-connection",
|
|
30
|
+
"transfer-encoding",
|
|
31
|
+
"upgrade",
|
|
32
|
+
];
|
|
33
|
+
|
|
22
34
|
export function createNodeRequestListener(
|
|
23
35
|
app: Hono<ReviewHonoEnv>,
|
|
24
|
-
):
|
|
25
|
-
return getRequestListener(
|
|
36
|
+
): ReturnType<typeof getRequestListener> {
|
|
37
|
+
return getRequestListener(async (request, env) => {
|
|
38
|
+
const response = await app.fetch(request, env);
|
|
39
|
+
|
|
40
|
+
if (
|
|
41
|
+
env.incoming.httpVersionMajor !== 2 ||
|
|
42
|
+
!H2_FORBIDDEN_HEADERS.some((name) => response.headers.has(name))
|
|
43
|
+
)
|
|
44
|
+
return response;
|
|
45
|
+
|
|
46
|
+
const stripped = new Response(response.body, response);
|
|
47
|
+
|
|
48
|
+
for (const name of H2_FORBIDDEN_HEADERS) stripped.headers.delete(name);
|
|
49
|
+
|
|
50
|
+
return stripped;
|
|
51
|
+
});
|
|
26
52
|
}
|
|
27
53
|
|
|
28
54
|
export function jsonResponse<T>(
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type IncomingMessage,
|
|
3
|
+
type ServerResponse,
|
|
4
|
+
createServer,
|
|
5
|
+
} from "node:http";
|
|
6
|
+
import {
|
|
7
|
+
type Http2ServerRequest,
|
|
8
|
+
type Http2ServerResponse,
|
|
9
|
+
type ServerHttp2Session,
|
|
10
|
+
createSecureServer,
|
|
11
|
+
} from "node:http2";
|
|
12
|
+
import {
|
|
13
|
+
type Server as NetServer,
|
|
14
|
+
type Socket,
|
|
15
|
+
createServer as createNetServer,
|
|
16
|
+
} from "node:net";
|
|
17
|
+
|
|
18
|
+
export interface LoopbackTls {
|
|
19
|
+
key: string;
|
|
20
|
+
cert: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface LoopbackServer {
|
|
24
|
+
server: NetServer;
|
|
25
|
+
secure: boolean;
|
|
26
|
+
close(): Promise<void>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type Listener = (
|
|
30
|
+
request: IncomingMessage | Http2ServerRequest,
|
|
31
|
+
response: ServerResponse | Http2ServerResponse,
|
|
32
|
+
) => Promise<void>;
|
|
33
|
+
|
|
34
|
+
const FIRST_BYTE_TIMEOUT_MS = 10_000;
|
|
35
|
+
|
|
36
|
+
const CLOSE_GRACE_MS = 1_000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Plain HTTP, or with `tls` one port for both: a TLS ClientHello starts with
|
|
40
|
+
* 0x16, a plain HTTP request never does.
|
|
41
|
+
*/
|
|
42
|
+
export function createLoopbackServer(
|
|
43
|
+
listener: Listener,
|
|
44
|
+
tls?: LoopbackTls,
|
|
45
|
+
log?: (message: string) => void,
|
|
46
|
+
): LoopbackServer {
|
|
47
|
+
const plain = createServer(listener);
|
|
48
|
+
|
|
49
|
+
const plainOnly = {
|
|
50
|
+
server: plain,
|
|
51
|
+
secure: false,
|
|
52
|
+
close: () => closeServer(plain),
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
if (!tls) return plainOnly;
|
|
56
|
+
|
|
57
|
+
let secure: ReturnType<typeof createSecureServer>;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
secure = createSecureServer(
|
|
61
|
+
// In MB. At Node's 10 MB default, queued large responses reset streams.
|
|
62
|
+
{ ...tls, allowHTTP1: true, maxSessionMemory: 1_024 },
|
|
63
|
+
listener,
|
|
64
|
+
);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
log?.(`TLS unavailable, serving plain HTTP only: ${String(error)}`);
|
|
67
|
+
|
|
68
|
+
return plainOnly;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Sniffed sockets bypass plain.listen(), which starts its idle and timeout tracking.
|
|
72
|
+
plain.emit("listening");
|
|
73
|
+
|
|
74
|
+
const sockets = new Set<Socket>();
|
|
75
|
+
const pending = new Set<Socket>();
|
|
76
|
+
const sessions = new Set<ServerHttp2Session>();
|
|
77
|
+
|
|
78
|
+
secure.on("session", (session) => {
|
|
79
|
+
sessions.add(session);
|
|
80
|
+
session.once("close", () => sessions.delete(session));
|
|
81
|
+
});
|
|
82
|
+
secure.on("tlsClientError", (_error, socket) => socket.destroy());
|
|
83
|
+
|
|
84
|
+
const server = createNetServer((socket) => {
|
|
85
|
+
sockets.add(socket);
|
|
86
|
+
pending.add(socket);
|
|
87
|
+
socket.once("close", () => {
|
|
88
|
+
sockets.delete(socket);
|
|
89
|
+
pending.delete(socket);
|
|
90
|
+
});
|
|
91
|
+
socket.on("error", () => socket.destroy());
|
|
92
|
+
|
|
93
|
+
const silent = () => socket.destroy();
|
|
94
|
+
|
|
95
|
+
socket.setTimeout(FIRST_BYTE_TIMEOUT_MS);
|
|
96
|
+
socket.once("timeout", silent);
|
|
97
|
+
socket.once("data", (head: Buffer) => {
|
|
98
|
+
pending.delete(socket);
|
|
99
|
+
socket.setTimeout(0);
|
|
100
|
+
socket.off("timeout", silent);
|
|
101
|
+
socket.pause();
|
|
102
|
+
socket.unshift(head);
|
|
103
|
+
|
|
104
|
+
if (head[0] === 0x16) secure.emit("connection", socket);
|
|
105
|
+
else {
|
|
106
|
+
plain.emit("connection", socket);
|
|
107
|
+
socket.resume();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
server,
|
|
114
|
+
secure: true,
|
|
115
|
+
close: async () => {
|
|
116
|
+
const closed = closeServer(server);
|
|
117
|
+
|
|
118
|
+
for (const socket of pending) socket.destroy();
|
|
119
|
+
plain.close();
|
|
120
|
+
|
|
121
|
+
for (const session of sessions) session.close();
|
|
122
|
+
|
|
123
|
+
const force = setTimeout(() => {
|
|
124
|
+
for (const socket of sockets) socket.destroy();
|
|
125
|
+
}, CLOSE_GRACE_MS);
|
|
126
|
+
|
|
127
|
+
try {
|
|
128
|
+
await closed;
|
|
129
|
+
} finally {
|
|
130
|
+
clearTimeout(force);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function closeServer(server: NetServer): Promise<void> {
|
|
137
|
+
return new Promise((resolve, reject) => {
|
|
138
|
+
if (!server.listening) {
|
|
139
|
+
resolve();
|
|
140
|
+
|
|
141
|
+
return;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -605,6 +605,10 @@ export const UI_TELEMETRY_EVENTS = {
|
|
|
605
605
|
event: "review_app_ready",
|
|
606
606
|
properties: { duration_ms: "number", install_location: INSTALL_LOCATION },
|
|
607
607
|
},
|
|
608
|
+
loopback_tls_fallback: {
|
|
609
|
+
event: "review_loopback_tls_fallback",
|
|
610
|
+
properties: { error_name: "enum_free_short" },
|
|
611
|
+
},
|
|
608
612
|
error_burst: {
|
|
609
613
|
event: "review_error_burst",
|
|
610
614
|
properties: { message_hash: "hash_hex", suppressed: "number" },
|