@dev.fast/whiteboard 0.2.5-preview.20261013.113 → 0.2.5-preview.20261015.115
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/THIRD_PARTY_NOTICES.md +29 -0
- package/dist/{account-alias-D0hF0sKk.js → account-alias-ChZR3RIU.js} +94 -8
- package/dist/{agent-cli-04xbDj23.js → agent-cli-CR2PnA9G.js} +9 -12
- package/dist/agent-cli-CRPPmS3l.js +2 -0
- package/dist/{agent-client-P3J4y8Zn.js → agent-client-DVd-prfp.js} +31 -14
- package/dist/{authoring-tools-fdA0RPIT.js → authoring-tools-BXKEwhfi.js} +5 -5
- package/dist/background-server-Cw7CcfaZ.js +156 -0
- package/dist/background-server-D2Rrr_e2.js +2 -0
- package/dist/build-info.json +1 -1
- package/dist/{cli-C0ziP6hg.js → cli-CSrz_wGk.js} +1 -1
- package/dist/{cli-runner-C3men22A.js → cli-runner-PlGqBeMw.js} +368 -252
- package/dist/cli.js +3 -2
- package/dist/{client-DzcSt6pV.js → client-DhZzPePs.js} +5 -3
- package/dist/{process-error-telemetry-BCBYp2t4.js → headless-host-CVXTSIlx.js} +3285 -2585
- package/dist/headless-host-Cp37C-v0.js +2 -0
- package/dist/input-error-OLgB_h31.js +11 -0
- package/dist/{local-data-Dy1pNtLF.js → local-data-yfLp5PiJ.js} +113 -62
- package/dist/{mcp-B8kuRSXM.js → mcp-DBbDwG4y.js} +4 -4
- package/dist/{package-paths-B9PDoAl6.js → package-paths-BsuI4WUf.js} +1 -1
- package/dist/profile-BSOkA6A-.js +2 -0
- package/dist/{profile-tbGWuWHu.js → profile-pAzg7fxC.js} +3 -3
- package/dist/remote-attach-BAt2eZx3.js +137 -0
- package/dist/remote-extensions-2Cxf3hMo.js +2 -0
- package/dist/remote-extensions-BiNx6NQK.js +323 -0
- package/dist/remote-language-server-DQgWUNeV.js +252 -0
- package/dist/remote-uninstall-DLtDG0o7.js +153 -0
- package/dist/{request-origin-DA4K1qWY.js → request-origin-DaRIV3cB.js} +5 -2
- package/dist/review-app-launcher-D7I4DGoH.js +222 -0
- package/dist/{review-home-paths-Y9mYWkCI.js → review-home-paths-zwevIq4I.js} +7 -7
- package/dist/{review-telemetry-CmM6tCbj.js → review-telemetry-BqB4CiTD.js} +5 -5
- package/dist/{s3-SzLpeCG6.js → s3-77-qkEhs.js} +1 -1
- package/dist/{s3-config-BgxkSoOy.js → s3-config-0xuCh-F_.js} +1 -1
- package/dist/{s3-config-CAxhO9u_.js → s3-config-DDWWMh5x.js} +5 -1
- package/dist/server/desktop-host.js +1663 -266
- package/dist/{desktop-discovery-Ba3dD5Cn.js → server-discovery-CifQ3yQS.js} +88 -3
- package/dist/sharing/index.d.ts +88 -32
- package/dist/sharing/index.js +1 -1
- package/dist/{src-DiHP11l3.js → src-BMPuxWv4.js} +6 -11
- package/dist/{src-WCbabvdS.js → src-Bgvf1sEN.js} +128 -8
- package/dist/{stored-document-migration-u4L_1VZa.js → stored-document-migration-CFOvjRwW.js} +5 -18
- package/dist/{tool-failure-CJHBCHMh.js → tool-failure-BXWzQG8T.js} +2 -2
- package/instructions/authoring.md +16 -12
- package/instructions/file-lenses.md +4 -2
- package/package.json +9 -14
- package/src/agent-connect.ts +507 -0
- package/src/agent-selection.ts +4 -0
- package/src/ask/agents.ts +59 -13
- package/src/ask/pi-mcp.ts +7 -3
- package/src/ask/thread-state.ts +9 -4
- package/src/ask/thread.ts +88 -50
- package/src/ask/threads.ts +53 -7
- package/src/cli-install.ts +3 -1
- package/src/cli-runner.ts +472 -81
- package/src/cli.ts +1 -1
- package/src/connect-agents.ts +285 -0
- package/src/connect-prompts.ts +60 -6
- package/src/desktop-discovery.ts +24 -0
- package/src/pinned-download.ts +118 -0
- package/src/remote-attach.ts +130 -0
- package/src/remote-extensions.ts +359 -0
- package/src/remote-language-server.ts +428 -0
- package/src/remote-toolchains.ts +107 -0
- package/src/remote-uninstall.ts +280 -0
- package/src/review-api/README.md +1 -3
- package/src/review-api/agent-cli.ts +4 -4
- package/src/review-api/agent-client.ts +23 -0
- package/src/review-api/ask-history.ts +31 -16
- package/src/review-api/authoring-tools.ts +1 -1
- package/src/review-api/blocks/index.ts +2 -4
- package/src/review-api/document.ts +1 -4
- package/src/review-api/http.ts +265 -237
- package/src/review-api/local-data.ts +18 -3
- package/src/review-api/onboarding.ts +478 -0
- package/src/review-api/origin.ts +7 -0
- package/src/review-api/recent-folders.ts +173 -0
- package/src/review-api/store.ts +56 -10
- package/src/review-api/workspaces.ts +56 -11
- package/src/review-app-launcher.ts +61 -3
- package/src/review-stack.ts +162 -36
- package/src/server/background-server.ts +250 -0
- package/src/server/desktop-host-shutdown.ts +24 -0
- package/src/server/desktop-host.ts +39 -24
- package/src/server/desktop-server.ts +68 -118
- package/src/server/global-verb-relay.ts +16 -0
- package/src/server/headless-host.ts +128 -3
- package/src/server/hono-http.ts +6 -1
- package/src/server/legacy-tutorial-cleanup.ts +38 -0
- package/src/server/review-gateway-hosts.ts +379 -0
- package/src/server/review-gateway-list.ts +109 -0
- package/src/server/review-gateway-memory.ts +130 -0
- package/src/server/review-gateway-pushes.ts +142 -0
- package/src/server/review-gateway-streams.ts +415 -0
- package/src/server/review-gateway-transport.ts +204 -0
- package/src/server/review-gateway.ts +433 -0
- package/src/server/review-server-core.ts +36 -6
- package/src/server-discovery.ts +11 -0
- package/src/sharing/import.ts +1 -0
- package/src/ui-telemetry-events.ts +5 -1
- package/src/vsix.ts +122 -0
- package/dist/agent-cli-BR_Jw5BL.js +0 -2
- package/dist/headless-host-BLLJ7Dyu.js +0 -95
- package/dist/profile-aMSw1oCM.js +0 -2
- package/dist/server-discovery-ZVi8Ay8J.js +0 -66
- package/src/fixtures/blocks/tutorial.json +0 -14
- package/src/review-api/blocks/tutorial.ts +0 -23
- package/src/server/tutorial-service.ts +0 -217
- package/tutorial/document.json +0 -216
- package/tutorial/lenses.json +0 -10
- package/tutorial/runtime-manifest.json +0 -11
- package/tutorial/sample-service/package.json +0 -9
- package/tutorial/sample-service/src/api/checkout-api.ts +0 -17
- package/tutorial/sample-service/src/app.ts +0 -26
- package/tutorial/sample-service/src/database/drizzle-orm.d.ts +0 -16
- package/tutorial/sample-service/src/database/schema.ts +0 -9
- package/tutorial/sample-service/src/fulfillment/fulfillment-queue.ts +0 -15
- package/tutorial/sample-service/src/fulfillment/fulfillment-worker.ts +0 -24
- package/tutorial/sample-service/src/inventory/forklift-mood.ts +0 -4
- package/tutorial/sample-service/src/inventory/inventory-service.ts +0 -28
- package/tutorial/sample-service/src/inventory/warehouse-lore.ts +0 -11
- package/tutorial/sample-service/src/orders/order-service.ts +0 -35
- package/tutorial/sample-service/src/orders/order.ts +0 -43
- package/tutorial/sample-service/src/orders/orders-repository.ts +0 -24
- package/tutorial/sample-service/src/payments/fraud-vibes.ts +0 -4
- package/tutorial/sample-service/src/payments/payment-gateway.ts +0 -29
- package/tutorial/sample-service/src/payments/raccoon-ledger.ts +0 -9
- package/tutorial/sample-service/src/shipping/shipping-gateway.ts +0 -15
- package/tutorial/sample-service/tsconfig.json +0 -12
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import {
|
|
2
|
+
type ReviewVerbRequest,
|
|
3
|
+
type ReviewVerbResponse,
|
|
4
|
+
parseJsonText,
|
|
5
|
+
parseReviewDesktopVerbFrame,
|
|
6
|
+
} from "@dev.fast/review-protocol";
|
|
7
|
+
|
|
8
|
+
import type { ReviewDesktopVerbRelay } from "./global-verb-relay.js";
|
|
9
|
+
import type { GatewayHosts, GatewayRemote } from "./review-gateway-hosts.js";
|
|
10
|
+
import { UUID } from "./review-gateway-memory.js";
|
|
11
|
+
import {
|
|
12
|
+
FIRST_BYTE_TIMEOUT_MS,
|
|
13
|
+
errorText,
|
|
14
|
+
keepOpen,
|
|
15
|
+
readLines,
|
|
16
|
+
remoteHeaders,
|
|
17
|
+
send,
|
|
18
|
+
} from "./review-gateway-transport.js";
|
|
19
|
+
|
|
20
|
+
const REMOTE_VERBS = new Set<ReviewVerbRequest["name"]>([
|
|
21
|
+
"authoringCapabilities",
|
|
22
|
+
"openApiReview",
|
|
23
|
+
"focusWindow",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
export function createGatewayPushes(input: {
|
|
27
|
+
hosts: GatewayHosts;
|
|
28
|
+
relay: ReviewDesktopVerbRelay;
|
|
29
|
+
claim(remote: GatewayRemote, reviewId: string): Promise<string | undefined>;
|
|
30
|
+
log(message: string): void;
|
|
31
|
+
}) {
|
|
32
|
+
const { hosts, relay } = input;
|
|
33
|
+
const links = new Map<GatewayRemote, AbortController>();
|
|
34
|
+
|
|
35
|
+
async function answer(
|
|
36
|
+
remote: GatewayRemote,
|
|
37
|
+
request: ReviewVerbRequest,
|
|
38
|
+
): Promise<ReviewVerbResponse> {
|
|
39
|
+
if (!REMOTE_VERBS.has(request.name))
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
error: `${request.name} is not available from another machine.`,
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
if (request.name === "openApiReview") {
|
|
46
|
+
const { reviewId } = request.args;
|
|
47
|
+
|
|
48
|
+
if (!UUID.test(reviewId))
|
|
49
|
+
return {
|
|
50
|
+
ok: false,
|
|
51
|
+
error: `${reviewId} cannot be opened from ${remote.alias}: a review on another machine has a UUID.`,
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const refused = await input.claim(remote, reviewId);
|
|
55
|
+
|
|
56
|
+
if (refused) return { ok: false, error: refused };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return relay.dispatch(request);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function reply(
|
|
63
|
+
remote: GatewayRemote,
|
|
64
|
+
id: string,
|
|
65
|
+
response: ReviewVerbResponse,
|
|
66
|
+
) {
|
|
67
|
+
try {
|
|
68
|
+
const answered = await send(remote, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
path: "/control/result",
|
|
71
|
+
headers: {
|
|
72
|
+
...remoteHeaders(remote),
|
|
73
|
+
"content-type": "application/json",
|
|
74
|
+
},
|
|
75
|
+
body: Buffer.from(JSON.stringify({ id, response })),
|
|
76
|
+
signal: AbortSignal.timeout(FIRST_BYTE_TIMEOUT_MS),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
await answered.body.dump();
|
|
80
|
+
} catch (error) {
|
|
81
|
+
input.log(
|
|
82
|
+
`Could not answer ${remote.alias}'s push: ${errorText(error)}.`,
|
|
83
|
+
);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function push(remote: GatewayRemote, data: string) {
|
|
88
|
+
let frame: ReturnType<typeof parseReviewDesktopVerbFrame>;
|
|
89
|
+
|
|
90
|
+
try {
|
|
91
|
+
frame = parseReviewDesktopVerbFrame(parseJsonText(data));
|
|
92
|
+
} catch {
|
|
93
|
+
input.log(`Ignored an unreadable push from ${remote.alias}.`);
|
|
94
|
+
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let response: ReviewVerbResponse;
|
|
99
|
+
|
|
100
|
+
try {
|
|
101
|
+
response = await answer(remote, frame.request);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
response = { ok: false, error: errorText(error) };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
await reply(remote, frame.id, response);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return {
|
|
110
|
+
changed() {
|
|
111
|
+
const online = new Set(relay.attached ? hosts.online() : []);
|
|
112
|
+
|
|
113
|
+
for (const [remote, abort] of links)
|
|
114
|
+
if (!online.has(remote)) {
|
|
115
|
+
abort.abort();
|
|
116
|
+
links.delete(remote);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const remote of online) {
|
|
120
|
+
if (links.has(remote)) continue;
|
|
121
|
+
const abort = new AbortController();
|
|
122
|
+
links.set(remote, abort);
|
|
123
|
+
|
|
124
|
+
keepOpen({
|
|
125
|
+
hosts,
|
|
126
|
+
remote,
|
|
127
|
+
path: "/control",
|
|
128
|
+
signal: abort.signal,
|
|
129
|
+
read: (body) =>
|
|
130
|
+
readLines(body, (line) => {
|
|
131
|
+
if (line.startsWith("data: "))
|
|
132
|
+
void push(remote, line.slice("data: ".length));
|
|
133
|
+
}),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
},
|
|
137
|
+
close() {
|
|
138
|
+
for (const abort of links.values()) abort.abort();
|
|
139
|
+
links.clear();
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
import { Readable } from "node:stream";
|
|
2
|
+
import type { ReadableStream as WebReadableStream } from "node:stream/web";
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type ReviewApiSummary,
|
|
6
|
+
type ReviewGatewayHostState,
|
|
7
|
+
parseJsonText,
|
|
8
|
+
reviewHostStatus,
|
|
9
|
+
} from "@dev.fast/review-protocol";
|
|
10
|
+
import { coverageModeSchema } from "@review/review-api/review-progress.js";
|
|
11
|
+
import { z } from "zod";
|
|
12
|
+
|
|
13
|
+
import type { GatewayHosts, GatewayRemote } from "./review-gateway-hosts.js";
|
|
14
|
+
import { type ListSource, mergeLists } from "./review-gateway-list.js";
|
|
15
|
+
import {
|
|
16
|
+
type GatewayMemory,
|
|
17
|
+
LIST_MODES,
|
|
18
|
+
type ListMode,
|
|
19
|
+
listEntriesSchema,
|
|
20
|
+
} from "./review-gateway-memory.js";
|
|
21
|
+
import { keepOpen, readLines, reconnect } from "./review-gateway-transport.js";
|
|
22
|
+
import { serverJson } from "./review-server-core.js";
|
|
23
|
+
|
|
24
|
+
const subscriptionsSchema = z.array(
|
|
25
|
+
z.strictObject({
|
|
26
|
+
reviewId: z.string().min(1).nullable(),
|
|
27
|
+
mode: coverageModeSchema,
|
|
28
|
+
}),
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
const lineSchema = z.discriminatedUnion("kind", [
|
|
32
|
+
z.object({
|
|
33
|
+
kind: z.literal("list"),
|
|
34
|
+
mode: z.enum(LIST_MODES),
|
|
35
|
+
reviews: z.array(z.unknown()),
|
|
36
|
+
}),
|
|
37
|
+
z.object({
|
|
38
|
+
kind: z.literal("review"),
|
|
39
|
+
reviewId: z.string(),
|
|
40
|
+
value: z.unknown().optional(),
|
|
41
|
+
}),
|
|
42
|
+
]);
|
|
43
|
+
|
|
44
|
+
export type Located =
|
|
45
|
+
| "local"
|
|
46
|
+
| { remote: GatewayRemote }
|
|
47
|
+
| { down: ReviewGatewayHostState }
|
|
48
|
+
| undefined;
|
|
49
|
+
|
|
50
|
+
export const downDetail = (down: ReviewGatewayHostState) =>
|
|
51
|
+
reviewHostStatus(down).sentence;
|
|
52
|
+
|
|
53
|
+
const watchPath = (subscriptions: object[]) =>
|
|
54
|
+
`/reviews-api/watch?subscriptions=${encodeURIComponent(JSON.stringify(subscriptions))}`;
|
|
55
|
+
|
|
56
|
+
function parseLine(text: string) {
|
|
57
|
+
try {
|
|
58
|
+
return lineSchema.safeParse(parseJsonText(text)).data;
|
|
59
|
+
} catch {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function createGatewayStreams(input: {
|
|
65
|
+
hosts: GatewayHosts;
|
|
66
|
+
memory: GatewayMemory;
|
|
67
|
+
local(request: Request): Response | Promise<Response>;
|
|
68
|
+
lists(): ListSource;
|
|
69
|
+
locate(reviewId: string): Located;
|
|
70
|
+
listed(): void;
|
|
71
|
+
}) {
|
|
72
|
+
const { hosts, memory } = input;
|
|
73
|
+
const feeds = new Map<GatewayRemote, AbortController>();
|
|
74
|
+
const clients = new Set<Client>();
|
|
75
|
+
const passThrough = new Set<() => void>();
|
|
76
|
+
|
|
77
|
+
const merge = (mode: ListMode, local: ReviewApiSummary[]) =>
|
|
78
|
+
mergeLists(mode, local, input.lists());
|
|
79
|
+
|
|
80
|
+
function feedLine(remote: GatewayRemote, text: string) {
|
|
81
|
+
const line = parseLine(text);
|
|
82
|
+
|
|
83
|
+
if (line?.kind !== "list" || remote.serverId === undefined) return;
|
|
84
|
+
|
|
85
|
+
memory.setList(
|
|
86
|
+
remote.serverId,
|
|
87
|
+
remote.alias,
|
|
88
|
+
line.mode,
|
|
89
|
+
listEntriesSchema.parse(line.reviews),
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
input.listed();
|
|
93
|
+
|
|
94
|
+
for (const client of clients) client.refresh();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function syncFeeds() {
|
|
98
|
+
const online = new Set(hosts.online());
|
|
99
|
+
|
|
100
|
+
for (const [remote, abort] of feeds)
|
|
101
|
+
if (!online.has(remote)) {
|
|
102
|
+
abort.abort();
|
|
103
|
+
feeds.delete(remote);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
for (const remote of online) {
|
|
107
|
+
if (feeds.has(remote)) continue;
|
|
108
|
+
const abort = new AbortController();
|
|
109
|
+
feeds.set(remote, abort);
|
|
110
|
+
|
|
111
|
+
keepOpen({
|
|
112
|
+
hosts,
|
|
113
|
+
remote,
|
|
114
|
+
path: watchPath(LIST_MODES.map((mode) => ({ reviewId: null, mode }))),
|
|
115
|
+
signal: abort.signal,
|
|
116
|
+
read: (body) => readLines(body, (text) => feedLine(remote, text)),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
interface Client {
|
|
122
|
+
refresh(): void;
|
|
123
|
+
stop(): void;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function openClient(
|
|
127
|
+
subscriptions: z.infer<typeof subscriptionsSchema>,
|
|
128
|
+
signal: AbortSignal,
|
|
129
|
+
): Response {
|
|
130
|
+
const modes = new Set<ListMode>();
|
|
131
|
+
const reviews = new Map<string, ListMode>();
|
|
132
|
+
|
|
133
|
+
for (const { reviewId, mode } of subscriptions)
|
|
134
|
+
if (reviewId === null) modes.add(mode);
|
|
135
|
+
else if (!reviews.has(reviewId)) reviews.set(reviewId, mode);
|
|
136
|
+
|
|
137
|
+
const encoder = new TextEncoder();
|
|
138
|
+
const pending = new Map<string, string>();
|
|
139
|
+
const sent = new Map<string, string>();
|
|
140
|
+
const localLists = new Map<ListMode, ReviewApiSummary[]>();
|
|
141
|
+
const reportedDown = new Set<string>();
|
|
142
|
+
|
|
143
|
+
const upstreams = new Map<
|
|
144
|
+
"local" | GatewayRemote,
|
|
145
|
+
{ key: string; abort: AbortController }
|
|
146
|
+
>();
|
|
147
|
+
|
|
148
|
+
let controller: ReadableStreamDefaultController<Uint8Array> | undefined;
|
|
149
|
+
let flushing = false;
|
|
150
|
+
let stopped = false;
|
|
151
|
+
|
|
152
|
+
const flush = () => {
|
|
153
|
+
if (!controller || !pending.size) return;
|
|
154
|
+
|
|
155
|
+
if (controller.desiredSize === null || controller.desiredSize <= 0)
|
|
156
|
+
return;
|
|
157
|
+
const lines: string[] = [];
|
|
158
|
+
|
|
159
|
+
for (const [item, line] of pending)
|
|
160
|
+
if (sent.get(item) !== line) {
|
|
161
|
+
lines.push(line);
|
|
162
|
+
sent.set(item, line);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
pending.clear();
|
|
166
|
+
|
|
167
|
+
if (lines.length)
|
|
168
|
+
controller.enqueue(encoder.encode(`${lines.join("\n")}\n`));
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const emit = (item: string, line: string) => {
|
|
172
|
+
if (stopped) return;
|
|
173
|
+
pending.set(item, line);
|
|
174
|
+
|
|
175
|
+
if (flushing) return;
|
|
176
|
+
flushing = true;
|
|
177
|
+
queueMicrotask(() => {
|
|
178
|
+
flushing = false;
|
|
179
|
+
flush();
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const emitList = (mode: ListMode) => {
|
|
184
|
+
const local = localLists.get(mode);
|
|
185
|
+
|
|
186
|
+
if (local)
|
|
187
|
+
emit(
|
|
188
|
+
`list:${mode}`,
|
|
189
|
+
JSON.stringify({ kind: "list", mode, reviews: merge(mode, local) }),
|
|
190
|
+
);
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
const emitReview = (reviewId: string, text: string) => {
|
|
194
|
+
reportedDown.delete(reviewId);
|
|
195
|
+
emit(`review:${reviewId}`, text);
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const down = (reviewId: string, state: ReviewGatewayHostState) => {
|
|
199
|
+
if (reportedDown.has(reviewId)) return;
|
|
200
|
+
reportedDown.add(reviewId);
|
|
201
|
+
emit(
|
|
202
|
+
`review:${reviewId}`,
|
|
203
|
+
JSON.stringify({ kind: "review", reviewId, error: downDetail(state) }),
|
|
204
|
+
);
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const openLocal = (ids: Map<string, ListMode>, abort: AbortSignal) => {
|
|
208
|
+
const path = watchPath([
|
|
209
|
+
...[...modes].map((mode) => ({ reviewId: null, mode })),
|
|
210
|
+
...[...ids].map(([reviewId, mode]) => ({ reviewId, mode })),
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
void reconnect(abort, async () => {
|
|
214
|
+
try {
|
|
215
|
+
const response = await input.local(
|
|
216
|
+
new Request(`http://gateway${path}`),
|
|
217
|
+
);
|
|
218
|
+
|
|
219
|
+
if (response.body)
|
|
220
|
+
await readLines(
|
|
221
|
+
// SAFETY: Node's Response body is its own web stream; the DOM
|
|
222
|
+
// type only names the same object.
|
|
223
|
+
Readable.fromWeb(response.body as WebReadableStream, {
|
|
224
|
+
signal: abort,
|
|
225
|
+
}),
|
|
226
|
+
(text) => {
|
|
227
|
+
const line = parseLine(text);
|
|
228
|
+
|
|
229
|
+
if (line?.kind === "list" && modes.has(line.mode)) {
|
|
230
|
+
// SAFETY: the local server writes ReviewApiSummary entries.
|
|
231
|
+
localLists.set(line.mode, line.reviews as ReviewApiSummary[]);
|
|
232
|
+
emitList(line.mode);
|
|
233
|
+
} else if (line?.kind === "review" && ids.has(line.reviewId))
|
|
234
|
+
emitReview(line.reviewId, text);
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
} catch {}
|
|
238
|
+
});
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const openRemote = (
|
|
242
|
+
remote: GatewayRemote,
|
|
243
|
+
ids: Map<string, ListMode>,
|
|
244
|
+
abort: AbortSignal,
|
|
245
|
+
) =>
|
|
246
|
+
keepOpen({
|
|
247
|
+
hosts,
|
|
248
|
+
remote,
|
|
249
|
+
path: watchPath(
|
|
250
|
+
[...ids].map(([reviewId, mode]) => ({ reviewId, mode })),
|
|
251
|
+
),
|
|
252
|
+
signal: abort,
|
|
253
|
+
read: (body) =>
|
|
254
|
+
readLines(body, (text) => {
|
|
255
|
+
const line = parseLine(text);
|
|
256
|
+
|
|
257
|
+
if (line?.kind === "review" && ids.has(line.reviewId))
|
|
258
|
+
emitReview(line.reviewId, text);
|
|
259
|
+
}),
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
const client: Client = {
|
|
263
|
+
refresh() {
|
|
264
|
+
if (stopped) return;
|
|
265
|
+
|
|
266
|
+
for (const mode of modes) emitList(mode);
|
|
267
|
+
|
|
268
|
+
const wanted = new Map<
|
|
269
|
+
"local" | GatewayRemote,
|
|
270
|
+
Map<string, ListMode>
|
|
271
|
+
>();
|
|
272
|
+
|
|
273
|
+
const want = (key: "local" | GatewayRemote, reviewId: string) => {
|
|
274
|
+
let ids = wanted.get(key);
|
|
275
|
+
|
|
276
|
+
if (!ids) wanted.set(key, (ids = new Map()));
|
|
277
|
+
ids.set(reviewId, reviews.get(reviewId)!);
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
if (modes.size) wanted.set("local", new Map());
|
|
281
|
+
|
|
282
|
+
for (const reviewId of reviews.keys()) {
|
|
283
|
+
const located = input.locate(reviewId);
|
|
284
|
+
|
|
285
|
+
if (located === undefined || located === "local")
|
|
286
|
+
want("local", reviewId);
|
|
287
|
+
else if ("remote" in located) want(located.remote, reviewId);
|
|
288
|
+
else if (located.down.state !== "connecting")
|
|
289
|
+
down(reviewId, located.down);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
for (const [key, upstream] of upstreams) {
|
|
293
|
+
const ids = wanted.get(key);
|
|
294
|
+
|
|
295
|
+
if (ids && JSON.stringify([...ids]) === upstream.key) continue;
|
|
296
|
+
upstream.abort.abort();
|
|
297
|
+
upstreams.delete(key);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
for (const [key, ids] of wanted) {
|
|
301
|
+
if (upstreams.has(key)) continue;
|
|
302
|
+
const abort = new AbortController();
|
|
303
|
+
upstreams.set(key, { key: JSON.stringify([...ids]), abort });
|
|
304
|
+
|
|
305
|
+
if (key === "local") openLocal(ids, abort.signal);
|
|
306
|
+
else openRemote(key, ids, abort.signal);
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
stop() {
|
|
310
|
+
if (stopped) return;
|
|
311
|
+
stopped = true;
|
|
312
|
+
clients.delete(client);
|
|
313
|
+
|
|
314
|
+
for (const upstream of upstreams.values()) upstream.abort.abort();
|
|
315
|
+
upstreams.clear();
|
|
316
|
+
|
|
317
|
+
try {
|
|
318
|
+
controller?.close();
|
|
319
|
+
} catch {}
|
|
320
|
+
},
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
signal.addEventListener("abort", () => client.stop(), { once: true });
|
|
324
|
+
|
|
325
|
+
const body = new ReadableStream<Uint8Array>({
|
|
326
|
+
start(started) {
|
|
327
|
+
controller = started;
|
|
328
|
+
clients.add(client);
|
|
329
|
+
client.refresh();
|
|
330
|
+
},
|
|
331
|
+
pull: flush,
|
|
332
|
+
cancel: () => client.stop(),
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
return new Response(body, {
|
|
336
|
+
headers: {
|
|
337
|
+
"content-type": "application/x-ndjson",
|
|
338
|
+
"cache-control": "no-store",
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
async function passLocal(request: Request): Promise<Response> {
|
|
344
|
+
const response = await input.local(request);
|
|
345
|
+
|
|
346
|
+
if (!response.body) return response;
|
|
347
|
+
let stop = () => {};
|
|
348
|
+
|
|
349
|
+
const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>({
|
|
350
|
+
start(controller) {
|
|
351
|
+
stop = () => controller.terminate();
|
|
352
|
+
},
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
passThrough.add(stop);
|
|
356
|
+
void response.body
|
|
357
|
+
.pipeTo(writable)
|
|
358
|
+
.catch(() => undefined)
|
|
359
|
+
.finally(() => passThrough.delete(stop));
|
|
360
|
+
|
|
361
|
+
return new Response(readable, response);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
watch(request: Request): Response | Promise<Response> {
|
|
366
|
+
if (!hosts.states().length) return passLocal(request);
|
|
367
|
+
let subscriptions: z.infer<typeof subscriptionsSchema>;
|
|
368
|
+
|
|
369
|
+
try {
|
|
370
|
+
subscriptions = subscriptionsSchema.parse(
|
|
371
|
+
JSON.parse(
|
|
372
|
+
new URL(request.url).searchParams.get("subscriptions") ?? "",
|
|
373
|
+
),
|
|
374
|
+
);
|
|
375
|
+
} catch {
|
|
376
|
+
return input.local(request);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return openClient(subscriptions, request.signal);
|
|
380
|
+
},
|
|
381
|
+
async list(request: Request): Promise<Response> {
|
|
382
|
+
const response = await input.local(request);
|
|
383
|
+
|
|
384
|
+
if (!response.ok || !hosts.states().length) return response;
|
|
385
|
+
|
|
386
|
+
const mode =
|
|
387
|
+
coverageModeSchema.safeParse(
|
|
388
|
+
new URL(request.url).searchParams.get("mode") ?? undefined,
|
|
389
|
+
).data ?? "structural";
|
|
390
|
+
|
|
391
|
+
const local: unknown = await response.json();
|
|
392
|
+
|
|
393
|
+
// SAFETY: the local server answers ReviewApiSummary[].
|
|
394
|
+
return serverJson(200, merge(mode, local as ReviewApiSummary[]));
|
|
395
|
+
},
|
|
396
|
+
changed() {
|
|
397
|
+
syncFeeds();
|
|
398
|
+
|
|
399
|
+
if (hosts.states().length) {
|
|
400
|
+
for (const stop of passThrough) stop();
|
|
401
|
+
passThrough.clear();
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
for (const client of clients) client.refresh();
|
|
405
|
+
},
|
|
406
|
+
close() {
|
|
407
|
+
for (const abort of feeds.values()) abort.abort();
|
|
408
|
+
feeds.clear();
|
|
409
|
+
|
|
410
|
+
for (const stop of passThrough) stop();
|
|
411
|
+
|
|
412
|
+
for (const client of clients) client.stop();
|
|
413
|
+
},
|
|
414
|
+
};
|
|
415
|
+
}
|