authorgravatar for chris@xenforo.comChris Deeming <chris@xenforo.com> 2026-08-14 03:10:41+01:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-13 22:10:41-04:00
log9fd788b5a92254a2afa72c2a53513ed2cb730f0d
tree9227ad1b9feb50f353cadb33ce253881e0edd2ca
parent710fd0eeb3b4c4146c28fa0fc558ac4c236a8067
signature Signed by PGP key B5690EEEBB952194

fix(preview): only show browser-ready local servers (#6021)


15 files changed, 1065 insertions(+), 213 deletions(-)

apps/server/src/preview/PortScanner.test.ts+532-25
...@@ -1,35 +1,60 @@...@@ -1,35 +1,60 @@
1import * as NodeNet from "node:net";1import * as NodeNet from "node:net";
22
3import { it as effectIt } from "@effect/vitest";3import { it as effectIt } from "@effect/vitest";
4import {
5 CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS,
6 PREVIEW_URL_MAX_LENGTH,
7 type DiscoveredLocalServer,
8} from "@t3tools/contracts";
4import { HostProcessPlatform } from "@t3tools/shared/hostProcess";9import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
5import * as Net from "@t3tools/shared/Net";10import * as Net from "@t3tools/shared/Net";
6import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
12import * as Clock from "effect/Clock";
13import * as Duration from "effect/Duration";
7import * as Effect from "effect/Effect";14import * as Effect from "effect/Effect";
8import * as Exit from "effect/Exit";15import * as Exit from "effect/Exit";
16import * as Fiber from "effect/Fiber";
9import * as Layer from "effect/Layer";17import * as Layer from "effect/Layer";
10import * as PlatformError from "effect/PlatformError";18import * as PlatformError from "effect/PlatformError";
19import * as Scope from "effect/Scope";
20import * as TestClock from "effect/testing/TestClock";
11import { expect } from "vite-plus/test";21import { expect } from "vite-plus/test";
22import { FetchHttpClient } from "effect/unstable/http";
1223
13import * as ProcessRunner from "../processRunner.ts";24import * as ProcessRunner from "../processRunner.ts";
14import * as PortScanner from "./PortScanner.ts";25import * as PortScanner from "./PortScanner.ts";
15const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {26const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (input) =>
16 run: (input) =>27 Effect.fail(
17 Effect.fail(28 new ProcessRunner.ProcessSpawnError({
18 new ProcessRunner.ProcessSpawnError({29 command: input.command,
19 command: input.command,30 argumentCount: input.args.length,
20 argumentCount: input.args.length,31 cwd: input.cwd,
21 cwd: input.cwd,32 cause: PlatformError.systemError({
22 cause: PlatformError.systemError({33 _tag: "NotFound",
23 _tag: "NotFound",34 module: "ChildProcess",
24 module: "ChildProcess",35 method: "spawn",
25 method: "spawn",36 description: "PowerShell is not installed in the test environment",
26 description: "PowerShell is not installed in the test environment",
27 }),
28 }),37 }),
29 ),38 }),
39 );
40
41const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
42 run: processProbeFailure,
30});43});
3144
32const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) =>45let integrationListeningPort: number | null = null;
46
47const TestIntegrationNet = Layer.succeed(Net.NetService, {
48 canListenOnHost: () => Effect.succeed(true),
49 isPortAvailableOnLoopback: (port) => Effect.sync(() => port !== integrationListeningPort),
50 reserveLoopbackPort: () => Effect.succeed(40_000),
51 findAvailablePort: (preferred) => Effect.succeed(preferred),
52});
53
54const makeProbeFailureLayer = (
55 run: ProcessRunner.ProcessRunner["Service"]["run"],
56 fetch: typeof globalThis.fetch = globalThis.fetch,
57) =>
33 PortScanner.layer.pipe(58 PortScanner.layer.pipe(
34 Layer.provide(59 Layer.provide(
35 Layer.mergeAll(60 Layer.mergeAll(
...@@ -41,19 +66,64 @@ const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"...@@ -41,19 +66,64 @@ const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"
41 findAvailablePort: (preferred) => Effect.succeed(preferred),66 findAvailablePort: (preferred) => Effect.succeed(preferred),
42 }),67 }),
43 Layer.succeed(HostProcessPlatform, "linux"),68 Layer.succeed(HostProcessPlatform, "linux"),
69 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))),
44 ),70 ),
45 ),71 ),
46 );72 );
4773
48const TestPortDiscoveryLive = PortScanner.layer.pipe(74const TestPortDiscoveryLive = PortScanner.layer.pipe(
49 Layer.provide(75 Layer.provide(
50 Layer.mergeAll(TestProcessRunner, Net.layer, Layer.succeed(HostProcessPlatform, "win32")),76 Layer.mergeAll(
77 TestProcessRunner,
78 TestIntegrationNet,
79 Layer.succeed(HostProcessPlatform, "win32"),
80 FetchHttpClient.layer,
81 ),
51 ),82 ),
52);83);
5384
54const openServer = (port: number): Effect.Effect<NodeNet.Server | null> =>85const LSOF_TEST_PORT = 43_123;
86
87const makeLsofScannerLayer = (input: {
88 readonly pid: () => number;
89 readonly fetch: typeof globalThis.fetch;
90}) =>
91 PortScanner.layer.pipe(
92 Layer.provide(
93 Layer.mergeAll(
94 Layer.succeed(ProcessRunner.ProcessRunner, {
95 run: () =>
96 Effect.succeed({
97 stdout: `p${input.pid()}\ncnode\nn*:${LSOF_TEST_PORT}\n`,
98 stderr: "",
99 code: null,
100 timedOut: false,
101 stdoutTruncated: false,
102 stderrTruncated: false,
103 stdoutInvalidUtf8: false,
104 stderrInvalidUtf8: false,
105 }),
106 }),
107 Layer.succeed(Net.NetService, {
108 canListenOnHost: () => Effect.succeed(true),
109 isPortAvailableOnLoopback: () => Effect.succeed(true),
110 reserveLoopbackPort: () => Effect.succeed(40_000),
111 findAvailablePort: (preferred) => Effect.succeed(preferred),
112 }),
113 Layer.succeed(HostProcessPlatform, "linux"),
114 FetchHttpClient.layer.pipe(
115 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, input.fetch)),
116 ),
117 ),
118 ),
119 );
120
121const openServer = (
122 port: number,
123 onConnection: (socket: NodeNet.Socket) => void,
124): Effect.Effect<NodeNet.Server | null> =>
55 Effect.callback((resume) => {125 Effect.callback((resume) => {
56 const server = NodeNet.createServer();126 const server = NodeNet.createServer(onConnection);
57 server.once("error", () => {127 server.once("error", () => {
58 resume(Effect.succeed(null));128 resume(Effect.succeed(null));
59 });129 });
...@@ -72,9 +142,10 @@ const closeServer = (server: NodeNet.Server): Effect.Effect<void> =>...@@ -72,9 +142,10 @@ const closeServer = (server: NodeNet.Server): Effect.Effect<void> =>
72142
73const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (143const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (
74 ports: ReadonlyArray<number>,144 ports: ReadonlyArray<number>,
145 onConnection: (socket: NodeNet.Socket) => void,
75) {146) {
76 for (const port of ports) {147 for (const port of ports) {
77 const server = yield* openServer(port);148 const server = yield* openServer(port, onConnection);
78 if (server !== null) return { port, server };149 if (server !== null) return { port, server };
79 }150 }
80 return yield* Effect.die(151 return yield* Effect.die(
...@@ -83,8 +154,46 @@ const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(fun...@@ -83,8 +154,46 @@ const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(fun
83});154});
84155
85const commonDevServer = Effect.acquireRelease(156const commonDevServer = Effect.acquireRelease(
86 openCommonDevServer(PortScanner.COMMON_DEV_PORTS),157 openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => {
87 ({ server }) => closeServer(server),158 socket.once("data", () => {
159 socket.end("HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: 5\r\n\r\nhello");
160 });
161 }).pipe(
162 Effect.tap(({ port }) =>
163 Effect.sync(() => {
164 integrationListeningPort = port;
165 }),
166 ),
167 ),
168 ({ server }) =>
169 closeServer(server).pipe(
170 Effect.ensuring(
171 Effect.sync(() => {
172 integrationListeningPort = null;
173 }),
174 ),
175 ),
176);
177
178const commonNonHttpServer = Effect.acquireRelease(
179 openCommonDevServer(PortScanner.COMMON_DEV_PORTS.toReversed(), (socket) => {
180 socket.on("error", () => undefined);
181 socket.once("data", () => socket.end("MYSQL\r\n\r\n"));
182 }).pipe(
183 Effect.tap(({ port }) =>
184 Effect.sync(() => {
185 integrationListeningPort = port;
186 }),
187 ),
188 ),
189 ({ server }) =>
190 closeServer(server).pipe(
191 Effect.ensuring(
192 Effect.sync(() => {
193 integrationListeningPort = null;
194 }),
195 ),
196 ),
88);197);
89198
90/**199/**
...@@ -94,7 +203,7 @@ const commonDevServer = Effect.acquireRelease(...@@ -94,7 +203,7 @@ const commonDevServer = Effect.acquireRelease(
94 */203 */
95effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => {204effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => {
96 it.effect(205 it.effect(
97 "scan() returns a server we just opened on a curated dev port",206 "scan() returns an HTTP server we just opened on a curated dev port",
98 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {207 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {
99 const { port } = yield* commonDevServer;208 const { port } = yield* commonDevServer;
100 const scanner = yield* PortScanner.PortDiscovery;209 const scanner = yield* PortScanner.PortDiscovery;
...@@ -105,13 +214,23 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall...@@ -105,13 +214,23 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
105 }),214 }),
106 );215 );
107216
217 it.effect(
218 "scan() excludes a listening port that does not speak HTTP",
219 Effect.fn("PortScannerTest.scanExcludesNonHttpServer")(function* () {
220 const { port } = yield* commonNonHttpServer;
221 const scanner = yield* PortScanner.PortDiscovery;
222 const result = yield* scanner.scan();
223 expect(result.some((server) => server.port === port)).toBe(false);
224 }),
225 );
226
108 it.effect(227 it.effect(
109 "retain drives an immediate broadcast to subscribers",228 "retain drives an immediate broadcast to subscribers",
110 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {229 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {
111 const { port } = yield* commonDevServer;230 const { port } = yield* commonDevServer;
112 const received: number[] = [];231 const received: number[] = [];
113 const scanner = yield* PortScanner.PortDiscovery;232 const scanner = yield* PortScanner.PortDiscovery;
114 yield* scanner.subscribe((servers) =>233 yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) =>
115 Effect.sync(() => {234 Effect.sync(() => {
116 for (const server of servers) received.push(server.port);235 for (const server of servers) received.push(server.port);
117 }),236 }),
...@@ -122,7 +241,395 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall...@@ -122,7 +241,395 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
122 );241 );
123});242});
124243
125effectIt("does not swallow process probe defects", () =>244effectIt.effect("revalidates a successful HTML probe after its cache entry expires", () => {
245 let responds = true;
246 const requests: string[] = [];
247 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
248 requests.push(String(input));
249 return responds
250 ? Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } }))
251 : Promise.reject(new TypeError("not HTTP"));
252 }) as typeof globalThis.fetch;
253 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
254
255 return Effect.gen(function* () {
256 const scanner = yield* PortScanner.PortDiscovery;
257 expect(yield* scanner.scan()).toHaveLength(1);
258 expect(yield* scanner.scan()).toHaveLength(1);
259 expect(requests).toEqual([`http://localhost:${LSOF_TEST_PORT}/`]);
260
261 responds = false;
262 yield* TestClock.adjust(Duration.seconds(15));
263 expect(yield* scanner.scan()).toHaveLength(0);
264 expect(requests).toEqual([
265 `http://localhost:${LSOF_TEST_PORT}/`,
266 `http://localhost:${LSOF_TEST_PORT}/`,
267 `https://localhost:${LSOF_TEST_PORT}/`,
268 ]);
269 }).pipe(Effect.provide(layer));
270});
271
272effectIt.effect("keeps a full configured URL when the discovered server root fails", () => {
273 const requests: string[] = [];
274 const configuredUrl = `http://localhost:${LSOF_TEST_PORT}/docs`;
275 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
276 const url = String(input);
277 requests.push(url);
278 return Promise.resolve(
279 url === configuredUrl
280 ? new Response("docs", { headers: { "content-type": "text/html" } })
281 : new Response("not found", {
282 status: 404,
283 headers: { "content-type": "text/html" },
284 }),
285 );
286 }) as typeof globalThis.fetch;
287 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
288
289 return Effect.gen(function* () {
290 const scanner = yield* PortScanner.PortDiscovery;
291 const servers = yield* scanner.scan([configuredUrl]);
292 expect(servers).toHaveLength(1);
293 expect(servers[0]?.url).toBe(configuredUrl);
294 expect(requests).toContain(configuredUrl);
295 }).pipe(Effect.provide(layer));
296});
297
298effectIt.effect("probes configured custom ports through a canonical loopback host", () => {
299 const customPort = 43_124;
300 const configuredUrl = `http://0.0.0.0:${customPort}/docs`;
301 const expectedUrl = `http://localhost:${customPort}/docs`;
302 const requests: string[] = [];
303 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
304 requests.push(String(input));
305 return Promise.resolve(new Response("docs", { headers: { "content-type": "text/html" } }));
306 }) as typeof globalThis.fetch;
307 const layer = makeProbeFailureLayer(processProbeFailure, fetchFn);
308
309 return Effect.gen(function* () {
310 const scanner = yield* PortScanner.PortDiscovery;
311 const servers = yield* scanner.scan([configuredUrl]);
312 expect(servers).toHaveLength(1);
313 expect(servers[0]?.host).toBe("localhost");
314 expect(servers[0]?.port).toBe(customPort);
315 expect(servers[0]?.url).toBe(expectedUrl);
316 expect(requests).toEqual([expectedUrl]);
317 }).pipe(Effect.provide(layer));
318});
319
320effectIt.effect("preserves explicit loopback hosts and bounds wildcard rewrites", () => {
321 const ipv4Url = "https://127.0.0.1:43125/docs";
322 const ipv6Url = "http://[::1]:43126/docs";
323 const wildcardPrefix = "http://0.0.0.0/";
324 const maximumWildcardUrl = `${wildcardPrefix}${"a".repeat(
325 PREVIEW_URL_MAX_LENGTH - wildcardPrefix.length,
326 )}`;
327 const requests: string[] = [];
328 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
329 requests.push(String(input));
330 return Promise.resolve(new Response("docs", { headers: { "content-type": "text/html" } }));
331 }) as typeof globalThis.fetch;
332 const layer = makeProbeFailureLayer(processProbeFailure, fetchFn);
333
334 return Effect.gen(function* () {
335 const scanner = yield* PortScanner.PortDiscovery;
336 const servers = yield* scanner.scan([ipv4Url, ipv6Url, maximumWildcardUrl]);
337 expect(servers.map((server) => server.url)).toEqual([ipv4Url, ipv6Url]);
338 expect(requests).toEqual([ipv4Url, ipv6Url]);
339 }).pipe(Effect.provide(layer));
340});
341
342effectIt.effect("projects configured paths independently for simultaneous subscribers", () => {
343 const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`;
344 const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`;
345 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
346 const url = String(input);
347 return Promise.resolve(
348 url === docsUrl || url === adminUrl
349 ? new Response("app", { headers: { "content-type": "text/html" } })
350 : new Response("not found", { status: 404 }),
351 );
352 }) as typeof globalThis.fetch;
353 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
354
355 return Effect.gen(function* () {
356 const scanner = yield* PortScanner.PortDiscovery;
357 const docsSnapshots: ReadonlyArray<DiscoveredLocalServer>[] = [];
358 const adminSnapshots: ReadonlyArray<DiscoveredLocalServer>[] = [];
359 yield* scanner.subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, (servers) =>
360 Effect.sync(() => docsSnapshots.push(servers)),
361 );
362 yield* scanner.subscribe({ configuredUrls: [adminUrl], initialSnapshot: [] }, (servers) =>
363 Effect.sync(() => adminSnapshots.push(servers)),
364 );
365 yield* scanner.retain;
366
367 expect(docsSnapshots.at(-1)?.[0]?.url).toBe(docsUrl);
368 expect(adminSnapshots.at(-1)?.[0]?.url).toBe(adminUrl);
369 }).pipe(Effect.scoped, Effect.provide(layer));
370});
371
372effectIt.effect(
373 "keeps each subscriber's candidates when their combined union exceeds the per-client cap",
374 () => {
375 const firstSubscriberUrls = Array.from(
376 { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS },
377 (_, index) => `http://localhost:${LSOF_TEST_PORT}/app-${index}`,
378 );
379 const secondSubscriberUrl = `http://localhost:${LSOF_TEST_PORT}/app-${CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS}`;
380 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) =>
381 Promise.resolve(
382 String(input) === secondSubscriberUrl
383 ? new Response("app", { headers: { "content-type": "text/html" } })
384 : new Response("not found", { status: 404 }),
385 )) as typeof globalThis.fetch;
386 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
387
388 return Effect.gen(function* () {
389 const scanner = yield* PortScanner.PortDiscovery;
390 const secondSnapshots: ReadonlyArray<DiscoveredLocalServer>[] = [];
391 yield* scanner.subscribe(
392 { configuredUrls: firstSubscriberUrls, initialSnapshot: [] },
393 () => Effect.void,
394 );
395 yield* scanner.subscribe(
396 { configuredUrls: [secondSubscriberUrl], initialSnapshot: [] },
397 (servers) => Effect.sync(() => secondSnapshots.push(servers)),
398 );
399 yield* scanner.retain;
400
401 expect(secondSnapshots.at(-1)?.[0]?.url).toBe(secondSubscriberUrl);
402 }).pipe(Effect.scoped, Effect.provide(layer));
403 },
404);
405
406effectIt.effect("stops probing a subscriber's configured paths after its scope closes", () => {
407 const docsUrl = `http://localhost:${LSOF_TEST_PORT}/docs`;
408 const adminUrl = `http://localhost:${LSOF_TEST_PORT}/admin`;
409 const requests: string[] = [];
410 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
411 const url = String(input);
412 requests.push(url);
413 return Promise.resolve(
414 url === docsUrl || url === adminUrl
415 ? new Response("app", { headers: { "content-type": "text/html" } })
416 : new Response("not found", { status: 404 }),
417 );
418 }) as typeof globalThis.fetch;
419 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
420
421 return Effect.gen(function* () {
422 const scanner = yield* PortScanner.PortDiscovery;
423 const docsScope = yield* Scope.make();
424 yield* scanner
425 .subscribe({ configuredUrls: [docsUrl], initialSnapshot: [] }, () => Effect.void)
426 .pipe(Effect.provideService(Scope.Scope, docsScope));
427 yield* scanner.subscribe(
428 { configuredUrls: [adminUrl], initialSnapshot: [] },
429 () => Effect.void,
430 );
431 yield* scanner.retain;
432 yield* Scope.close(docsScope, Exit.void);
433
434 requests.length = 0;
435 yield* TestClock.adjust(Duration.seconds(15));
436 expect(requests).toContain(adminUrl);
437 expect(requests).not.toContain(docsUrl);
438 }).pipe(Effect.scoped, Effect.provide(layer));
439});
440
441effectIt.effect("uses the current configured fragment when readiness comes from cache", () => {
442 const requests: string[] = [];
443 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
444 requests.push(String(input));
445 return Promise.resolve(new Response("docs", { headers: { "content-type": "text/html" } }));
446 }) as typeof globalThis.fetch;
447 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
448 const oldUrl = `http://localhost:${LSOF_TEST_PORT}/docs#old`;
449 const newUrl = `http://localhost:${LSOF_TEST_PORT}/docs#new`;
450
451 return Effect.gen(function* () {
452 const scanner = yield* PortScanner.PortDiscovery;
453 expect((yield* scanner.scan([oldUrl]))[0]?.url).toBe(oldUrl);
454 const requestCount = requests.length;
455 expect((yield* scanner.scan([newUrl]))[0]?.url).toBe(newUrl);
456 expect(requests).toHaveLength(requestCount);
457 }).pipe(Effect.provide(layer));
458});
459
460effectIt.effect("shares a configured root probe with discovered-root classification", () => {
461 const requests: string[] = [];
462 const rootUrl = `http://localhost:${LSOF_TEST_PORT}/`;
463 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
464 requests.push(String(input));
465 return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } }));
466 }) as typeof globalThis.fetch;
467 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
468
469 return Effect.gen(function* () {
470 const scanner = yield* PortScanner.PortDiscovery;
471 expect(yield* scanner.scan([rootUrl])).toHaveLength(1);
472 expect(requests).toEqual([rootUrl]);
473
474 yield* TestClock.adjust(Duration.seconds(15));
475 expect(yield* scanner.scan([rootUrl])).toHaveLength(1);
476 expect(requests).toEqual([rootUrl, rootUrl]);
477 }).pipe(Effect.provide(layer));
478});
479
480effectIt.effect("starts fresh cache entries after the probing batch completes", () =>
481 Effect.gen(function* () {
482 const baseClock = yield* Clock.Clock;
483 const times = [0, 20_000, 20_000, 20_000];
484 let timeIndex = 0;
485 const currentTimeMillis = () => times[Math.min(timeIndex++, times.length - 1)]!;
486 const clock: Clock.Clock = {
487 ...baseClock,
488 currentTimeMillisUnsafe: currentTimeMillis,
489 currentTimeMillis: Effect.sync(currentTimeMillis),
490 };
491 const requests: string[] = [];
492 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
493 requests.push(String(input));
494 return Promise.resolve(new Response("app", { headers: { "content-type": "text/html" } }));
495 }) as typeof globalThis.fetch;
496 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
497
498 yield* Effect.gen(function* () {
499 const scanner = yield* PortScanner.PortDiscovery;
500 expect(yield* scanner.scan()).toHaveLength(1);
501 expect(yield* scanner.scan()).toHaveLength(1);
502 expect(requests).toHaveLength(1);
503 }).pipe(Effect.provide(layer), Effect.provideService(Clock.Clock, clock));
504 }),
505);
506
507effectIt.effect("caches a failed web probe until its bounded cache entry expires", () => {
508 let responds = false;
509 const requests: string[] = [];
510 const fetchFn = ((input: Parameters<typeof globalThis.fetch>[0]) => {
511 requests.push(String(input));
512 return responds
513 ? Promise.resolve(new Response("hello", { headers: { "content-type": "text/html" } }))
514 : Promise.reject(new TypeError("not HTTP"));
515 }) as typeof globalThis.fetch;
516 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
517
518 return Effect.gen(function* () {
519 const scanner = yield* PortScanner.PortDiscovery;
520 expect(yield* scanner.scan()).toHaveLength(0);
521 expect(yield* scanner.scan()).toHaveLength(0);
522 expect(requests).toHaveLength(2);
523
524 responds = true;
525 yield* TestClock.adjust(Duration.seconds(15));
526 expect(yield* scanner.scan()).toHaveLength(1);
527 expect(requests).toHaveLength(3);
528 }).pipe(Effect.provide(layer));
529});
530
531effectIt.effect("falls back to HTTPS and does not follow redirects while probing", () => {
532 const redirects: Array<string | undefined> = [];
533 const fetchFn = (async (
534 input: Parameters<typeof globalThis.fetch>[0],
535 init?: Parameters<typeof globalThis.fetch>[1],
536 ) => {
537 redirects.push(init?.redirect);
538 if (String(input).startsWith("http:")) throw new TypeError("TLS listener");
539 return new Response(null, { status: 302, headers: { location: "https://example.com" } });
540 }) as typeof globalThis.fetch;
541 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
542
543 return Effect.gen(function* () {
544 const scanner = yield* PortScanner.PortDiscovery;
545 const servers = yield* scanner.scan();
546 expect(servers).toHaveLength(1);
547 expect(servers[0]?.url).toBe(`https://localhost:${LSOF_TEST_PORT}`);
548 expect(redirects).toEqual(["manual", "manual"]);
549 }).pipe(Effect.provide(layer));
550});
551
552effectIt.effect(
553 "excludes HTTP errors, non-navigation responses, and successful non-documents",
554 () => {
555 let pid = 1;
556 let makeResponse = () =>
557 new Response("not found", { status: 404, headers: { "content-type": "text/html" } });
558 const fetchFn = ((_input: Parameters<typeof globalThis.fetch>[0]) =>
559 Promise.resolve(makeResponse())) as typeof globalThis.fetch;
560 const layer = makeLsofScannerLayer({ pid: () => pid, fetch: fetchFn });
561
562 return Effect.gen(function* () {
563 const scanner = yield* PortScanner.PortDiscovery;
564 expect(yield* scanner.scan()).toHaveLength(0);
565
566 pid += 1;
567 makeResponse = () =>
568 new Response("{}", { status: 200, headers: { "content-type": "application/json" } });
569 expect(yield* scanner.scan()).toHaveLength(0);
570
571 pid += 1;
572 makeResponse = () =>
573 new Response("ready", { status: 200, headers: { "content-type": "text/plain" } });
574 expect(yield* scanner.scan()).toHaveLength(0);
575
576 pid += 1;
577 makeResponse = () => new Response(null, { status: 304, headers: { location: "/cached" } });
578 expect(yield* scanner.scan()).toHaveLength(0);
579
580 pid += 1;
581 makeResponse = () =>
582 new Response(null, { status: 204, headers: { "content-type": "text/html" } });
583 expect(yield* scanner.scan()).toHaveLength(0);
584
585 pid += 1;
586 makeResponse = () => new Response(null, { status: 302 });
587 expect(yield* scanner.scan()).toHaveLength(0);
588
589 pid += 1;
590 makeResponse = () =>
591 new Response("<html />", {
592 status: 200,
593 headers: { "content-type": "application/xhtml+xml; charset=utf-8" },
594 });
595 expect(yield* scanner.scan()).toHaveLength(1);
596 }).pipe(Effect.provide(layer));
597 },
598);
599
600effectIt.effect("aborts HTTP and HTTPS probes when they time out", () => {
601 const aborted: string[] = [];
602 const fetchFn = ((
603 input: Parameters<typeof globalThis.fetch>[0],
604 init?: Parameters<typeof globalThis.fetch>[1],
605 ) =>
606 new Promise<Response>((_resolve, reject) => {
607 const signal = init?.signal;
608 const onAbort = () => {
609 aborted.push(String(input));
610 reject(new DOMException("Aborted", "AbortError"));
611 };
612 if (signal?.aborted) {
613 onAbort();
614 } else {
615 signal?.addEventListener("abort", onAbort, { once: true });
616 }
617 })) as typeof globalThis.fetch;
618 const layer = makeLsofScannerLayer({ pid: () => 1234, fetch: fetchFn });
619
620 return Effect.gen(function* () {
621 const scanner = yield* PortScanner.PortDiscovery;
622 const scanFiber = yield* Effect.forkChild(scanner.scan());
623 yield* TestClock.adjust(Duration.seconds(2));
624 expect(yield* Fiber.join(scanFiber)).toHaveLength(0);
625 expect(aborted).toEqual([
626 `http://localhost:${LSOF_TEST_PORT}/`,
627 `https://localhost:${LSOF_TEST_PORT}/`,
628 ]);
629 }).pipe(Effect.provide(layer));
630});
631
632effectIt.effect("does not swallow process probe defects", () =>
126 Effect.gen(function* () {633 Effect.gen(function* () {
127 const defect = new Error("unexpected process probe defect");634 const defect = new Error("unexpected process probe defect");
128 const layer = makeProbeFailureLayer(() => Effect.die(defect));635 const layer = makeProbeFailureLayer(() => Effect.die(defect));
...@@ -140,7 +647,7 @@ effectIt("does not swallow process probe defects", () =>...@@ -140,7 +647,7 @@ effectIt("does not swallow process probe defects", () =>
140 }),647 }),
141);648);
142649
143effectIt("does not swallow process probe interruption", () =>650effectIt.effect("does not swallow process probe interruption", () =>
144 Effect.gen(function* () {651 Effect.gen(function* () {
145 const layer = makeProbeFailureLayer(() => Effect.interrupt);652 const layer = makeProbeFailureLayer(() => Effect.interrupt);
146653
apps/server/src/preview/PortScanner.ts+305-31
...@@ -8,29 +8,49 @@...@@ -8,29 +8,49 @@
8 * Windows / lsof missing: checks a curated list of common dev ports through8 * Windows / lsof missing: checks a curated list of common dev ports through
9 * the shared Net service.9 * the shared Net service.
10 *10 *
11 * Listening ports are published only after a bounded HTTP(S) probe finds a
12 * successful HTML document or a redirect to one.
13 * Positive and negative results are cached briefly by candidate URL and listener identity,
14 * limiting repeated requests without leaving stale classifications around.
15 *
11 * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber16 * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber
12 * polls forever, but each tick is a no-op when the retain count is zero.17 * polls forever, but each tick is a no-op when the retain count is zero.
13 */18 */
14import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";19import {
20 CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS,
21 PREVIEW_URL_MAX_LENGTH,
22 ThreadId,
23 type DiscoveredLocalServer,
24} from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";25import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as Net from "@t3tools/shared/Net";26import * as Net from "@t3tools/shared/Net";
17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";27import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
18import * as Cause from "effect/Cause";28import * as Cause from "effect/Cause";
29import * as Clock from "effect/Clock";
19import * as Context from "effect/Context";30import * as Context from "effect/Context";
20import * as Duration from "effect/Duration";31import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";32import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";33import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";
23import * as Ref from "effect/Ref";35import * as Ref from "effect/Ref";
24import * as Schedule from "effect/Schedule";36import * as Schedule from "effect/Schedule";
25import * as Scope from "effect/Scope";37import * as Scope from "effect/Scope";
38import * as Semaphore from "effect/Semaphore";
39import { FetchHttpClient, HttpClient } from "effect/unstable/http";
2640
27import * as ProcessRunner from "../processRunner.ts";41import * as ProcessRunner from "../processRunner.ts";
2842
29export class PortDiscovery extends Context.Service<43export class PortDiscovery extends Context.Service<
30 PortDiscovery,44 PortDiscovery,
31 {45 {
32 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;46 readonly scan: (
47 configuredUrls?: ReadonlyArray<string>,
48 ) => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
33 readonly subscribe: (49 readonly subscribe: (
50 input: {
51 readonly configuredUrls: ReadonlyArray<string>;
52 readonly initialSnapshot: ReadonlyArray<DiscoveredLocalServer>;
53 },
34 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,54 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,
35 ) => Effect.Effect<void, never, Scope.Scope>;55 ) => Effect.Effect<void, never, Scope.Scope>;
36 readonly retain: Effect.Effect<void, never, Scope.Scope>;56 readonly retain: Effect.Effect<void, never, Scope.Scope>;
...@@ -53,12 +73,20 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([...@@ -53,12 +73,20 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
53const POLL_INTERVAL = Duration.seconds(3);73const POLL_INTERVAL = Duration.seconds(3);
54const LSOF_TIMEOUT_MS = 5_000;74const LSOF_TIMEOUT_MS = 5_000;
55const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;75const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
76const WEB_PROBE_TIMEOUT = Duration.seconds(1);
77const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15));
78const WEB_PROBE_CONCURRENCY = 16;
79const NAVIGATION_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
5680
57type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;81type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;
5882
59interface ScannerState {83interface ListenerSubscription {
84 readonly configuredUrls: ReadonlyArray<string>;
60 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;85 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
61 readonly listeners: ReadonlySet<Listener>;86}
87
88interface ScannerState {
89 readonly listeners: ReadonlyMap<Listener, ListenerSubscription>;
62 readonly terminalProcesses: ReadonlyMap<90 readonly terminalProcesses: ReadonlyMap<
63 string,91 string,
64 {92 {
...@@ -74,11 +102,86 @@ interface TerminalProcessOwner {...@@ -74,11 +102,86 @@ interface TerminalProcessOwner {
74 readonly terminalId: string;102 readonly terminalId: string;
75}103}
76104
105interface WebProbeCacheEntry {
106 readonly pid: number | null;
107 readonly isWeb: boolean;
108 readonly expiresAtMillis: number;
109}
110
111interface WebProbeGroup {
112 readonly server: DiscoveredLocalServer;
113 readonly urls: ReadonlyArray<string>;
114 readonly configuredKey: string | null;
115}
116
117interface WebProbeSnapshot {
118 readonly discovered: ReadonlyArray<DiscoveredLocalServer>;
119 readonly configured: ReadonlyMap<string, DiscoveredLocalServer>;
120}
121
77const terminalOwnerKey = (owner: {122const terminalOwnerKey = (owner: {
78 readonly threadId: string;123 readonly threadId: string;
79 readonly terminalId: string;124 readonly terminalId: string;
80}): string => `${owner.threadId}\u0000${owner.terminalId}`;125}): string => `${owner.threadId}\u0000${owner.terminalId}`;
81126
127const parseConfiguredUrl = (raw: string): URL | null => {
128 try {
129 const url = new URL(raw);
130 if (url.protocol !== "http:" && url.protocol !== "https:") return null;
131 if (!isLoopbackHost(url.hostname)) return null;
132 return url;
133 } catch {
134 return null;
135 }
136};
137
138const localServerKey = (host: string, port: number): string =>
139 `${isLoopbackHost(host) ? "loopback" : host.toLowerCase()}:${port}`;
140
141const urlPort = (url: URL): number =>
142 url.port.length > 0 ? Number.parseInt(url.port, 10) : url.protocol === "http:" ? 80 : 443;
143
144const webProbeCacheKey = (raw: string): string => {
145 const url = new URL(raw);
146 url.hash = "";
147 return url.href;
148};
149
150const normalizeConfiguredUrls = (urls: ReadonlyArray<string>): ReadonlyArray<string> => [
151 ...new Set(
152 urls
153 .slice(0, CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS)
154 .filter((raw) => raw.length <= PREVIEW_URL_MAX_LENGTH)
155 .map(parseConfiguredUrl)
156 .filter((url): url is URL => url !== null && url.href.length <= PREVIEW_URL_MAX_LENGTH)
157 .map((url) => {
158 if (url.hostname === "0.0.0.0") url.hostname = "localhost";
159 return url.href;
160 })
161 .filter((url) => url.length <= PREVIEW_URL_MAX_LENGTH),
162 ),
163];
164
165const projectWebProbeSnapshot = (
166 snapshot: WebProbeSnapshot,
167 configuredUrls: ReadonlyArray<string>,
168): ReadonlyArray<DiscoveredLocalServer> => {
169 const visibleByServer = new Map<string, DiscoveredLocalServer>();
170 for (const raw of normalizeConfiguredUrls(configuredUrls)) {
171 const url = new URL(raw);
172 const port = urlPort(url);
173 const serverKey = localServerKey(url.hostname, port);
174 if (visibleByServer.has(serverKey)) continue;
175 const configured = snapshot.configured.get(webProbeCacheKey(raw));
176 if (configured) visibleByServer.set(serverKey, { ...configured, url: raw });
177 }
178 for (const server of snapshot.discovered) {
179 const key = localServerKey(server.host, server.port);
180 if (!visibleByServer.has(key)) visibleByServer.set(key, server);
181 }
182 return [...visibleByServer.values()].toSorted((left, right) => left.port - right.port);
183};
184
82const parseLsofOutput = (185const parseLsofOutput = (
83 raw: string,186 raw: string,
84 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),187 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),
...@@ -190,12 +293,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -190,12 +293,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
190 const net = yield* Net.NetService;293 const net = yield* Net.NetService;
191 const processRunner = yield* ProcessRunner.ProcessRunner;294 const processRunner = yield* ProcessRunner.ProcessRunner;
192 const hostPlatform = yield* HostProcessPlatform;295 const hostPlatform = yield* HostProcessPlatform;
296 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
193 const stateRef = yield* Ref.make<ScannerState>({297 const stateRef = yield* Ref.make<ScannerState>({
194 lastSnapshot: [],298 listeners: new Map(),
195 listeners: new Set(),
196 terminalProcesses: new Map(),299 terminalProcesses: new Map(),
197 retainCount: 0,300 retainCount: 0,
198 });301 });
302 const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map());
303 const scanSemaphore = yield* Semaphore.make(1);
199304
200 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {305 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
201 const results = yield* Effect.forEach(306 const results = yield* Effect.forEach(
...@@ -221,6 +326,149 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -221,6 +326,149 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
221 }));326 }));
222 });327 });
223328
329 const probeWebUrl = Effect.fn("PortDiscovery.probeWebUrl")((url: string) =>
330 httpClient.get(url).pipe(
331 Effect.map((response) => {
332 const location = response.headers.location?.trim();
333 if (NAVIGATION_REDIRECT_STATUSES.has(response.status) && location) return url;
334 if (response.status < 200 || response.status >= 300) return null;
335 if (response.status === 204 || response.status === 205) return null;
336 const contentType = response.headers["content-type"]
337 ?.split(";", 1)[0]
338 ?.trim()
339 .toLowerCase();
340 return contentType === "text/html" || contentType === "application/xhtml+xml" ? url : null;
341 }),
342 Effect.scoped,
343 Effect.timeoutOption(WEB_PROBE_TIMEOUT),
344 Effect.map(Option.getOrNull),
345 Effect.orElseSucceed(() => null),
346 Effect.provideService(FetchHttpClient.RequestInit, { redirect: "manual" }),
347 ),
348 );
349
350 const makeWebProbeGroups = (
351 servers: ReadonlyArray<DiscoveredLocalServer>,
352 configuredUrls: ReadonlyArray<string>,
353 ): ReadonlyArray<WebProbeGroup> => {
354 const serversByKey = new Map(
355 servers.map((server) => [localServerKey(server.host, server.port), server] as const),
356 );
357 const groups: WebProbeGroup[] = [];
358 const configuredResources = new Set<string>();
359
360 for (const raw of configuredUrls) {
361 const url = new URL(raw);
362 const port = urlPort(url);
363 const key = localServerKey(url.hostname, port);
364 const resourceKey = webProbeCacheKey(raw);
365 if (configuredResources.has(resourceKey)) continue;
366 configuredResources.add(resourceKey);
367 groups.push({
368 server: serversByKey.get(key) ?? {
369 host: url.hostname,
370 port,
371 url: raw,
372 processName: null,
373 pid: null,
374 terminal: null,
375 },
376 urls: [raw],
377 configuredKey: resourceKey,
378 });
379 }
380
381 for (const server of servers) {
382 groups.push({
383 server,
384 urls: [`http://${server.host}:${server.port}`, `https://${server.host}:${server.port}`],
385 configuredKey: null,
386 });
387 }
388
389 return groups;
390 };
391
392 const probeWebServers = Effect.fn("PortDiscovery.probeWebServers")(function* (
393 servers: ReadonlyArray<DiscoveredLocalServer>,
394 configuredUrls: ReadonlyArray<string>,
395 ) {
396 const nowMillis = yield* Clock.currentTimeMillis;
397 const cached = yield* Ref.get(webProbeCacheRef);
398 const groups = makeWebProbeGroups(servers, configuredUrls);
399 const batchProbes = new Map<
400 string,
401 Effect.Effect<{ readonly probe: WebProbeCacheEntry; readonly fresh: boolean }>
402 >();
403 const batchProbeSemaphore = yield* Semaphore.make(1);
404 const getProbe = (url: string, pid: number | null) => {
405 const key = webProbeCacheKey(url);
406 const identity = `${key}\u0000${pid ?? ""}`;
407 return batchProbeSemaphore
408 .withPermits(1)(
409 Effect.gen(function* () {
410 const existing = batchProbes.get(identity);
411 if (existing) return [existing] as const;
412 const cachedProbe = cached.get(key);
413 const cachedIsCurrent =
414 cachedProbe?.pid === pid && cachedProbe.expiresAtMillis > nowMillis;
415 const memoized = yield* Effect.cached(
416 cachedIsCurrent
417 ? Effect.succeed({ probe: cachedProbe, fresh: false })
418 : probeWebUrl(url).pipe(
419 Effect.map((result) => ({
420 probe: { pid, isWeb: result !== null, expiresAtMillis: 0 },
421 fresh: true,
422 })),
423 ),
424 );
425 batchProbes.set(identity, memoized);
426 return [memoized] as const;
427 }),
428 )
429 .pipe(Effect.flatMap(([probe]) => probe));
430 };
431 const probed = yield* Effect.forEach(
432 groups,
433 (group) =>
434 Effect.gen(function* () {
435 const probes: Array<readonly [string, WebProbeCacheEntry, boolean]> = [];
436 let visibleUrl: string | null = null;
437 for (const url of group.urls) {
438 const key = webProbeCacheKey(url);
439 const { probe, fresh } = yield* getProbe(url, group.server.pid);
440 probes.push([key, probe, fresh]);
441 if (probe.isWeb) {
442 visibleUrl = url;
443 break;
444 }
445 }
446 return { group, probes, visibleUrl };
447 }),
448 { concurrency: WEB_PROBE_CONCURRENCY },
449 );
450 const completedAtMillis = yield* Clock.currentTimeMillis;
451 const nextCache = new Map(
452 [...cached].filter(([, probe]) => probe.expiresAtMillis > completedAtMillis),
453 );
454 const discovered: DiscoveredLocalServer[] = [];
455 const configured = new Map<string, DiscoveredLocalServer>();
456 for (const { group, probes, visibleUrl } of probed) {
457 for (const [key, probe, fresh] of probes) {
458 nextCache.set(
459 key,
460 fresh ? { ...probe, expiresAtMillis: completedAtMillis + WEB_PROBE_CACHE_TTL_MS } : probe,
461 );
462 }
463 if (visibleUrl === null) continue;
464 const server = { ...group.server, url: visibleUrl };
465 if (group.configuredKey === null) discovered.push(server);
466 else configured.set(group.configuredKey, server);
467 }
468 yield* Ref.set(webProbeCacheRef, nextCache);
469 return { discovered, configured } satisfies WebProbeSnapshot;
470 });
471
224 const recoverProcessProbeFailure =472 const recoverProcessProbeFailure =
225 (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) =>473 (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) =>
226 Effect.logDebug("preview port process probe failed; falling back to common-port probes", {474 Effect.logDebug("preview port process probe failed; falling back to common-port probes", {
...@@ -229,7 +477,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -229,7 +477,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
229 platform: hostPlatform,477 platform: hostPlatform,
230 }).pipe(Effect.as(null));478 }).pipe(Effect.as(null));
231479
232 const scanOnce = Effect.fn("PortDiscovery.scan")(function* () {480 const scanUnlocked = Effect.fn("PortDiscovery.scanUnlocked")(function* (
481 configuredUrls: ReadonlyArray<string>,
482 ) {
233 const state = yield* Ref.get(stateRef);483 const state = yield* Ref.get(stateRef);
234 const terminalByProcessId = new Map<number, TerminalProcessOwner>();484 const terminalByProcessId = new Map<number, TerminalProcessOwner>();
235 for (const registration of state.terminalProcesses.values()) {485 for (const registration of state.terminalProcesses.values()) {
...@@ -259,8 +509,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -259,8 +509,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
259 ProcessTimeoutError: recoverWindowsProbeFailure,509 ProcessTimeoutError: recoverWindowsProbeFailure,
260 }),510 }),
261 );511 );
262 if (listeners !== null) return listeners;512 if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls);
263 return yield* probeCommonPorts();513 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
264 }514 }
265 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");515 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
266 const lsofResult = yield* processRunner516 const lsofResult = yield* processRunner
...@@ -281,27 +531,47 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -281,27 +531,47 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
281 ProcessTimeoutError: recoverLsofProbeFailure,531 ProcessTimeoutError: recoverLsofProbeFailure,
282 }),532 }),
283 );533 );
284 if (lsofResult !== null) return lsofResult;534 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);
285 return yield* probeCommonPorts();535 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
286 });536 });
287537
288 const broadcast = Effect.fn("PortDiscovery.broadcast")(function* (538 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(
289 servers: ReadonlyArray<DiscoveredLocalServer>,539 (configuredUrls: ReadonlyArray<string>) =>
290 ) {540 scanSemaphore.withPermits(1)(scanUnlocked(configuredUrls)),
291 const listeners = (yield* Ref.get(stateRef)).listeners;541 );
292 yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true });542
293 });543 const scanOnce: PortDiscovery["Service"]["scan"] = (configuredUrls = []) => {
544 const normalized = normalizeConfiguredUrls(configuredUrls);
545 return scanSnapshot(normalized).pipe(
546 Effect.map((snapshot) => projectWebProbeSnapshot(snapshot, normalized)),
547 );
548 };
294549
295 const pollTick = Effect.fn("PortDiscovery.pollTick")(550 const pollTick = Effect.fn("PortDiscovery.pollTick")(
296 function* () {551 function* () {
297 if ((yield* Ref.get(stateRef)).retainCount <= 0) return;552 if ((yield* Ref.get(stateRef)).retainCount <= 0) return;
298 const next = yield* scanOnce();553 const configuredUrls = [
299 const changed = yield* Ref.modify(stateRef, (state) =>554 ...new Set(
300 serversEqual(state.lastSnapshot, next)555 [...(yield* Ref.get(stateRef)).listeners.values()].flatMap(
301 ? [false, state]556 (subscription) => subscription.configuredUrls,
302 : [true, { ...state, lastSnapshot: next }],557 ),
303 );558 ),
304 if (changed) yield* broadcast(next);559 ];
560 const snapshot = yield* scanSnapshot(configuredUrls);
561 const notifications = yield* Ref.modify(stateRef, (state) => {
562 const listeners = new Map(state.listeners);
563 const changed: Array<readonly [Listener, ReadonlyArray<DiscoveredLocalServer>]> = [];
564 for (const [listener, subscription] of listeners) {
565 const next = projectWebProbeSnapshot(snapshot, subscription.configuredUrls);
566 if (serversEqual(subscription.lastSnapshot, next)) continue;
567 listeners.set(listener, { ...subscription, lastSnapshot: next });
568 changed.push([listener, next]);
569 }
570 return [changed, { ...state, listeners }];
571 });
572 yield* Effect.forEach(notifications, ([listener, servers]) => listener(servers), {
573 discard: true,
574 });
305 },575 },
306 Effect.catchCause((cause: Cause.Cause<never>) =>576 Effect.catchCause((cause: Cause.Cause<never>) =>
307 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),577 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),
...@@ -332,15 +602,19 @@ export const make = Effect.gen(function* PortDiscoveryMake() {...@@ -332,15 +602,19 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
332 );602 );
333603
334 const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")(604 const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")(
335 (listener) =>605 (input, listener) =>
336 Effect.acquireRelease(606 Effect.acquireRelease(
337 Ref.update(stateRef, (state) => ({607 Ref.update(stateRef, (state) => {
338 ...state,608 const listeners = new Map(state.listeners);
339 listeners: new Set([...state.listeners, listener]),609 listeners.set(listener, {
340 })),610 configuredUrls: normalizeConfiguredUrls(input.configuredUrls),
611 lastSnapshot: input.initialSnapshot,
612 });
613 return { ...state, listeners };
614 }),
341 () =>615 () =>
342 Ref.update(stateRef, (state) => {616 Ref.update(stateRef, (state) => {
343 const listeners = new Set(state.listeners);617 const listeners = new Map(state.listeners);
344 listeners.delete(listener);618 listeners.delete(listener);
345 return { ...state, listeners };619 return { ...state, listeners };
346 }),620 }),
apps/server/src/ws.ts+15-7
...@@ -2131,23 +2131,31 @@ const makeWsRpcLayer = (...@@ -2131,23 +2131,31 @@ const makeWsRpcLayer = (
2131 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {2131 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {
2132 "rpc.aggregate": "preview",2132 "rpc.aggregate": "preview",
2133 }),2133 }),
2134 [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) =>2134 [WS_METHODS.subscribeDiscoveredLocalServers]: (input) =>
2135 observeRpcStream(2135 observeRpcStream(
2136 WS_METHODS.subscribeDiscoveredLocalServers,2136 WS_METHODS.subscribeDiscoveredLocalServers,
2137 Stream.callback<DiscoveredLocalServerList>((queue) =>2137 Stream.callback<DiscoveredLocalServerList>((queue) =>
2138 Effect.gen(function* () {2138 Effect.gen(function* () {
2139 const configuredUrls = input.configuredUrls ?? [];
2139 yield* portDiscovery.retain;2140 yield* portDiscovery.retain;
2140 const initial = yield* portDiscovery.scan();2141 const initial = yield* portDiscovery.scan(configuredUrls);
2141 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);2142 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
2142 yield* Queue.offer(queue, {2143 yield* Queue.offer(queue, {
2143 servers: initial,2144 servers: initial,
2144 scannedAt: initialScannedAt,2145 scannedAt: initialScannedAt,
2146 configuredUrlProbing: true,
2145 });2147 });
2146 yield* portDiscovery.subscribe((servers) =>2148 yield* portDiscovery.subscribe(
2147 Effect.gen(function* () {2149 { configuredUrls, initialSnapshot: initial },
2148 const scannedAt = DateTime.formatIso(yield* DateTime.now);2150 (servers) =>
2149 yield* Queue.offer(queue, { servers, scannedAt });2151 Effect.gen(function* () {
2150 }),2152 const scannedAt = DateTime.formatIso(yield* DateTime.now);
2153 yield* Queue.offer(queue, {
2154 servers,
2155 scannedAt,
2156 configuredUrlProbing: true,
2157 });
2158 }),
2151 );2159 );
2152 }),2160 }),
2153 ),2161 ),
apps/web/src/components/preview/PreviewEmptyState.test.tsx-2
...@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({...@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({
12 pid: number | null;12 pid: number | null;
13 terminal: null;13 terminal: null;
14 source: "scanner";14 source: "scanner";
15 listening: boolean;
16 }>,15 }>,
17}));16}));
1817
...@@ -38,7 +37,6 @@ function server(port: number) {...@@ -38,7 +37,6 @@ function server(port: number) {
38 pid: 1,37 pid: 1,
39 terminal: null,38 terminal: null,
40 source: "scanner" as const,39 source: "scanner" as const,
41 listening: true,
42 };40 };
43}41}
4442
apps/web/src/components/preview/PreviewEmptyState.tsx+3-6
...@@ -12,7 +12,6 @@ interface Props {...@@ -12,7 +12,6 @@ interface Props {
12 threadRef: ScopedThreadRef;12 threadRef: ScopedThreadRef;
13 environmentId: EnvironmentId;13 environmentId: EnvironmentId;
14 configuredUrls?: ReadonlyArray<string> | undefined;14 configuredUrls?: ReadonlyArray<string> | undefined;
15 recentlySeenUrls?: ReadonlyArray<string> | undefined;
16 recentEntries: ReadonlyArray<BrowserHistoryEntry>;15 recentEntries: ReadonlyArray<BrowserHistoryEntry>;
17 onRemoveRecent: (url: string) => void;16 onRemoveRecent: (url: string) => void;
18 onOpenUrl: (url: string) => void;17 onOpenUrl: (url: string) => void;
...@@ -22,7 +21,6 @@ export function PreviewEmptyState({...@@ -22,7 +21,6 @@ export function PreviewEmptyState({
22 threadRef,21 threadRef,
23 environmentId,22 environmentId,
24 configuredUrls,23 configuredUrls,
25 recentlySeenUrls,
26 recentEntries,24 recentEntries,
27 onRemoveRecent,25 onRemoveRecent,
28 onOpenUrl,26 onOpenUrl,
...@@ -30,7 +28,6 @@ export function PreviewEmptyState({...@@ -30,7 +28,6 @@ export function PreviewEmptyState({
30 const servers = useDiscoveredLocalServers({28 const servers = useDiscoveredLocalServers({
31 environmentId,29 environmentId,
32 configuredUrls,30 configuredUrls,
33 recentlySeenUrls,
34 });31 });
35 const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8);32 const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8);
3633
...@@ -42,7 +39,7 @@ export function PreviewEmptyState({...@@ -42,7 +39,7 @@ export function PreviewEmptyState({
42 </EmptyMedia>39 </EmptyMedia>
43 <EmptyTitle>No preview yet</EmptyTitle>40 <EmptyTitle>No preview yet</EmptyTitle>
44 <EmptyDescription>41 <EmptyDescription>
45 Type a URL above, or run a dev script. Listening localhost ports will show up here42 Type a URL above, or run a dev script. Browser-ready localhost servers will show up here
46 automatically.43 automatically.
47 </EmptyDescription>44 </EmptyDescription>
48 </Empty>45 </Empty>
...@@ -51,7 +48,7 @@ export function PreviewEmptyState({...@@ -51,7 +48,7 @@ export function PreviewEmptyState({
5148
52 return (49 return (
53 <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">50 <div className="flex h-full min-h-0 overflow-y-auto px-5 py-8">
54 <div className="m-auto flex w-full max-w-xl flex-col gap-6">51 <div className="mx-auto flex w-full max-w-xl flex-col gap-6">
55 {recents.length > 0 ? (52 {recents.length > 0 ? (
56 <div className="flex flex-col gap-3">53 <div className="flex flex-col gap-3">
57 <div className="flex items-center gap-2 text-sm text-muted-foreground">54 <div className="flex items-center gap-2 text-sm text-muted-foreground">
...@@ -88,7 +85,7 @@ export function PreviewEmptyState({...@@ -88,7 +85,7 @@ export function PreviewEmptyState({
88 ))}85 ))}
89 </div>86 </div>
90 <p className="px-1 text-xs text-muted-foreground">87 <p className="px-1 text-xs text-muted-foreground">
91 Select a listening port to open it in this browser tab.88 Select a live local server to open it in this browser tab.
92 </p>89 </p>
93 </div>90 </div>
94 ) : null}91 ) : null}
apps/web/src/components/preview/PreviewLocalServerCard.tsx+1-22
...@@ -24,32 +24,11 @@ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {...@@ -24,32 +24,11 @@ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
24 {server.host}:{server.port}24 {server.host}:{server.port}
25 </span>25 </span>
26 </div>26 </div>
27 {server.listening ? <PulsingDot /> : <DimDot />}
28 </button>27 </button>
29 );28 );
30}29}
3130
32function describeServer(server: PreviewableServer): string {31function describeServer(server: PreviewableServer): string {
33 if (server.processName) return server.processName;32 if (server.processName) return server.processName;
34 if (server.listening) return "Listening";33 return "Listening";
35 if (server.source === "configured") return "Configured";
36 return "Recently seen";
37}
38
39function PulsingDot() {
40 return (
41 <span aria-label="Listening" className="relative inline-flex size-2 shrink-0">
42 <span className="absolute inset-0 animate-status-ping rounded-full bg-success opacity-60" />
43 <span className="relative inline-flex size-2 rounded-full bg-success" />
44 </span>
45 );
46}
47
48function DimDot() {
49 return (
50 <span
51 aria-label="Not currently listening"
52 className="size-2 shrink-0 rounded-full bg-muted-foreground/40"
53 />
54 );
55}34}
apps/web/src/components/preview/PreviewView.tsx-1
...@@ -713,7 +713,6 @@ export function PreviewView({...@@ -713,7 +713,6 @@ export function PreviewView({
713 threadRef={threadRef}713 threadRef={threadRef}
714 environmentId={threadRef.environmentId}714 environmentId={threadRef.environmentId}
715 configuredUrls={configuredUrls}715 configuredUrls={configuredUrls}
716 recentlySeenUrls={previewState.recentlySeenUrls}
717 recentEntries={recentHistoryEntries}716 recentEntries={recentHistoryEntries}
718 onRemoveRecent={(url) => removeUrlForThread(threadRef, url)}717 onRemoveRecent={(url) => removeUrlForThread(threadRef, url)}
719 onOpenUrl={(next) => void handleOpenServerUrl(next)}718 onOpenUrl={(next) => void handleOpenServerUrl(next)}
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts+60-53
...@@ -1,7 +1,7 @@...@@ -1,7 +1,7 @@
1import type { DiscoveredLocalServer } from "@t3tools/contracts";1import type { DiscoveredLocalServer } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers";4import { mergeServers } from "./useDiscoveredLocalServers";
55
6const scannerServer = (6const scannerServer = (
7 overrides: Partial<DiscoveredLocalServer & { requestedUrl: string }>,7 overrides: Partial<DiscoveredLocalServer & { requestedUrl: string }>,
...@@ -21,7 +21,6 @@ describe("mergeServers", () => {...@@ -21,7 +21,6 @@ describe("mergeServers", () => {
21 const result = mergeServers({21 const result = mergeServers({
22 scanner: [scannerServer({})],22 scanner: [scannerServer({})],
23 configuredUrls: [],23 configuredUrls: [],
24 recentlySeenUrls: [],
25 });24 });
26 expect(result).toHaveLength(1);25 expect(result).toHaveLength(1);
27 expect(result[0]).toMatchObject({26 expect(result[0]).toMatchObject({
...@@ -29,7 +28,6 @@ describe("mergeServers", () => {...@@ -29,7 +28,6 @@ describe("mergeServers", () => {
29 port: 5173,28 port: 5173,
30 requestedUrl: "http://localhost:5173",29 requestedUrl: "http://localhost:5173",
31 source: "scanner",30 source: "scanner",
32 listening: true,
33 processName: "vite",31 processName: "vite",
34 });32 });
35 });33 });
...@@ -38,102 +36,111 @@ describe("mergeServers", () => {...@@ -38,102 +36,111 @@ describe("mergeServers", () => {
38 const result = mergeServers({36 const result = mergeServers({
39 scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })],37 scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })],
40 configuredUrls: ["http://localhost:5173"],38 configuredUrls: ["http://localhost:5173"],
41 recentlySeenUrls: [],
42 });39 });
43 expect(result).toHaveLength(1);40 expect(result).toHaveLength(1);
44 expect(result[0]).toMatchObject({41 expect(result[0]).toMatchObject({
45 port: 5173,42 port: 5173,
46 source: "configured",43 source: "configured",
47 listening: true,
48 processName: "node",44 processName: "node",
49 pid: 9999,45 pid: 9999,
50 });46 });
51 });47 });
5248
53 it("keeps configured entries that the scanner doesn't see, with listening=false", () => {49 it("excludes configured entries that the live scanner doesn't see", () => {
54 const result = mergeServers({50 const result = mergeServers({
55 scanner: [],51 scanner: [],
56 configuredUrls: ["http://localhost:5173"],52 configuredUrls: ["http://localhost:5173"],
57 recentlySeenUrls: [],
58 });53 });
59 expect(result).toHaveLength(1);54 expect(result).toHaveLength(0);
60 expect(result[0]).toMatchObject({
61 source: "configured",
62 listening: false,
63 requestedUrl: "http://localhost:5173/",
64 });
65 });
66
67 it("dedupes recently-seen URLs against scanner+configured entries", () => {
68 const result = mergeServers({
69 scanner: [scannerServer({ port: 5173 })],
70 configuredUrls: [],
71 recentlySeenUrls: ["http://localhost:5173/", "http://localhost:8080/"],
72 });
73 expect(result.map((s) => s.port)).toEqual([5173, 8080]);
74 expect(result.find((s) => s.port === 5173)?.source).toBe("scanner");
75 expect(result.find((s) => s.port === 8080)?.source).toBe("recent");
76 expect(result.find((s) => s.port === 8080)?.requestedUrl).toBe("http://localhost:8080/");
77 });55 });
7856
79 it("ignores non-loopback URLs in configured/recent inputs", () => {57 it("ignores non-loopback configured URLs", () => {
80 const result = mergeServers({58 const result = mergeServers({
81 scanner: [],59 scanner: [scannerServer({})],
82 configuredUrls: ["https://example.com", "ws://localhost:5173"],60 configuredUrls: ["https://example.com", "ws://localhost:5173"],
83 recentlySeenUrls: ["https://api.example.com"],
84 });61 });
85 expect(result).toHaveLength(0);62 expect(result).toHaveLength(1);
63 expect(result[0]?.source).toBe("scanner");
86 });64 });
8765
88 it("sorts: configured before scanner before recent, then by port", () => {66 it("sorts configured live servers before scanner-only servers", () => {
89 const result = mergeServers({67 const result = mergeServers({
90 scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })],68 scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })],
91 configuredUrls: ["http://localhost:5173"],69 configuredUrls: ["http://localhost:8080"],
92 recentlySeenUrls: ["http://localhost:9000/", "http://localhost:4321/"],
93 });70 });
94 expect(result.map((s) => `${s.source}:${s.port}`)).toEqual([71 expect(result.map((s) => `${s.source}:${s.port}`)).toEqual(["configured:8080", "scanner:3000"]);
95 "configured:5173",
96 "scanner:3000",
97 "scanner:8080",
98 "recent:4321",
99 "recent:9000",
100 ]);
101 });72 });
10273
103 it("dedupes by lowercased host", () => {74 it("dedupes by lowercased host", () => {
104 const result = mergeServers({75 const result = mergeServers({
105 scanner: [scannerServer({ host: "Localhost", port: 5173 })],76 scanner: [scannerServer({ host: "Localhost", port: 5173 })],
106 configuredUrls: ["http://localhost:5173"],77 configuredUrls: ["http://localhost:5173"],
107 recentlySeenUrls: [],
108 });78 });
109 expect(result).toHaveLength(1);79 expect(result).toHaveLength(1);
80 expect(result[0]?.source).toBe("configured");
110 });81 });
11182
112 it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => {83 it.each(["127.0.0.1", "0.0.0.0", "[::1]"])(
84 "matches configured loopback alias %s to a live localhost server",
85 (host) => {
86 const result = mergeServers({
87 scanner: [
88 scannerServer({ requestedUrl: `http://localhost:5173/dashboard?mode=test#results` }),
89 ],
90 configuredUrls: [`http://${host}:5173/dashboard?mode=test#results`],
91 });
92 expect(result).toHaveLength(1);
93 expect(result[0]?.source).toBe("configured");
94 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/dashboard?mode=test#results");
95 },
96 );
97
98 it("keeps the scanner-verified path and protocol", () => {
113 const result = mergeServers({99 const result = mergeServers({
114 scanner: [100 scanner: [
115 scannerServer({101 scannerServer({
116 port: 5173,
117 url: "https://env-42.example.dev:5173/",102 url: "https://env-42.example.dev:5173/",
118 requestedUrl: "http://localhost:5173/",103 requestedUrl: "http://localhost:5173/dashboard?mode=test#results",
119 }),104 }),
120 ],105 ],
121 configuredUrls: [],106 configuredUrls: ["https://localhost:5173/dashboard?mode=test#results"],
122 recentlySeenUrls: [],
123 });107 });
124 expect(result[0]?.url).toBe("https://env-42.example.dev:5173/");108 expect(result[0]?.url).toBe("https://env-42.example.dev:5173/");
109 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/dashboard?mode=test#results");
110 });
111
112 it("overlays a configured path when an older server does not advertise path probing", () => {
113 const result = mergeServers({
114 scanner: [scannerServer({ requestedUrl: "http://localhost:5173/" })],
115 configuredUrls: ["https://localhost:5173/docs?mode=test#results"],
116 configuredUrlProbing: false,
117 });
118
119 expect(result[0]?.requestedUrl).toBe("https://localhost:5173/docs?mode=test#results");
120 });
121
122 it("does not overlay an unverified configured path when the server probes paths", () => {
123 const result = mergeServers({
124 scanner: [scannerServer({ requestedUrl: "http://localhost:5173/" })],
125 configuredUrls: ["http://localhost:5173/docs"],
126 configuredUrlProbing: true,
127 });
128
125 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/");129 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/");
126 });130 });
127});
128131
129describe("PreviewableServer interface", () => {132 it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => {
130 it("preserves listening flag through enrichment", () => {
131 const result = mergeServers({133 const result = mergeServers({
132 scanner: [scannerServer({})],134 scanner: [
133 configuredUrls: ["http://localhost:5173"],135 scannerServer({
134 recentlySeenUrls: [],136 port: 5173,
137 url: "https://env-42.example.dev:5173/",
138 requestedUrl: "http://localhost:5173/",
139 }),
140 ],
141 configuredUrls: [],
135 });142 });
136 const merged: PreviewableServer | undefined = result[0];143 expect(result[0]?.url).toBe("https://env-42.example.dev:5173/");
137 expect(merged?.listening).toBe(true);144 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/");
138 });145 });
139});146});
apps/web/src/components/preview/useDiscoveredLocalServers.ts+20-61
...@@ -4,15 +4,10 @@ import { useMemo } from "react";...@@ -4,15 +4,10 @@ import { useMemo } from "react";
44
5import type { EnvironmentId } from "@t3tools/contracts";5import type { EnvironmentId } from "@t3tools/contracts";
6import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";6import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
7import { useDiscoveredPorts } from "~/portDiscoveryState";7import { useDiscoveredPortsState } from "~/portDiscoveryState";
88
9export interface PreviewableServer extends DiscoveredLocalServer {9export interface PreviewableServer extends DiscoveredLocalServer {
10 source: "scanner" | "configured" | "recent";10 source: "scanner" | "configured";
11 /**
12 * True when the port scanner currently sees this server listening. A
13 * `configured` entry can also be `listening` when the scan enriched it.
14 */
15 listening: boolean;
16 /**11 /**
17 * Pre-resolution loopback url. `url` is the resolved navigation target12 * Pre-resolution loopback url. `url` is the resolved navigation target
18 * (volatile on a remote environment); history must key off this instead.13 * (volatile on a remote environment); history must key off this instead.
...@@ -23,99 +18,62 @@ export interface PreviewableServer extends DiscoveredLocalServer {...@@ -23,99 +18,62 @@ export interface PreviewableServer extends DiscoveredLocalServer {
23interface UseDiscoveredLocalServersInput {18interface UseDiscoveredLocalServersInput {
24 environmentId: EnvironmentId;19 environmentId: EnvironmentId;
25 configuredUrls?: ReadonlyArray<string> | undefined;20 configuredUrls?: ReadonlyArray<string> | undefined;
26 recentlySeenUrls?: ReadonlyArray<string> | undefined;
27}21}
2822
29/**23/**
30 * Merge the environment-level port snapshot with configured / recently-seen24 * Enrich the environment-level live server snapshot with matching configured
31 * URLs and return a stable sorted list.25 * URLs and return a stable sorted list.
32 */26 */
33export function useDiscoveredLocalServers(27export function useDiscoveredLocalServers(
34 input: UseDiscoveredLocalServersInput,28 input: UseDiscoveredLocalServersInput,
35): ReadonlyArray<PreviewableServer> {29): ReadonlyArray<PreviewableServer> {
36 const scannerSnapshot = useDiscoveredPorts(input.environmentId);30 const scannerState = useDiscoveredPortsState(input.environmentId, input.configuredUrls);
3731
38 return useMemo(32 return useMemo(
39 () =>33 () =>
40 mergeServers({34 mergeServers({
41 scanner: scannerSnapshot.map((server) => ({35 scanner: scannerState.servers.map((server) => ({
42 ...server,36 ...server,
43 url: resolveDiscoveredServerUrl(input.environmentId, server.url),37 url: resolveDiscoveredServerUrl(input.environmentId, server.url),
44 requestedUrl: server.url,38 requestedUrl: server.url,
45 })),39 })),
46 configuredUrls: input.configuredUrls ?? [],40 configuredUrls: input.configuredUrls ?? [],
47 recentlySeenUrls: input.recentlySeenUrls ?? [],41 configuredUrlProbing: scannerState.configuredUrlProbing,
48 }),42 }),
49 [input.environmentId, scannerSnapshot, input.configuredUrls, input.recentlySeenUrls],43 [input.environmentId, scannerState, input.configuredUrls],
50 );44 );
51}45}
5246
53export function mergeServers(input: {47export function mergeServers(input: {
54 scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>;48 scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>;
55 configuredUrls: ReadonlyArray<string>;49 configuredUrls: ReadonlyArray<string>;
56 recentlySeenUrls: ReadonlyArray<string>;50 configuredUrlProbing?: boolean;
57}): ReadonlyArray<PreviewableServer> {51}): ReadonlyArray<PreviewableServer> {
58 const seen = new Map<string, PreviewableServer>();52 const configuredByServer = new Map<string, { host: string; port: number; url: string }>();
5953
60 for (const url of input.configuredUrls) {54 for (const url of input.configuredUrls) {
61 const parsed = parseLocalUrl(url);55 const parsed = parseLocalUrl(url);
62 if (!parsed) continue;56 if (!parsed) continue;
63 const key = canonicalKey(parsed.host, parsed.port);57 const key = canonicalKey(parsed.host, parsed.port);
64 if (seen.has(key)) continue;58 if (!configuredByServer.has(key)) configuredByServer.set(key, parsed);
65 seen.set(key, {
66 host: parsed.host,
67 port: parsed.port,
68 url: parsed.url,
69 requestedUrl: parsed.url,
70 processName: null,
71 pid: null,
72 terminal: null,
73 source: "configured",
74 listening: false,
75 });
76 }59 }
7760
61 const live: PreviewableServer[] = [];
78 for (const server of input.scanner) {62 for (const server of input.scanner) {
79 const key = canonicalKey(server.host, server.port);63 const key = canonicalKey(server.host, server.port);
80 const existing = seen.get(key);64 const configured = configuredByServer.get(key);
81 if (existing) {65 live.push({
82 // Enrich a configured entry with live process metadata; flip66 ...server,
83 // `listening` so it pulses green like a scanner-discovered entry.67 requestedUrl:
84 seen.set(key, {68 configured && input.configuredUrlProbing === false ? configured.url : server.requestedUrl,
85 ...existing,69 source: configured ? "configured" : "scanner",
86 processName: server.processName ?? existing.processName,
87 pid: server.pid ?? existing.pid,
88 terminal: server.terminal ?? existing.terminal,
89 listening: true,
90 });
91 continue;
92 }
93 seen.set(key, { ...server, source: "scanner", listening: true });
94 }
95
96 for (const url of input.recentlySeenUrls) {
97 const parsed = parseLocalUrl(url);
98 if (!parsed) continue;
99 const key = canonicalKey(parsed.host, parsed.port);
100 if (seen.has(key)) continue;
101 seen.set(key, {
102 host: parsed.host,
103 port: parsed.port,
104 url: parsed.url,
105 requestedUrl: parsed.url,
106 processName: null,
107 pid: null,
108 terminal: null,
109 source: "recent",
110 listening: false,
111 });70 });
112 }71 }
11372
114 return Array.from(seen.values()).toSorted((a, b) => {73 return live.toSorted((a, b) => {
115 const sourceOrder: Record<PreviewableServer["source"], number> = {74 const sourceOrder: Record<PreviewableServer["source"], number> = {
116 configured: 0,75 configured: 0,
117 scanner: 1,76 scanner: 1,
118 recent: 2,
119 };77 };
120 if (sourceOrder[a.source] !== sourceOrder[b.source]) {78 if (sourceOrder[a.source] !== sourceOrder[b.source]) {
121 return sourceOrder[a.source] - sourceOrder[b.source];79 return sourceOrder[a.source] - sourceOrder[b.source];
...@@ -125,7 +83,8 @@ export function mergeServers(input: {...@@ -125,7 +83,8 @@ export function mergeServers(input: {
125}83}
12684
127function canonicalKey(host: string, port: number): string {85function canonicalKey(host: string, port: number): string {
128 return `${host.toLowerCase()}:${port}`;86 const normalizedHost = host.toLowerCase();
87 return `${isLoopbackHost(normalizedHost) ? "loopback" : normalizedHost}:${port}`;
129}88}
13089
131function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null {90function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null {
apps/web/src/portDiscoveryState.test.ts created+35
...@@ -0,0 +1,35 @@
1import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH } from "@t3tools/contracts";
2import { describe, expect, it } from "vite-plus/test";
3
4import { boundConfiguredLocalServerUrls } from "./portDiscoveryState";
5
6describe("boundConfiguredLocalServerUrls", () => {
7 it("keeps subscription payloads within the discovery RPC bounds", () => {
8 const urls = Array.from(
9 { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS + 1 },
10 (_, index) => `http://localhost:${3_000 + index}`,
11 );
12 urls.unshift(
13 "https://example.com",
14 "not a URL",
15 `http://localhost/${"a".repeat(PREVIEW_URL_MAX_LENGTH)}`,
16 );
17
18 const bounded = boundConfiguredLocalServerUrls(urls);
19
20 expect(bounded).toHaveLength(CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS);
21 expect(bounded.every((url) => url.startsWith("http://localhost:"))).toBe(true);
22 });
23
24 it("does not let fragment-only variants crowd out another server", () => {
25 const fragments = Array.from(
26 { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS },
27 (_, index) => `http://localhost:3000/docs#section-${index}`,
28 );
29
30 expect(boundConfiguredLocalServerUrls([...fragments, "http://localhost:4000/app"])).toEqual([
31 "http://localhost:3000/docs#section-0",
32 "http://localhost:4000/app",
33 ]);
34 });
35});
apps/web/src/portDiscoveryState.ts+58-3
...@@ -1,4 +1,11 @@...@@ -1,4 +1,11 @@
1import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts";1import {
2 CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS,
3 PREVIEW_URL_MAX_LENGTH,
4 type DiscoveredLocalServer,
5 type EnvironmentId,
6 type ThreadId,
7} from "@t3tools/contracts";
8import { isLoopbackHost } from "@t3tools/shared/preview";
2import { useMemo } from "react";9import { useMemo } from "react";
310
4import { previewEnvironment } from "./state/preview";11import { previewEnvironment } from "./state/preview";
...@@ -6,15 +13,63 @@ import { useEnvironmentQuery } from "./state/query";...@@ -6,15 +13,63 @@ import { useEnvironmentQuery } from "./state/query";
613
7const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]);14const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]);
815
16interface DiscoveredPortsState {
17 readonly servers: ReadonlyArray<DiscoveredLocalServer>;
18 readonly configuredUrlProbing: boolean;
19}
20
21export function boundConfiguredLocalServerUrls(
22 urls: ReadonlyArray<string> | undefined,
23): ReadonlyArray<string> {
24 const bounded: string[] = [];
25 const seen = new Set<string>();
26 for (const raw of urls ?? []) {
27 if (raw.length === 0 || raw.length > PREVIEW_URL_MAX_LENGTH || raw.trim().length !== raw.length)
28 continue;
29 try {
30 const url = new URL(raw);
31 if (url.protocol !== "http:" && url.protocol !== "https:") continue;
32 if (!isLoopbackHost(url.hostname) || url.href.length > PREVIEW_URL_MAX_LENGTH) continue;
33 const resourceUrl = new URL(url.href);
34 resourceUrl.hash = "";
35 if (seen.has(resourceUrl.href)) continue;
36 seen.add(resourceUrl.href);
37 bounded.push(url.href);
38 if (bounded.length >= CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS) break;
39 } catch {
40 // Invalid and non-local project preview URLs are not discovery candidates.
41 }
42 }
43 return bounded;
44}
45
9export function useDiscoveredPorts(46export function useDiscoveredPorts(
10 environmentId: EnvironmentId | null,47 environmentId: EnvironmentId | null,
48 configuredUrls?: ReadonlyArray<string>,
11): ReadonlyArray<DiscoveredLocalServer> {49): ReadonlyArray<DiscoveredLocalServer> {
50 return useDiscoveredPortsState(environmentId, configuredUrls).servers;
51}
52
53export function useDiscoveredPortsState(
54 environmentId: EnvironmentId | null,
55 configuredUrls?: ReadonlyArray<string>,
56): DiscoveredPortsState {
57 const boundedConfiguredUrls = boundConfiguredLocalServerUrls(configuredUrls);
12 const query = useEnvironmentQuery(58 const query = useEnvironmentQuery(
13 environmentId === null59 environmentId === null
14 ? null60 ? null
15 : previewEnvironment.discoveredServers({ environmentId, input: {} }),61 : previewEnvironment.discoveredServers({
62 environmentId,
63 input: boundedConfiguredUrls.length ? { configuredUrls: boundedConfiguredUrls } : {},
64 }),
65 );
66 return useMemo(
67 () => ({
68 servers: query.data?.servers ?? EMPTY_PORTS,
69 configuredUrlProbing: query.data?.configuredUrlProbing === true,
70 }),
71 [query.data?.configuredUrlProbing, query.data?.servers],
16 );72 );
17 return query.data?.servers ?? EMPTY_PORTS;
18}73}
1974
20export function useThreadDiscoveredPorts(input: {75export function useThreadDiscoveredPorts(input: {
packages/client-runtime/src/state/preview.ts+3
...@@ -41,6 +41,9 @@ export function createPreviewEnvironmentAtoms<R, E>(...@@ -41,6 +41,9 @@ export function createPreviewEnvironmentAtoms<R, E>(
41 discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, {41 discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
42 label: "environment-data:preview:discovered-servers",42 label: "environment-data:preview:discovered-servers",
43 tag: WS_METHODS.subscribeDiscoveredLocalServers,43 tag: WS_METHODS.subscribeDiscoveredLocalServers,
44 // Configured URLs are part of this atom's key. Dispose immediately so
45 // unmounted projects stop contributing probe candidates on the server.
46 idleTtlMs: 0,
44 }),47 }),
45 automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, {48 automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
46 label: "environment-data:preview:automation-requests",49 label: "environment-data:preview:automation-requests",
packages/contracts/src/preview.test.ts+20
...@@ -2,7 +2,10 @@ import { Schema } from "effect";...@@ -2,7 +2,10 @@ import { Schema } from "effect";
2import { describe, expect, it } from "vite-plus/test";2import { describe, expect, it } from "vite-plus/test";
33
4import {4import {
5 ConfiguredLocalServerUrls,
6 CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS,
5 DiscoveredLocalServer,7 DiscoveredLocalServer,
8 PREVIEW_URL_MAX_LENGTH,
6 PreviewEvent,9 PreviewEvent,
7 PreviewNavStatus,10 PreviewNavStatus,
8 PreviewSessionSnapshot,11 PreviewSessionSnapshot,
...@@ -21,6 +24,7 @@ const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);...@@ -21,6 +24,7 @@ const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
21const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);24const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
22const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);25const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
23const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);26const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
27const decodeConfiguredLocalServerUrls = Schema.decodeUnknownSync(ConfiguredLocalServerUrls);
24const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);28const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);
25const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);29const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);
26const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput);30const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput);
...@@ -342,3 +346,19 @@ describe("DiscoveredLocalServer", () => {...@@ -342,3 +346,19 @@ describe("DiscoveredLocalServer", () => {
342 ).toThrow();346 ).toThrow();
343 });347 });
344});348});
349
350describe("ConfiguredLocalServerUrls", () => {
351 it("bounds the number and length of probe candidates", () => {
352 expect(() =>
353 decodeConfiguredLocalServerUrls(
354 Array.from(
355 { length: CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS + 1 },
356 (_, index) => `http://localhost:${3_000 + index}`,
357 ),
358 ),
359 ).toThrow();
360 expect(() =>
361 decodeConfiguredLocalServerUrls([`http://localhost/${"a".repeat(PREVIEW_URL_MAX_LENGTH)}`]),
362 ).toThrow();
363 });
364});
packages/contracts/src/preview.ts+9-1
...@@ -11,7 +11,14 @@...@@ -11,7 +11,14 @@
11import { Schema } from "effect";11import { Schema } from "effect";
12import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";12import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts";
1313
14const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048));14export const PREVIEW_URL_MAX_LENGTH = 2_048;
15export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32;
16
17const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(PREVIEW_URL_MAX_LENGTH));
18
19export const ConfiguredLocalServerUrls = Schema.Array(Url).check(
20 Schema.isMaxLength(CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS),
21);
15const Title = Schema.String.check(Schema.isMaxLength(512));22const Title = Schema.String.check(Schema.isMaxLength(512));
1623
17export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));24export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
...@@ -272,6 +279,7 @@ export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;...@@ -272,6 +279,7 @@ export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;
272export const DiscoveredLocalServerList = Schema.Struct({279export const DiscoveredLocalServerList = Schema.Struct({
273 servers: Schema.Array(DiscoveredLocalServer),280 servers: Schema.Array(DiscoveredLocalServer),
274 scannedAt: Schema.String,281 scannedAt: Schema.String,
282 configuredUrlProbing: Schema.optional(Schema.Literal(true)),
275});283});
276export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type;284export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type;
277285
packages/contracts/src/rpc.ts+4-1
...@@ -129,6 +129,7 @@ import {...@@ -129,6 +129,7 @@ import {
129} from "./terminal.ts";129} from "./terminal.ts";
130import {130import {
131 DiscoveredLocalServerList,131 DiscoveredLocalServerList,
132 ConfiguredLocalServerUrls,
132 PreviewCloseInput,133 PreviewCloseInput,
133 PreviewError,134 PreviewError,
134 PreviewEvent,135 PreviewEvent,
...@@ -849,7 +850,9 @@ export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewE...@@ -849,7 +850,9 @@ export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewE
849export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(850export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(
850 WS_METHODS.subscribeDiscoveredLocalServers,851 WS_METHODS.subscribeDiscoveredLocalServers,
851 {852 {
852 payload: Schema.Struct({}),853 payload: Schema.Struct({
854 configuredUrls: Schema.optional(ConfiguredLocalServerUrls),
855 }),
853 success: DiscoveredLocalServerList,856 success: DiscoveredLocalServerList,
854 error: EnvironmentAuthorizationError,857 error: EnvironmentAuthorizationError,
855 stream: true,858 stream: true,