| author | |
| committer | |
| log | 9fd788b5a92254a2afa72c2a53513ed2cb730f0d |
| tree | 9227ad1b9feb50f353cadb33ce253881e0edd2ca |
| parent | 710fd0eeb3b4c4146c28fa0fc558ac4c236a8067 |
| signature | Signed by PGP key B5690EEEBB952194 |
15 files changed, 1065 insertions(+), 213 deletions(-)
apps/server/src/preview/PortScanner.test.ts+532-25| ... | @@ -1,35 +1,60 @@ | ... | @@ -1,35 +1,60 @@ |
| 1 | import * as NodeNet from "node:net"; | 1 | import * as NodeNet from "node:net"; |
| 2 | 2 | ||
| 3 | import { it as effectIt } from "@effect/vitest"; | 3 | import { it as effectIt } from "@effect/vitest"; |
| 4 | import { | ||
| 5 | CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, | ||
| 6 | PREVIEW_URL_MAX_LENGTH, | ||
| 7 | type DiscoveredLocalServer, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 4 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 9 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 5 | import * as Net from "@t3tools/shared/Net"; | 10 | import * as Net from "@t3tools/shared/Net"; |
| 6 | import * as Cause from "effect/Cause"; | 11 | import * as Cause from "effect/Cause"; |
| 12 | import * as Clock from "effect/Clock"; | ||
| 13 | import * as Duration from "effect/Duration"; | ||
| 7 | import * as Effect from "effect/Effect"; | 14 | import * as Effect from "effect/Effect"; |
| 8 | import * as Exit from "effect/Exit"; | 15 | import * as Exit from "effect/Exit"; |
| 16 | import * as Fiber from "effect/Fiber"; | ||
| 9 | import * as Layer from "effect/Layer"; | 17 | import * as Layer from "effect/Layer"; |
| 10 | import * as PlatformError from "effect/PlatformError"; | 18 | import * as PlatformError from "effect/PlatformError"; |
| 19 | import * as Scope from "effect/Scope"; | ||
| 20 | import * as TestClock from "effect/testing/TestClock"; | ||
| 11 | import { expect } from "vite-plus/test"; | 21 | import { expect } from "vite-plus/test"; |
| 22 | import { FetchHttpClient } from "effect/unstable/http"; | ||
| 12 | 23 | ||
| 13 | import * as ProcessRunner from "../processRunner.ts"; | 24 | import * as ProcessRunner from "../processRunner.ts"; |
| 14 | import * as PortScanner from "./PortScanner.ts"; | 25 | import * as PortScanner from "./PortScanner.ts"; |
| 15 | const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { | 26 | const 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 | |||
| 41 | const TestProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, { | ||
| 42 | run: processProbeFailure, | ||
| 30 | }); | 43 | }); |
| 31 | 44 | ||
| 32 | const makeProbeFailureLayer = (run: ProcessRunner.ProcessRunner["Service"]["run"]) => | 45 | let integrationListeningPort: number | null = null; |
| 46 | |||
| 47 | const 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 | |||
| 54 | const 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 | ); |
| 47 | 73 | ||
| 48 | const TestPortDiscoveryLive = PortScanner.layer.pipe( | 74 | const 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 | ); |
| 53 | 84 | ||
| 54 | const openServer = (port: number): Effect.Effect<NodeNet.Server | null> => | 85 | const LSOF_TEST_PORT = 43_123; |
| 86 | |||
| 87 | const 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 | |||
| 121 | const 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> => |
| 72 | 142 | ||
| 73 | const openCommonDevServer = Effect.fn("PortScannerTest.openCommonDevServer")(function* ( | 143 | const 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 | }); |
| 84 | 155 | ||
| 85 | const commonDevServer = Effect.acquireRelease( | 156 | const 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 | |||
| 178 | const 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 | ); |
| 89 | 198 | ||
| 90 | /** | 199 | /** |
| ... | @@ -94,7 +203,7 @@ const commonDevServer = Effect.acquireRelease( | ... | @@ -94,7 +203,7 @@ const commonDevServer = Effect.acquireRelease( |
| 94 | */ | 203 | */ |
| 95 | effectIt.layer(TestPortDiscoveryLive)("PortDiscovery integration (TCP probe fallback)", (it) => { | 204 | effectIt.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 | ); |
| 107 | 216 | ||
| 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 | }); |
| 124 | 243 | ||
| 125 | effectIt("does not swallow process probe defects", () => | 244 | effectIt.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 | |||
| 272 | effectIt.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 | |||
| 298 | effectIt.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 | |||
| 320 | effectIt.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 | |||
| 342 | effectIt.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 | |||
| 372 | effectIt.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 | |||
| 406 | effectIt.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 | |||
| 441 | effectIt.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 | |||
| 460 | effectIt.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 | |||
| 480 | effectIt.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 | |||
| 507 | effectIt.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 | |||
| 531 | effectIt.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 | |||
| 552 | effectIt.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 | |||
| 600 | effectIt.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 | |||
| 632 | effectIt.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 | ); |
| 142 | 649 | ||
| 143 | effectIt("does not swallow process probe interruption", () => | 650 | effectIt.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); |
| 146 | 653 |
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 through | 8 | * 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 fiber | 16 | * 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 | */ |
| 14 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; | 19 | import { |
| 20 | CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, | ||
| 21 | PREVIEW_URL_MAX_LENGTH, | ||
| 22 | ThreadId, | ||
| 23 | type DiscoveredLocalServer, | ||
| 24 | } from "@t3tools/contracts"; | ||
| 15 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; | 25 | import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; |
| 16 | import * as Net from "@t3tools/shared/Net"; | 26 | import * as Net from "@t3tools/shared/Net"; |
| 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | 27 | import { isLoopbackHost, LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; |
| 18 | import * as Cause from "effect/Cause"; | 28 | import * as Cause from "effect/Cause"; |
| 29 | import * as Clock from "effect/Clock"; | ||
| 19 | import * as Context from "effect/Context"; | 30 | import * as Context from "effect/Context"; |
| 20 | import * as Duration from "effect/Duration"; | 31 | import * as Duration from "effect/Duration"; |
| 21 | import * as Effect from "effect/Effect"; | 32 | import * as Effect from "effect/Effect"; |
| 22 | import * as Layer from "effect/Layer"; | 33 | import * as Layer from "effect/Layer"; |
| 34 | import * as Option from "effect/Option"; | ||
| 23 | import * as Ref from "effect/Ref"; | 35 | import * as Ref from "effect/Ref"; |
| 24 | import * as Schedule from "effect/Schedule"; | 36 | import * as Schedule from "effect/Schedule"; |
| 25 | import * as Scope from "effect/Scope"; | 37 | import * as Scope from "effect/Scope"; |
| 38 | import * as Semaphore from "effect/Semaphore"; | ||
| 39 | import { FetchHttpClient, HttpClient } from "effect/unstable/http"; | ||
| 26 | 40 | ||
| 27 | import * as ProcessRunner from "../processRunner.ts"; | 41 | import * as ProcessRunner from "../processRunner.ts"; |
| 28 | 42 | ||
| 29 | export class PortDiscovery extends Context.Service< | 43 | export 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([ |
| 53 | const POLL_INTERVAL = Duration.seconds(3); | 73 | const POLL_INTERVAL = Duration.seconds(3); |
| 54 | const LSOF_TIMEOUT_MS = 5_000; | 74 | const LSOF_TIMEOUT_MS = 5_000; |
| 55 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; | 75 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; |
| 76 | const WEB_PROBE_TIMEOUT = Duration.seconds(1); | ||
| 77 | const WEB_PROBE_CACHE_TTL_MS = Duration.toMillis(Duration.seconds(15)); | ||
| 78 | const WEB_PROBE_CONCURRENCY = 16; | ||
| 79 | const NAVIGATION_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]); | ||
| 56 | 80 | ||
| 57 | type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; | 81 | type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; |
| 58 | 82 | ||
| 59 | interface ScannerState { | 83 | interface ListenerSubscription { |
| 84 | readonly configuredUrls: ReadonlyArray<string>; | ||
| 60 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; | 85 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; |
| 61 | readonly listeners: ReadonlySet<Listener>; | 86 | } |
| 87 | |||
| 88 | interface 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 | } |
| 76 | 104 | ||
| 105 | interface WebProbeCacheEntry { | ||
| 106 | readonly pid: number | null; | ||
| 107 | readonly isWeb: boolean; | ||
| 108 | readonly expiresAtMillis: number; | ||
| 109 | } | ||
| 110 | |||
| 111 | interface WebProbeGroup { | ||
| 112 | readonly server: DiscoveredLocalServer; | ||
| 113 | readonly urls: ReadonlyArray<string>; | ||
| 114 | readonly configuredKey: string | null; | ||
| 115 | } | ||
| 116 | |||
| 117 | interface WebProbeSnapshot { | ||
| 118 | readonly discovered: ReadonlyArray<DiscoveredLocalServer>; | ||
| 119 | readonly configured: ReadonlyMap<string, DiscoveredLocalServer>; | ||
| 120 | } | ||
| 121 | |||
| 77 | const terminalOwnerKey = (owner: { | 122 | const 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}`; |
| 81 | 126 | ||
| 127 | const 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 | |||
| 138 | const localServerKey = (host: string, port: number): string => | ||
| 139 | `${isLoopbackHost(host) ? "loopback" : host.toLowerCase()}:${port}`; | ||
| 140 | |||
| 141 | const urlPort = (url: URL): number => | ||
| 142 | url.port.length > 0 ? Number.parseInt(url.port, 10) : url.protocol === "http:" ? 80 : 443; | ||
| 143 | |||
| 144 | const webProbeCacheKey = (raw: string): string => { | ||
| 145 | const url = new URL(raw); | ||
| 146 | url.hash = ""; | ||
| 147 | return url.href; | ||
| 148 | }; | ||
| 149 | |||
| 150 | const 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 | |||
| 165 | const 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 | |||
| 82 | const parseLsofOutput = ( | 185 | const 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); | ||
| 199 | 304 | ||
| 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 | }); |
| 223 | 328 | ||
| 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)); |
| 231 | 479 | ||
| 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* processRunner | 516 | 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 | }); |
| 287 | 537 | ||
| 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 | }; | ||
| 294 | 549 | ||
| 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 | ); |
| 333 | 603 | ||
| 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 | })); |
| 18 | 17 | ||
| ... | @@ -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 | } |
| 44 | 42 |
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); |
| 36 | 33 | ||
| ... | @@ -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 here | 42 | 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({ |
| 51 | 48 | ||
| 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 | } |
| 31 | 30 | ||
| 32 | function describeServer(server: PreviewableServer): string { | 31 | function 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 | |||
| 39 | function 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 | |||
| 48 | function 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 @@ |
| 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | 1 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { mergeServers, type PreviewableServer } from "./useDiscoveredLocalServers"; | 4 | import { mergeServers } from "./useDiscoveredLocalServers"; |
| 5 | 5 | ||
| 6 | const scannerServer = ( | 6 | const 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 | }); |
| 52 | 48 | ||
| 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 | }); |
| 78 | 56 | ||
| 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 | }); |
| 87 | 65 | ||
| 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 | }); |
| 102 | 73 | ||
| 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 | }); |
| 111 | 82 | ||
| 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 | }); | ||
| 128 | 131 | ||
| 129 | describe("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"; |
| 4 | 4 | ||
| 5 | import type { EnvironmentId } from "@t3tools/contracts"; | 5 | import type { EnvironmentId } from "@t3tools/contracts"; |
| 6 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; | 6 | import { resolveDiscoveredServerUrl } from "~/browser/browserTargetResolver"; |
| 7 | import { useDiscoveredPorts } from "~/portDiscoveryState"; | 7 | import { useDiscoveredPortsState } from "~/portDiscoveryState"; |
| 8 | 8 | ||
| 9 | export interface PreviewableServer extends DiscoveredLocalServer { | 9 | export 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 target | 12 | * 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 { |
| 23 | interface UseDiscoveredLocalServersInput { | 18 | interface UseDiscoveredLocalServersInput { |
| 24 | environmentId: EnvironmentId; | 19 | environmentId: EnvironmentId; |
| 25 | configuredUrls?: ReadonlyArray<string> | undefined; | 20 | configuredUrls?: ReadonlyArray<string> | undefined; |
| 26 | recentlySeenUrls?: ReadonlyArray<string> | undefined; | ||
| 27 | } | 21 | } |
| 28 | 22 | ||
| 29 | /** | 23 | /** |
| 30 | * Merge the environment-level port snapshot with configured / recently-seen | 24 | * 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 | */ |
| 33 | export function useDiscoveredLocalServers( | 27 | export 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); |
| 37 | 31 | ||
| 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 | } |
| 52 | 46 | ||
| 53 | export function mergeServers(input: { | 47 | export 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 }>(); |
| 59 | 53 | ||
| 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 | } |
| 77 | 60 | ||
| 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; flip | 66 | ...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 | } |
| 113 | 72 | ||
| 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 | } |
| 126 | 84 | ||
| 127 | function canonicalKey(host: string, port: number): string { | 85 | function 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 | } |
| 130 | 89 | ||
| 131 | function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { | 90 | function parseLocalUrl(raw: string): { host: string; port: number; url: string } | null { |
apps/web/src/portDiscoveryState.test.ts created+35| ... | @@ -0,0 +1,35 @@ | ||
| 1 | import { CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS, PREVIEW_URL_MAX_LENGTH } from "@t3tools/contracts"; | ||
| 2 | import { describe, expect, it } from "vite-plus/test"; | ||
| 3 | |||
| 4 | import { boundConfiguredLocalServerUrls } from "./portDiscoveryState"; | ||
| 5 | |||
| 6 | describe("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 @@ |
| 1 | import type { DiscoveredLocalServer, EnvironmentId, ThreadId } from "@t3tools/contracts"; | 1 | import { |
| 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"; | ||
| 8 | import { isLoopbackHost } from "@t3tools/shared/preview"; | ||
| 2 | import { useMemo } from "react"; | 9 | import { useMemo } from "react"; |
| 3 | 10 | ||
| 4 | import { previewEnvironment } from "./state/preview"; | 11 | import { previewEnvironment } from "./state/preview"; |
| ... | @@ -6,15 +13,63 @@ import { useEnvironmentQuery } from "./state/query"; | ... | @@ -6,15 +13,63 @@ import { useEnvironmentQuery } from "./state/query"; |
| 6 | 13 | ||
| 7 | const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]); | 14 | const EMPTY_PORTS: ReadonlyArray<DiscoveredLocalServer> = Object.freeze([]); |
| 8 | 15 | ||
| 16 | interface DiscoveredPortsState { | ||
| 17 | readonly servers: ReadonlyArray<DiscoveredLocalServer>; | ||
| 18 | readonly configuredUrlProbing: boolean; | ||
| 19 | } | ||
| 20 | |||
| 21 | export 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 | |||
| 9 | export function useDiscoveredPorts( | 46 | export 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 | |||
| 53 | export 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 === null | 59 | environmentId === null |
| 14 | ? null | 60 | ? 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 | } |
| 19 | 74 | ||
| 20 | export function useThreadDiscoveredPorts(input: { | 75 | export 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"; |
| 2 | import { describe, expect, it } from "vite-plus/test"; | 2 | import { describe, expect, it } from "vite-plus/test"; |
| 3 | 3 | ||
| 4 | import { | 4 | import { |
| 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); |
| 21 | const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); | 24 | const decodeSnapshot = Schema.decodeUnknownSync(PreviewSessionSnapshot); |
| 22 | const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); | 25 | const decodeNavStatus = Schema.decodeUnknownSync(PreviewNavStatus); |
| 23 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); | 26 | const decodeServer = Schema.decodeUnknownSync(DiscoveredLocalServer); |
| 27 | const decodeConfiguredLocalServerUrls = Schema.decodeUnknownSync(ConfiguredLocalServerUrls); | ||
| 24 | const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); | 28 | const decodeViewport = Schema.decodeUnknownSync(PreviewViewportSetting); |
| 25 | const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); | 29 | const decodeResizeInput = Schema.decodeUnknownSync(PreviewAutomationResizeInput); |
| 26 | const decodeOpenInput = Schema.decodeUnknownSync(PreviewAutomationOpenInput); | 30 | const 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 | |||
| 350 | describe("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 @@ |
| 11 | import { Schema } from "effect"; | 11 | import { Schema } from "effect"; |
| 12 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; | 12 | import { NonNegativeInt, PositiveInt, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; |
| 13 | 13 | ||
| 14 | const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(2048)); | 14 | export const PREVIEW_URL_MAX_LENGTH = 2_048; |
| 15 | export const CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS = 32; | ||
| 16 | |||
| 17 | const Url = TrimmedNonEmptyString.check(Schema.isMaxLength(PREVIEW_URL_MAX_LENGTH)); | ||
| 18 | |||
| 19 | export const ConfiguredLocalServerUrls = Schema.Array(Url).check( | ||
| 20 | Schema.isMaxLength(CONFIGURED_LOCAL_SERVER_URLS_MAX_ITEMS), | ||
| 21 | ); | ||
| 15 | const Title = Schema.String.check(Schema.isMaxLength(512)); | 22 | const Title = Schema.String.check(Schema.isMaxLength(512)); |
| 16 | 23 | ||
| 17 | export const PreviewTabId = TrimmedNonEmptyString.check(Schema.isMaxLength(128)); | 24 | export 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; |
| 272 | export const DiscoveredLocalServerList = Schema.Struct({ | 279 | export 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 | }); |
| 276 | export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; | 284 | export type DiscoveredLocalServerList = typeof DiscoveredLocalServerList.Type; |
| 277 | 285 |
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"; |
| 130 | import { | 130 | import { |
| 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 |
| 849 | export const WsSubscribeDiscoveredLocalServersRpc = Rpc.make( | 850 | export 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, |