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 @@
11import * as NodeNet from "node:net";
22
33import { 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";
49import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
510import * as Net from "@t3tools/shared/Net";
611import * as Cause from "effect/Cause";
12import * as Clock from "effect/Clock";
13import * as Duration from "effect/Duration";
714import * as Effect from "effect/Effect";
815import * as Exit from "effect/Exit";
16import * as Fiber from "effect/Fiber";
917import * as Layer from "effect/Layer";
1018import * as PlatformError from "effect/PlatformError";
19import * as Scope from "effect/Scope";
20import * as TestClock from "effect/testing/TestClock";
1121import { expect } from "vite-plus/test";
22import { FetchHttpClient } from "effect/unstable/http";
1223
1324import * as ProcessRunner from "../processRunner.ts";
1425import * as PortScanner from "./PortScanner.ts";
15const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
16 run: (input) =>
17 Effect.fail(
18 new ProcessRunner.ProcessSpawnError({
19 command: input.command,
20 argumentCount: input.args.length,
21 cwd: input.cwd,
22 cause: PlatformError.systemError({
23 _tag: "NotFound",
24 module: "ChildProcess",
25 method: "spawn",
26 description: "PowerShell is not installed in the test environment",
27 }),
26const processProbeFailure: ProcessRunner.ProcessRunner["Service"]["run"] = (input) =>
27 Effect.fail(
28 new ProcessRunner.ProcessSpawnError({
29 command: input.command,
30 argumentCount: input.args.length,
31 cwd: input.cwd,
32 cause: PlatformError.systemError({
33 _tag: "NotFound",
34 module: "ChildProcess",
35 method: "spawn",
36 description: "PowerShell is not installed in the test environment",
2837 }),
29 ),
38 }),
39 );
40
41const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
42 run: processProbeFailure,
3043});
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) =>
3358 PortScanner.layer.pipe(
3459 Layer.provide(
3560 Layer.mergeAll(
......@@ -41,19 +66,64 @@ const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"
4166 findAvailablePort: (preferred) => Effect.succeed(preferred),
4267 }),
4368 Layer.succeed(HostProcessPlatform, "linux"),
69 FetchHttpClient.layer.pipe(Layer.provide(Layer.succeed(FetchHttpClient.Fetch, fetch))),
4470 ),
4571 ),
4672 );
4773
4874const TestPortDiscoveryLive = PortScanner.layer.pipe(
4975 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 ),
5182 ),
5283);
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> =>
55125 Effect.callback((resume) => {
56 const server = NodeNet.createServer();
126 const server = NodeNet.createServer(onConnection);
57127 server.once("error", () => {
58128 resume(Effect.succeed(null));
59129 });
......@@ -72,9 +142,10 @@ const closeServer = (server: NodeNet.Server): Effect.Effect<void> =>
72142
73143const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* (
74144 ports: ReadonlyArray<number>,
145 onConnection: (socket: NodeNet.Socket) => void,
75146) {
76147 for (const port of ports) {
77 const server = yield* openServer(port);
148 const server = yield* openServer(port, onConnection);
78149 if (server !== null) return { port, server };
79150 }
80151 return yield* Effect.die(
......@@ -83,8 +154,46 @@ const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(fun
83154});
84155
85156const commonDevServer = Effect.acquireRelease(
86 openCommonDevServer(PortScanner.COMMON_DEV_PORTS),
87 ({ server }) => closeServer(server),
157 openCommonDevServer(PortScanner.COMMON_DEV_PORTS, (socket) => {
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 ),
88197);
89198
90199/**
......@@ -94,7 +203,7 @@ const commonDevServer = Effect.acquireRelease(
94203 */
95204effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => {
96205 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",
98207 Effect.fn("PortScannerTest.scanFindsCommonDevServer")(function* () {
99208 const { port } = yield* commonDevServer;
100209 const scanner = yield* PortScanner.PortDiscovery;
......@@ -105,13 +214,23 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
105214 }),
106215 );
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
108227 it.effect(
109228 "retain drives an immediate broadcast to subscribers",
110229 Effect.fn("PortScannerTest.retainBroadcastsImmediately")(function* () {
111230 const { port } = yield* commonDevServer;
112231 const received: number[] = [];
113232 const scanner = yield* PortScanner.PortDiscovery;
114 yield* scanner.subscribe((servers) =>
233 yield* scanner.subscribe({ configuredUrls: [], initialSnapshot: [] }, (servers) =>
115234 Effect.sync(() => {
116235 for (const server of servers) received.push(server.port);
117236 }),
......@@ -122,7 +241,395 @@ effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fall
122241 );
123242});
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", () =>
126633 Effect.gen(function* () {
127634 const defect = new Error("unexpected process probe defect");
128635 const layer = makeProbeFailureLayer(() => Effect.die(defect));
......@@ -140,7 +647,7 @@ effectIt("does not swallow process probe defects", () =>
140647 }),
141648);
142649
143effectIt("does not swallow process probe interruption", () =>
650effectIt.effect("does not swallow process probe interruption", () =>
144651 Effect.gen(function* () {
145652 const layer = makeProbeFailureLayer(() => Effect.interrupt);
146653
apps/server/src/preview/PortScanner.ts+305-31
......@@ -8,29 +8,49 @@
88 * Windows / lsof missing: checks a curated list of common dev ports through
99 * the shared Net service.
1010 *
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 *
1116 * Polling is reference-counted via scoped `retain`. A single layer-scoped fiber
1217 * polls forever, but each tick is a no-op when the retain count is zero.
1318 */
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";
1525import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
1626import * 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";
1828import * as Cause from "effect/Cause";
29import * as Clock from "effect/Clock";
1930import * as Context from "effect/Context";
2031import * as Duration from "effect/Duration";
2132import * as Effect from "effect/Effect";
2233import * as Layer from "effect/Layer";
34import * as Option from "effect/Option";
2335import * as Ref from "effect/Ref";
2436import * as Schedule from "effect/Schedule";
2537import * as Scope from "effect/Scope";
38import * as Semaphore from "effect/Semaphore";
39import { FetchHttpClient, HttpClient } from "effect/unstable/http";
2640
2741import * as ProcessRunner from "../processRunner.ts";
2842
2943export class PortDiscovery extends Context.Service<
3044 PortDiscovery,
3145 {
32 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
46 readonly scan: (
47 configuredUrls?: ReadonlyArray<string>,
48 ) => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
3349 readonly subscribe: (
50 input: {
51 readonly configuredUrls: ReadonlyArray<string>;
52 readonly initialSnapshot: ReadonlyArray<DiscoveredLocalServer>;
53 },
3454 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,
3555 ) => Effect.Effect<void, never, Scope.Scope>;
3656 readonly retain: Effect.Effect<void, never, Scope.Scope>;
......@@ -53,12 +73,20 @@ export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([
5373const POLL_INTERVAL = Duration.seconds(3);
5474const LSOF_TIMEOUT_MS = 5_000;
5575const 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
5781type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;
5882
59interface ScannerState {
83interface ListenerSubscription {
84 readonly configuredUrls: ReadonlyArray<string>;
6085 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
61 readonly listeners: ReadonlySet<Listener>;
86}
87
88interface ScannerState {
89 readonly listeners: ReadonlyMap<Listener, ListenerSubscription>;
6290 readonly terminalProcesses: ReadonlyMap<
6391 string,
6492 {
......@@ -74,11 +102,86 @@ interface TerminalProcessOwner {
74102 readonly terminalId: string;
75103}
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
77122const terminalOwnerKey = (owner: {
78123 readonly threadId: string;
79124 readonly terminalId: string;
80125}): 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
82185const parseLsofOutput = (
83186 raw: string,
84187 terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(),
......@@ -190,12 +293,14 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
190293 const net = yield* Net.NetService;
191294 const processRunner = yield* ProcessRunner.ProcessRunner;
192295 const hostPlatform = yield* HostProcessPlatform;
296 const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.withScope);
193297 const stateRef = yield* Ref.make<ScannerState>({
194 lastSnapshot: [],
195 listeners: new Set(),
298 listeners: new Map(),
196299 terminalProcesses: new Map(),
197300 retainCount: 0,
198301 });
302 const webProbeCacheRef = yield* Ref.make<ReadonlyMap<string, WebProbeCacheEntry>>(new Map());
303 const scanSemaphore = yield* Semaphore.make(1);
199304
200305 const probeCommonPorts = Effect.fn("PortDiscovery.probeCommonPorts")(function* () {
201306 const results = yield* Effect.forEach(
......@@ -221,6 +326,149 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
221326 }));
222327 });
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
224472 const recoverProcessProbeFailure =
225473 (probe: "lsof" | "windows-listeners") => (error: ProcessRunner.ProcessRunError) =>
226474 Effect.logDebug("preview port process probe failed; falling back to common-port probes", {
......@@ -229,7 +477,9 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
229477 platform: hostPlatform,
230478 }).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 ) {
233483 const state = yield* Ref.get(stateRef);
234484 const terminalByProcessId = new Map<number, TerminalProcessOwner>();
235485 for (const registration of state.terminalProcesses.values()) {
......@@ -259,8 +509,8 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
259509 ProcessTimeoutError: recoverWindowsProbeFailure,
260510 }),
261511 );
262 if (listeners !== null) return listeners;
263 return yield* probeCommonPorts();
512 if (listeners !== null) return yield* probeWebServers(listeners, configuredUrls);
513 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
264514 }
265515 const recoverLsofProbeFailure = recoverProcessProbeFailure("lsof");
266516 const lsofResult = yield* processRunner
......@@ -281,27 +531,47 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
281531 ProcessTimeoutError: recoverLsofProbeFailure,
282532 }),
283533 );
284 if (lsofResult !== null) return lsofResult;
285 return yield* probeCommonPorts();
534 if (lsofResult !== null) return yield* probeWebServers(lsofResult, configuredUrls);
535 return yield* probeWebServers(yield* probeCommonPorts(), configuredUrls);
286536 });
287537
288 const broadcast = Effect.fn("PortDiscovery.broadcast")(function* (
289 servers: ReadonlyArray<DiscoveredLocalServer>,
290 ) {
291 const listeners = (yield* Ref.get(stateRef)).listeners;
292 yield* Effect.forEach(listeners, (listener) => listener(servers), { discard: true });
293 });
538 const scanSnapshot = Effect.fn("PortDiscovery.scanSnapshot")(
539 (configuredUrls: ReadonlyArray<string>) =>
540 scanSemaphore.withPermits(1)(scanUnlocked(configuredUrls)),
541 );
542
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
295550 const pollTick = Effect.fn("PortDiscovery.pollTick")(
296551 function* () {
297552 if ((yield* Ref.get(stateRef)).retainCount <= 0) return;
298 const next = yield* scanOnce();
299 const changed = yield* Ref.modify(stateRef, (state) =>
300 serversEqual(state.lastSnapshot, next)
301 ? [false, state]
302 : [true, { ...state, lastSnapshot: next }],
303 );
304 if (changed) yield* broadcast(next);
553 const configuredUrls = [
554 ...new Set(
555 [...(yield* Ref.get(stateRef)).listeners.values()].flatMap(
556 (subscription) => subscription.configuredUrls,
557 ),
558 ),
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 });
305575 },
306576 Effect.catchCause((cause: Cause.Cause<never>) =>
307577 Effect.logWarning("preview port scan failed", Cause.pretty(cause)),
......@@ -332,15 +602,19 @@ export const make = Effect.gen(function* PortDiscoveryMake() {
332602 );
333603
334604 const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")(
335 (listener) =>
605 (input, listener) =>
336606 Effect.acquireRelease(
337 Ref.update(stateRef, (state) => ({
338 ...state,
339 listeners: new Set([...state.listeners, listener]),
340 })),
607 Ref.update(stateRef, (state) => {
608 const listeners = new Map(state.listeners);
609 listeners.set(listener, {
610 configuredUrls: normalizeConfiguredUrls(input.configuredUrls),
611 lastSnapshot: input.initialSnapshot,
612 });
613 return { ...state, listeners };
614 }),
341615 () =>
342616 Ref.update(stateRef, (state) => {
343 const listeners = new Set(state.listeners);
617 const listeners = new Map(state.listeners);
344618 listeners.delete(listener);
345619 return { ...state, listeners };
346620 }),
apps/server/src/ws.ts+15-7
......@@ -2131,23 +2131,31 @@ const makeWsRpcLayer = (
21312131 observeRpcStream(WS_METHODS.subscribePreviewEvents, previewManager.events, {
21322132 "rpc.aggregate": "preview",
21332133 }),
2134 [WS_METHODS.subscribeDiscoveredLocalServers]: (_input) =>
2134 [WS_METHODS.subscribeDiscoveredLocalServers]: (input) =>
21352135 observeRpcStream(
21362136 WS_METHODS.subscribeDiscoveredLocalServers,
21372137 Stream.callback<DiscoveredLocalServerList>((queue) =>
21382138 Effect.gen(function* () {
2139 const configuredUrls = input.configuredUrls ?? [];
21392140 yield* portDiscovery.retain;
2140 const initial = yield* portDiscovery.scan();
2141 const initial = yield* portDiscovery.scan(configuredUrls);
21412142 const initialScannedAt = DateTime.formatIso(yield* DateTime.now);
21422143 yield* Queue.offer(queue, {
21432144 servers: initial,
21442145 scannedAt: initialScannedAt,
2146 configuredUrlProbing: true,
21452147 });
2146 yield* portDiscovery.subscribe((servers) =>
2147 Effect.gen(function* () {
2148 const scannedAt = DateTime.formatIso(yield* DateTime.now);
2149 yield* Queue.offer(queue, { servers, scannedAt });
2150 }),
2148 yield* portDiscovery.subscribe(
2149 { configuredUrls, initialSnapshot: initial },
2150 (servers) =>
2151 Effect.gen(function* () {
2152 const scannedAt = DateTime.formatIso(yield* DateTime.now);
2153 yield* Queue.offer(queue, {
2154 servers,
2155 scannedAt,
2156 configuredUrlProbing: true,
2157 });
2158 }),
21512159 );
21522160 }),
21532161 ),
apps/web/src/components/preview/PreviewEmptyState.test.tsx-2
......@@ -12,7 +12,6 @@ const mocks = vi.hoisted(() => ({
1212 pid: number | null;
1313 terminal: null;
1414 source: "scanner";
15 listening: boolean;
1615 }>,
1716}));
1817
......@@ -38,7 +37,6 @@ function server(port: number) {
3837 pid: 1,
3938 terminal: null,
4039 source: "scanner" as const,
41 listening: true,
4240 };
4341}
4442
apps/web/src/components/preview/PreviewEmptyState.tsx+3-6
......@@ -12,7 +12,6 @@ interface Props {
1212 threadRef: ScopedThreadRef;
1313 environmentId: EnvironmentId;
1414 configuredUrls?: ReadonlyArray<string> | undefined;
15 recentlySeenUrls?: ReadonlyArray<string> | undefined;
1615 recentEntries: ReadonlyArray<BrowserHistoryEntry>;
1716 onRemoveRecent: (url: string) => void;
1817 onOpenUrl: (url: string) => void;
......@@ -22,7 +21,6 @@ export function PreviewEmptyState({
2221 threadRef,
2322 environmentId,
2423 configuredUrls,
25 recentlySeenUrls,
2624 recentEntries,
2725 onRemoveRecent,
2826 onOpenUrl,
......@@ -30,7 +28,6 @@ export function PreviewEmptyState({
3028 const servers = useDiscoveredLocalServers({
3129 environmentId,
3230 configuredUrls,
33 recentlySeenUrls,
3431 });
3532 const recents = recentEntries.filter((entry) => URL.canParse(entry.url)).slice(0, 8);
3633
......@@ -42,7 +39,7 @@ export function PreviewEmptyState({
4239 </EmptyMedia>
4340 <EmptyTitle>No preview yet</EmptyTitle>
4441 <EmptyDescription>
45 Type a URL above, or run a dev script. Listening localhost ports will show up here
42 Type a URL above, or run a dev script. Browser-ready localhost servers will show up here
4643 automatically.
4744 </EmptyDescription>
4845 </Empty>
......@@ -51,7 +48,7 @@ export function PreviewEmptyState({
5148
5249 return (
5350 <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">
5552 {recents.length > 0 ? (
5653 <div className="flex flex-col gap-3">
5754 <div className="flex items-center gap-2 text-sm text-muted-foreground">
......@@ -88,7 +85,7 @@ export function PreviewEmptyState({
8885 ))}
8986 </div>
9087 <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.
9289 </p>
9390 </div>
9491 ) : null}
apps/web/src/components/preview/PreviewLocalServerCard.tsx+1-22
......@@ -24,32 +24,11 @@ export function PreviewLocalServerCard({ threadRef, server, onOpen }: Props) {
2424 {server.host}:{server.port}
2525 </span>
2626 </div>
27 {server.listening ? <PulsingDot /> : <DimDot />}
2827 </button>
2928 );
3029}
3130
3231function describeServer(server: PreviewableServer): string {
3332 if (server.processName) return server.processName;
34 if (server.listening) 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 );
33 return "Listening";
5534}
apps/web/src/components/preview/PreviewView.tsx-1
......@@ -713,7 +713,6 @@ export function PreviewView({
713713 threadRef={threadRef}
714714 environmentId={threadRef.environmentId}
715715 configuredUrls={configuredUrls}
716 recentlySeenUrls={previewState.recentlySeenUrls}
717716 recentEntries={recentHistoryEntries}
718717 onRemoveRecent={(url) => removeUrlForThread(threadRef, url)}
719718 onOpenUrl={(next) => void handleOpenServerUrl(next)}
apps/web/src/components/preview/useDiscoveredLocalServers.test.ts+60-53
......@@ -1,7 +1,7 @@
11import type { DiscoveredLocalServer } from "@t3tools/contracts";
22import { describe, expect, it } from "vite-plus/test";
33
4import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers";
4import { mergeServers } from "./useDiscoveredLocalServers";
55
66const scannerServer = (
77 overrides: Partial<DiscoveredLocalServer & { requestedUrl: string }>,
......@@ -21,7 +21,6 @@ describe("mergeServers", () => {
2121 const result = mergeServers({
2222 scanner: [scannerServer({})],
2323 configuredUrls: [],
24 recentlySeenUrls: [],
2524 });
2625 expect(result).toHaveLength(1);
2726 expect(result[0]).toMatchObject({
......@@ -29,7 +28,6 @@ describe("mergeServers", () => {
2928 port: 5173,
3029 requestedUrl: "http://localhost:5173",
3130 source: "scanner",
32 listening: true,
3331 processName: "vite",
3432 });
3533 });
......@@ -38,102 +36,111 @@ describe("mergeServers", () => {
3836 const result = mergeServers({
3937 scanner: [scannerServer({ port: 5173, processName: "node", pid: 9999 })],
4038 configuredUrls: ["http://localhost:5173"],
41 recentlySeenUrls: [],
4239 });
4340 expect(result).toHaveLength(1);
4441 expect(result[0]).toMatchObject({
4542 port: 5173,
4643 source: "configured",
47 listening: true,
4844 processName: "node",
4945 pid: 9999,
5046 });
5147 });
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", () => {
5450 const result = mergeServers({
5551 scanner: [],
5652 configuredUrls: ["http://localhost:5173"],
57 recentlySeenUrls: [],
5853 });
59 expect(result).toHaveLength(1);
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/");
54 expect(result).toHaveLength(0);
7755 });
7856
79 it("ignores non-loopback URLs in configured/recent inputs", () => {
57 it("ignores non-loopback configured URLs", () => {
8058 const result = mergeServers({
81 scanner: [],
59 scanner: [scannerServer({})],
8260 configuredUrls: ["https://example.com", "ws://localhost:5173"],
83 recentlySeenUrls: ["https://api.example.com"],
8461 });
85 expect(result).toHaveLength(0);
62 expect(result).toHaveLength(1);
63 expect(result[0]?.source).toBe("scanner");
8664 });
8765
88 it("sorts: configured before scanner before recent, then by port", () => {
66 it("sorts configured live servers before scanner-only servers", () => {
8967 const result = mergeServers({
9068 scanner: [scannerServer({ port: 8080 }), scannerServer({ port: 3000 })],
91 configuredUrls: ["http://localhost:5173"],
92 recentlySeenUrls: ["http://localhost:9000/", "http://localhost:4321/"],
69 configuredUrls: ["http://localhost:8080"],
9370 });
94 expect(result.map((s) => `${s.source}:${s.port}`)).toEqual([
95 "configured:5173",
96 "scanner:3000",
97 "scanner:8080",
98 "recent:4321",
99 "recent:9000",
100 ]);
71 expect(result.map((s) => `${s.source}:${s.port}`)).toEqual(["configured:8080", "scanner:3000"]);
10172 });
10273
10374 it("dedupes by lowercased host", () => {
10475 const result = mergeServers({
10576 scanner: [scannerServer({ host: "Localhost", port: 5173 })],
10677 configuredUrls: ["http://localhost:5173"],
107 recentlySeenUrls: [],
10878 });
10979 expect(result).toHaveLength(1);
80 expect(result[0]?.source).toBe("configured");
11081 });
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", () => {
11399 const result = mergeServers({
114100 scanner: [
115101 scannerServer({
116 port: 5173,
117102 url: "https://env-42.example.dev:5173/",
118 requestedUrl: "http://localhost:5173/",
103 requestedUrl: "http://localhost:5173/dashboard?mode=test#results",
119104 }),
120105 ],
121 configuredUrls: [],
122 recentlySeenUrls: [],
106 configuredUrls: ["https://localhost:5173/dashboard?mode=test#results"],
123107 });
124108 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
125129 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/");
126130 });
127});
128131
129describe("PreviewableServer interface", () => {
130 it("preserves listening flag through enrichment", () => {
132 it("keeps a scanner entry's pre-resolution requestedUrl distinct from a resolved url", () => {
131133 const result = mergeServers({
132 scanner: [scannerServer({})],
133 configuredUrls: ["http://localhost:5173"],
134 recentlySeenUrls: [],
134 scanner: [
135 scannerServer({
136 port: 5173,
137 url: "https://env-42.example.dev:5173/",
138 requestedUrl: "http://localhost:5173/",
139 }),
140 ],
141 configuredUrls: [],
135142 });
136 const merged: PreviewableServer | undefined = result[0];
137 expect(merged?.listening).toBe(true);
143 expect(result[0]?.url).toBe("https://env-42.example.dev:5173/");
144 expect(result[0]?.requestedUrl).toBe("http://localhost:5173/");
138145 });
139146});
apps/web/src/components/preview/useDiscoveredLocalServers.ts+20-61
......@@ -4,15 +4,10 @@ import { useMemo } from "react";
44
55import type { EnvironmentId } from "@t3tools/contracts";
66import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver";
7import { useDiscoveredPorts } from "~/portDiscoveryState";
7import { useDiscoveredPortsState } from "~/portDiscoveryState";
88
99export interface PreviewableServer extends DiscoveredLocalServer {
10 source: "scanner" | "configured" | "recent";
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;
10 source: "scanner" | "configured";
1611 /**
1712 * Pre-resolution loopback url. `url` is the resolved navigation target
1813 * (volatile on a remote environment); history must key off this instead.
......@@ -23,99 +18,62 @@ export interface PreviewableServer extends DiscoveredLocalServer {
2318interface UseDiscoveredLocalServersInput {
2419 environmentId: EnvironmentId;
2520 configuredUrls?: ReadonlyArray<string> | undefined;
26 recentlySeenUrls?: ReadonlyArray<string> | undefined;
2721}
2822
2923/**
30 * Merge the environment-level port snapshot with configured / recently-seen
24 * Enrich the environment-level live server snapshot with matching configured
3125 * URLs and return a stable sorted list.
3226 */
3327export function useDiscoveredLocalServers(
3428 input: UseDiscoveredLocalServersInput,
3529): ReadonlyArray<PreviewableServer> {
36 const scannerSnapshot = useDiscoveredPorts(input.environmentId);
30 const scannerState = useDiscoveredPortsState(input.environmentId, input.configuredUrls);
3731
3832 return useMemo(
3933 () =>
4034 mergeServers({
41 scanner: scannerSnapshot.map((server) => ({
35 scanner: scannerState.servers.map((server) => ({
4236 ...server,
4337 url: resolveDiscoveredServerUrl(input.environmentId, server.url),
4438 requestedUrl: server.url,
4539 })),
4640 configuredUrls: input.configuredUrls ?? [],
47 recentlySeenUrls: input.recentlySeenUrls ?? [],
41 configuredUrlProbing: scannerState.configuredUrlProbing,
4842 }),
49 [input.environmentId, scannerSnapshot, input.configuredUrls, input.recentlySeenUrls],
43 [input.environmentId, scannerState, input.configuredUrls],
5044 );
5145}
5246
5347export function mergeServers(input: {
5448 scanner: ReadonlyArray<DiscoveredLocalServer & { requestedUrl: string }>;
5549 configuredUrls: ReadonlyArray<string>;
56 recentlySeenUrls: ReadonlyArray<string>;
50 configuredUrlProbing?: boolean;
5751}): ReadonlyArray<PreviewableServer> {
58 const seen = new Map<string, PreviewableServer>();
52 const configuredByServer = new Map<string, { host: string; port: number; url: string }>();
5953
6054 for (const url of input.configuredUrls) {
6155 const parsed = parseLocalUrl(url);
6256 if (!parsed) continue;
6357 const key = canonicalKey(parsed.host, parsed.port);
64 if (seen.has(key)) continue;
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 });
58 if (!configuredByServer.has(key)) configuredByServer.set(key, parsed);
7659 }
7760
61 const live: PreviewableServer[] = [];
7862 for (const server of input.scanner) {
7963 const key = canonicalKey(server.host, server.port);
80 const existing = seen.get(key);
81 if (existing) {
82 // Enrich a configured entry with live process metadata; flip
83 // `listening` so it pulses green like a scanner-discovered entry.
84 seen.set(key, {
85 ...existing,
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,
64 const configured = configuredByServer.get(key);
65 live.push({
66 ...server,
67 requestedUrl:
68 configured && input.configuredUrlProbing === false ? configured.url : server.requestedUrl,
69 source: configured ? "configured" : "scanner",
11170 });
11271 }
11372
114 return Array.from(seen.values()).toSorted((a, b) => {
73 return live.toSorted((a, b) => {
11574 const sourceOrder: Record<PreviewableServer["source"], number> = {
11675 configured: 0,
11776 scanner: 1,
118 recent: 2,
11977 };
12078 if (sourceOrder[a.source] !== sourceOrder[b.source]) {
12179 return sourceOrder[a.source] - sourceOrder[b.source];
......@@ -125,7 +83,8 @@ export function mergeServers(input: {
12583}
12684
12785function canonicalKey(host: string, port: number): string {
128 return `${host.toLowerCase()}:${port}`;
86 const normalizedHost = host.toLowerCase();
87 return `${isLoopbackHost(normalizedHost) ? "loopback" : normalizedHost}:${port}`;
12988}
13089
13190function 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 @@
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";
29import { useMemo } from "react";
310
411import { previewEnvironment } from "./state/preview";
......@@ -6,15 +13,63 @@ import { useEnvironmentQuery } from "./state/query";
613
714const 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
946export function useDiscoveredPorts(
1047 environmentId: EnvironmentId | null,
48 configuredUrls?: ReadonlyArray<string>,
1149): 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);
1258 const query = useEnvironmentQuery(
1359 environmentId === null
1460 ? 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],
1672 );
17 return query.data?.servers ?? EMPTY_PORTS;
1873}
1974
2075export function useThreadDiscoveredPorts(input: {
packages/client-runtime/src/state/preview.ts+3
......@@ -41,6 +41,9 @@ export function createPreviewEnvironmentAtoms<R, E>(
4141 discoveredServers: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
4242 label: "environment-data:preview:discovered-servers",
4343 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,
4447 }),
4548 automationRequests: createEnvironmentRpcSubscriptionAtomFamily(runtime, {
4649 label: "environment-data:preview:automation-requests",
packages/contracts/src/preview.test.ts+20
......@@ -2,7 +2,10 @@ import { Schema } from "effect";
22import { describe, expect, it } from "vite-plus/test";
33
44import {
5 ConfiguredLocalServerUrls,
6 CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS,
57 DiscoveredLocalServer,
8 PREVIEW_URL_MAX_LENGTH,
69 PreviewEvent,
710 PreviewNavStatus,
811 PreviewSessionSnapshot,
......@@ -21,6 +24,7 @@ const decodePreviewEvent = Schema.decodeUnknownSync(PreviewEvent);
2124const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot);
2225const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus);
2326const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer);
27const decodeConfiguredLocalServerUrls = Schema.decodeUnknownSync(ConfiguredLocalServerUrls);
2428const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting);
2529const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput);
2630const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput);
......@@ -342,3 +346,19 @@ describe("DiscoveredLocalServer", () => {
342346 ).toThrow();
343347 });
344348});
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 @@
1111import { Schema } from "effect";
1212import { 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);
1522const Title = Schema.String.check(Schema.isMaxLength(512));
1623
1724export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128));
......@@ -272,6 +279,7 @@ export type DiscoveredLocalServer = typeof DiscoveredLocalServer.Type;
272279export const DiscoveredLocalServerList = Schema.Struct({
273280 servers: Schema.Array(DiscoveredLocalServer),
274281 scannedAt: Schema.String,
282 configuredUrlProbing: Schema.optional(Schema.Literal(true)),
275283});
276284export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type;
277285
packages/contracts/src/rpc.ts+4-1
......@@ -129,6 +129,7 @@ import {
129129} from "./terminal.ts";
130130import {
131131 DiscoveredLocalServerList,
132 ConfiguredLocalServerUrls,
132133 PreviewCloseInput,
133134 PreviewError,
134135 PreviewEvent,
......@@ -849,7 +850,9 @@ export const WsSubscribePreviewEventsRpc = Rpc.make(WS_METHODS.subscribePreviewE
849850export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make(
850851 WS_METHODS.subscribeDiscoveredLocalServers,
851852 {
852 payload: Schema.Struct({}),
853 payload: Schema.Struct({
854 configuredUrls: Schema.optional(ConfiguredLocalServerUrls),
855 }),
853856 success: DiscoveredLocalServerList,
854857 error: EnvironmentAuthorizationError,
855858 stream: true,