| author | |
| committer | |
| log | f7c422d5815e7be03415a87b30558c7bcf2cf8e9 |
| tree | 9653f2b0f620d46530f781a5ba08d86727cc61f3 |
| parent | dd739c880d6c912130cac005ea773ae386560460 |
- Move MCP session registry and preview broker out of `Layers/` and `Services/`
- Update imports, tests, and server wiring to use the new module layout40 files changed, 2353 insertions(+), 2429 deletions(-)
apps/server/src/mcp/Layers/McpHttpServer.test.ts deleted-163| ... | ... | @@ -1,163 +0,0 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import { McpSchema, McpServer } from "effect/unstable/ai"; | |
| 7 | import { HttpServerResponse } from "effect/unstable/http"; | |
| 8 | ||
| 9 | import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; | |
| 10 | import { normalizeMcpHttpResponse, PreviewToolkitRegistrationLive } from "./McpHttpServer.ts"; | |
| 11 | import { previewAutomationBroker } from "./PreviewAutomationBroker.ts"; | |
| 12 | ||
| 13 | const environmentId = EnvironmentId.make("environment-mcp-test"); | |
| 14 | const threadId = ThreadId.make("thread-mcp-test"); | |
| 15 | const tabId = PreviewTabId.make("tab-mcp-test"); | |
| 16 | const invocation = { | |
| 17 | environmentId, | |
| 18 | threadId, | |
| 19 | providerSessionId: "provider-session-mcp-test", | |
| 20 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 21 | capabilities: new Set(["preview"] as const), | |
| 22 | issuedAt: 1, | |
| 23 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 24 | }; | |
| 25 | const client = McpSchema.McpServerClient.of({ | |
| 26 | clientId: 1, | |
| 27 | initializePayload: { | |
| 28 | protocolVersion: "2025-03-26", | |
| 29 | capabilities: {}, | |
| 30 | clientInfo: { name: "mcp-test", version: "1.0.0" }, | |
| 31 | }, | |
| 32 | getClient: Effect.die("unused"), | |
| 33 | }); | |
| 34 | const TestLayer = PreviewToolkitRegistrationLive.pipe( | |
| 35 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 36 | ); | |
| 37 | ||
| 38 | it("normalizes empty successful notification responses to accepted", () => { | |
| 39 | const notificationResponse = normalizeMcpHttpResponse( | |
| 40 | HttpServerResponse.text("", { status: 200, contentType: "application/json" }), | |
| 41 | ); | |
| 42 | expect(notificationResponse.status).toBe(202); | |
| 43 | ||
| 44 | const resultResponse = normalizeMcpHttpResponse( | |
| 45 | HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), | |
| 46 | ); | |
| 47 | expect(resultResponse.status).toBe(200); | |
| 48 | }); | |
| 49 | ||
| 50 | it.effect("registers annotated tools and preserves authenticated request context", () => | |
| 51 | Effect.scoped( | |
| 52 | Effect.gen(function* () { | |
| 53 | const server = yield* McpServer.McpServer; | |
| 54 | const requests = yield* previewAutomationBroker.connect("mcp-test-client"); | |
| 55 | yield* Stream.runForEach(requests, (request) => | |
| 56 | previewAutomationBroker.respond({ | |
| 57 | requestId: request.requestId, | |
| 58 | ok: true, | |
| 59 | result: | |
| 60 | request.operation === "snapshot" | |
| 61 | ? { | |
| 62 | url: "http://example.test/", | |
| 63 | title: "Example", | |
| 64 | loading: false, | |
| 65 | visibleText: "Example", | |
| 66 | interactiveElements: [], | |
| 67 | accessibilityTree: {}, | |
| 68 | consoleEntries: [], | |
| 69 | networkEntries: [], | |
| 70 | actionTimeline: [], | |
| 71 | screenshot: { | |
| 72 | mimeType: "image/png", | |
| 73 | data: Buffer.from("png").toString("base64"), | |
| 74 | width: 10, | |
| 75 | height: 5, | |
| 76 | }, | |
| 77 | } | |
| 78 | : request.operation === "press" | |
| 79 | ? undefined | |
| 80 | : { | |
| 81 | available: true, | |
| 82 | visible: true, | |
| 83 | tabId, | |
| 84 | url: "http://example.test/", | |
| 85 | title: "Example", | |
| 86 | loading: false, | |
| 87 | }, | |
| 88 | }), | |
| 89 | ).pipe(Effect.forkScoped); | |
| 90 | yield* Effect.yieldNow; | |
| 91 | yield* previewAutomationBroker.reportOwner({ | |
| 92 | clientId: "mcp-test-client", | |
| 93 | environmentId, | |
| 94 | threadId, | |
| 95 | tabId, | |
| 96 | visible: true, | |
| 97 | supportsAutomation: true, | |
| 98 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 99 | }); | |
| 100 | ||
| 101 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); | |
| 102 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); | |
| 103 | expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); | |
| 104 | expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); | |
| 105 | ||
| 106 | const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); | |
| 107 | expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); | |
| 108 | expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); | |
| 109 | expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); | |
| 110 | ||
| 111 | const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); | |
| 112 | expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); | |
| 113 | expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); | |
| 114 | expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); | |
| 115 | ||
| 116 | const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); | |
| 117 | expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); | |
| 118 | expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); | |
| 119 | ||
| 120 | const status = yield* server | |
| 121 | .callTool({ name: "preview_status", arguments: {} }) | |
| 122 | .pipe( | |
| 123 | Effect.provideService(McpInvocationContext, invocation), | |
| 124 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 125 | ); | |
| 126 | expect(status.isError).toBe(false); | |
| 127 | expect(status.structuredContent).toMatchObject({ | |
| 128 | available: true, | |
| 129 | tabId, | |
| 130 | }); | |
| 131 | ||
| 132 | const malformed = yield* server | |
| 133 | .callTool({ name: "preview_click", arguments: { selector: "" } }) | |
| 134 | .pipe( | |
| 135 | Effect.provideService(McpInvocationContext, invocation), | |
| 136 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 137 | ); | |
| 138 | expect(malformed.isError).toBe(true); | |
| 139 | ||
| 140 | const snapshot = yield* server | |
| 141 | .callTool({ name: "preview_snapshot", arguments: {} }) | |
| 142 | .pipe( | |
| 143 | Effect.provideService(McpInvocationContext, invocation), | |
| 144 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 145 | ); | |
| 146 | expect(snapshot.isError).toBe(false); | |
| 147 | expect(snapshot.content.some((content) => content.type === "image")).toBe(true); | |
| 148 | expect(snapshot.structuredContent).toMatchObject({ | |
| 149 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, | |
| 150 | }); | |
| 151 | ||
| 152 | const press = yield* server | |
| 153 | .callTool({ name: "preview_press", arguments: { key: "Enter" } }) | |
| 154 | .pipe( | |
| 155 | Effect.provideService(McpInvocationContext, invocation), | |
| 156 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 157 | ); | |
| 158 | expect(press.isError).toBe(false); | |
| 159 | expect(press.structuredContent).toBeUndefined(); | |
| 160 | expect(press.content).toEqual([{ type: "text", text: "null" }]); | |
| 161 | }), | |
| 162 | ).pipe(Effect.provide(TestLayer)), | |
| 163 | ); |
apps/server/src/mcp/Layers/McpHttpServer.ts deleted-165| ... | ... | @@ -1,165 +0,0 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Sink from "effect/Sink"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 8 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; | |
| 9 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 10 | ||
| 11 | import packageJson from "../../../package.json" with { type: "json" }; | |
| 12 | import { McpInvocationContext } from "../Services/McpInvocationContext.ts"; | |
| 13 | import { McpSessionRegistry } from "../Services/McpSessionRegistry.ts"; | |
| 14 | import { PreviewToolkitHandlersLive } from "../toolkits/preview/handlers.ts"; | |
| 15 | import { PreviewToolkit } from "../toolkits/preview/tools.ts"; | |
| 16 | ||
| 17 | const unauthorized = HttpServerResponse.jsonUnsafe( | |
| 18 | { | |
| 19 | error: "invalid_mcp_credential", | |
| 20 | message: "A valid provider-scoped MCP bearer credential is required.", | |
| 21 | }, | |
| 22 | { | |
| 23 | status: 401, | |
| 24 | headers: { | |
| 25 | "cache-control": "no-store", | |
| 26 | "www-authenticate": "Bearer", | |
| 27 | }, | |
| 28 | }, | |
| 29 | ); | |
| 30 | ||
| 31 | export const normalizeMcpHttpResponse = ( | |
| 32 | response: HttpServerResponse.HttpServerResponse, | |
| 33 | ): HttpServerResponse.HttpServerResponse => { | |
| 34 | const bodyIsEmpty = | |
| 35 | response.body._tag === "Empty" || | |
| 36 | (response.body._tag === "Uint8Array" && response.body.contentLength === 0) || | |
| 37 | (response.body._tag === "Raw" && response.body.contentLength === 0); | |
| 38 | return response.status === 200 && bodyIsEmpty | |
| 39 | ? HttpServerResponse.setStatus(response, 202) | |
| 40 | : response; | |
| 41 | }; | |
| 42 | ||
| 43 | const McpAuthMiddlewareLive = HttpRouter.middleware<{ | |
| 44 | provides: McpInvocationContext; | |
| 45 | }>()( | |
| 46 | Effect.gen(function* () { | |
| 47 | const registry = yield* McpSessionRegistry; | |
| 48 | return (httpEffect) => | |
| 49 | Effect.gen(function* () { | |
| 50 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 51 | const authorization = request.headers.authorization; | |
| 52 | const token = | |
| 53 | authorization?.startsWith("Bearer ") === true | |
| 54 | ? authorization.slice("Bearer ".length).trim() | |
| 55 | : ""; | |
| 56 | const invocation = yield* registry.resolve(token); | |
| 57 | if (!invocation) return unauthorized; | |
| 58 | return yield* httpEffect.pipe( | |
| 59 | Effect.provideService(McpInvocationContext, invocation), | |
| 60 | Effect.map(normalizeMcpHttpResponse), | |
| 61 | ); | |
| 62 | }); | |
| 63 | }), | |
| 64 | ).layer; | |
| 65 | ||
| 66 | const McpTransportLive = McpServer.layerHttp({ | |
| 67 | name: "T3 Code", | |
| 68 | version: packageJson.version, | |
| 69 | path: "/mcp", | |
| 70 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); | |
| 71 | ||
| 72 | export const PreviewToolkitRegistrationLive = Layer.effectDiscard( | |
| 73 | Effect.gen(function* () { | |
| 74 | const server = yield* McpServer.McpServer; | |
| 75 | const built = yield* PreviewToolkit; | |
| 76 | const handleTool = built.handle as unknown as ( | |
| 77 | name: keyof typeof built.tools, | |
| 78 | payload: unknown, | |
| 79 | ) => Effect.Effect< | |
| 80 | Stream.Stream<{ readonly encodedResult: unknown }, Error>, | |
| 81 | Error, | |
| 82 | McpInvocationContext | |
| 83 | >; | |
| 84 | for (const tool of Object.values(built.tools)) { | |
| 85 | yield* server.addTool({ | |
| 86 | tool: new McpSchema.Tool({ | |
| 87 | name: tool.name, | |
| 88 | description: Tool.getDescription(tool), | |
| 89 | inputSchema: Tool.getJsonSchema(tool), | |
| 90 | annotations: { | |
| 91 | ...Context.getOption(tool.annotations, Tool.Title).pipe( | |
| 92 | Option.map((title) => ({ title })), | |
| 93 | Option.getOrUndefined, | |
| 94 | ), | |
| 95 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), | |
| 96 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), | |
| 97 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), | |
| 98 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), | |
| 99 | }, | |
| 100 | }), | |
| 101 | annotations: tool.annotations, | |
| 102 | handle: (payload) => | |
| 103 | handleTool(tool.name as keyof typeof built.tools, payload).pipe( | |
| 104 | Stream.unwrap, | |
| 105 | Stream.run(Sink.last()), | |
| 106 | Effect.flatMap(Effect.fromOption), | |
| 107 | Effect.matchCause({ | |
| 108 | onFailure: (cause) => | |
| 109 | new McpSchema.CallToolResult({ | |
| 110 | isError: true, | |
| 111 | content: [{ type: "text", text: Cause.pretty(cause) }], | |
| 112 | }), | |
| 113 | onSuccess: (result) => { | |
| 114 | if (tool.name === "preview_snapshot") { | |
| 115 | const snapshot = result.encodedResult as { | |
| 116 | readonly screenshot: { | |
| 117 | readonly mimeType: "image/png"; | |
| 118 | readonly data: string; | |
| 119 | readonly width: number; | |
| 120 | readonly height: number; | |
| 121 | }; | |
| 122 | readonly [key: string]: unknown; | |
| 123 | }; | |
| 124 | const { screenshot, ...page } = snapshot; | |
| 125 | const metadata = { | |
| 126 | ...page, | |
| 127 | screenshot: { | |
| 128 | mimeType: screenshot.mimeType, | |
| 129 | width: screenshot.width, | |
| 130 | height: screenshot.height, | |
| 131 | }, | |
| 132 | }; | |
| 133 | return new McpSchema.CallToolResult({ | |
| 134 | isError: false, | |
| 135 | structuredContent: metadata, | |
| 136 | content: [ | |
| 137 | { type: "text", text: JSON.stringify(metadata) }, | |
| 138 | { | |
| 139 | type: "image", | |
| 140 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | |
| 141 | mimeType: screenshot.mimeType, | |
| 142 | }, | |
| 143 | ], | |
| 144 | }); | |
| 145 | } | |
| 146 | const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; | |
| 147 | return new McpSchema.CallToolResult({ | |
| 148 | isError: false, | |
| 149 | structuredContent: | |
| 150 | result.encodedResult !== null && typeof result.encodedResult === "object" | |
| 151 | ? result.encodedResult | |
| 152 | : undefined, | |
| 153 | content: [{ type: "text", text: encodedResultText }], | |
| 154 | }); | |
| 155 | }, | |
| 156 | }), | |
| 157 | ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, | |
| 158 | }); | |
| 159 | } | |
| 160 | }), | |
| 161 | ).pipe(Layer.provide(PreviewToolkitHandlersLive)); | |
| 162 | ||
| 163 | export const McpHttpServerLive = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( | |
| 164 | Layer.provideMerge(McpTransportLive), | |
| 165 | ); |
apps/server/src/mcp/Layers/McpSessionRegistry.test.ts deleted-66| ... | ... | @@ -1,66 +0,0 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import { HttpServer } from "effect/unstable/http"; | |
| 6 | ||
| 7 | import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; | |
| 8 | import { makeMcpSessionRegistry } from "./McpSessionRegistry.ts"; | |
| 9 | ||
| 10 | const environmentId = EnvironmentId.make("environment-1"); | |
| 11 | const fakeHttpServer = HttpServer.HttpServer.of({ | |
| 12 | address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, | |
| 13 | serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], | |
| 14 | }); | |
| 15 | const fakeEnvironment = ServerEnvironment.of({ | |
| 16 | getEnvironmentId: Effect.succeed(environmentId), | |
| 17 | getDescriptor: Effect.die("unused"), | |
| 18 | }); | |
| 19 | ||
| 20 | const makeRegistry = (now: () => number) => | |
| 21 | makeMcpSessionRegistry({ | |
| 22 | now, | |
| 23 | idleTimeoutMs: 100, | |
| 24 | maximumLifetimeMs: 1_000, | |
| 25 | }).pipe( | |
| 26 | Effect.provideService(HttpServer.HttpServer, fakeHttpServer), | |
| 27 | Effect.provideService(ServerEnvironment, fakeEnvironment), | |
| 28 | Effect.provide(NodeServices.layer), | |
| 29 | ); | |
| 30 | ||
| 31 | it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => | |
| 32 | Effect.gen(function* () { | |
| 33 | let timestamp = 1_000; | |
| 34 | const registry = yield* makeRegistry(() => timestamp); | |
| 35 | const threadId = ThreadId.make("thread-1"); | |
| 36 | const issued = yield* registry.issue({ | |
| 37 | threadId, | |
| 38 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 39 | }); | |
| 40 | expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); | |
| 41 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 42 | expect(token.length).toBeGreaterThan(20); | |
| 43 | ||
| 44 | const resolved = yield* registry.resolve(token); | |
| 45 | expect(resolved?.threadId).toBe(threadId); | |
| 46 | ||
| 47 | yield* registry.revokeThread(threadId); | |
| 48 | expect(yield* registry.resolve(token)).toBeUndefined(); | |
| 49 | ||
| 50 | timestamp += 2_000; | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it.effect("expires credentials after inactivity", () => | |
| 55 | Effect.gen(function* () { | |
| 56 | let timestamp = 1_000; | |
| 57 | const registry = yield* makeRegistry(() => timestamp); | |
| 58 | const issued = yield* registry.issue({ | |
| 59 | threadId: ThreadId.make("thread-2"), | |
| 60 | providerInstanceId: ProviderInstanceId.make("claude"), | |
| 61 | }); | |
| 62 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 63 | timestamp += 101; | |
| 64 | expect(yield* registry.resolve(token)).toBeUndefined(); | |
| 65 | }), | |
| 66 | ); |
apps/server/src/mcp/Layers/McpSessionRegistry.ts deleted-173| ... | ... | @@ -1,173 +0,0 @@ |
| 1 | import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Crypto from "effect/Crypto"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Ref from "effect/Ref"; | |
| 6 | import { HttpServer } from "effect/unstable/http"; | |
| 7 | ||
| 8 | import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts"; | |
| 9 | import type { McpInvocationScope } from "../Services/McpInvocationContext.ts"; | |
| 10 | import { | |
| 11 | McpSessionRegistry, | |
| 12 | type McpCredentialRequest, | |
| 13 | type McpIssuedCredential, | |
| 14 | type McpSessionRegistryShape, | |
| 15 | } from "../Services/McpSessionRegistry.ts"; | |
| 16 | ||
| 17 | interface CredentialRecord { | |
| 18 | readonly tokenHash: string; | |
| 19 | readonly scope: McpInvocationScope; | |
| 20 | readonly lastUsedAt: number; | |
| 21 | } | |
| 22 | ||
| 23 | interface RegistryState { | |
| 24 | readonly records: ReadonlyMap<string, CredentialRecord>; | |
| 25 | } | |
| 26 | ||
| 27 | export interface McpSessionRegistryOptions { | |
| 28 | readonly idleTimeoutMs?: number; | |
| 29 | readonly maximumLifetimeMs?: number; | |
| 30 | readonly now?: () => number; | |
| 31 | } | |
| 32 | ||
| 33 | const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; | |
| 34 | const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; | |
| 35 | ||
| 36 | const bytesToHex = (bytes: Uint8Array): string => | |
| 37 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | |
| 38 | ||
| 39 | const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); | |
| 40 | ||
| 41 | export const makeMcpSessionRegistry = (options: McpSessionRegistryOptions = {}) => | |
| 42 | Effect.gen(function* () { | |
| 43 | const crypto = yield* Crypto.Crypto; | |
| 44 | const environment = yield* ServerEnvironment; | |
| 45 | const environmentId = yield* environment.getEnvironmentId; | |
| 46 | const httpServer = yield* HttpServer.HttpServer; | |
| 47 | const state = yield* Ref.make<RegistryState>({ records: new Map() }); | |
| 48 | const now = options.now ?? Date.now; | |
| 49 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; | |
| 50 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; | |
| 51 | const endpoint = | |
| 52 | httpServer.address._tag === "TcpAddress" | |
| 53 | ? `http://127.0.0.1:${httpServer.address.port}/mcp` | |
| 54 | : "http://127.0.0.1/mcp"; | |
| 55 | ||
| 56 | const hashToken = (token: string) => | |
| 57 | crypto | |
| 58 | .digest("SHA-256", new TextEncoder().encode(token)) | |
| 59 | .pipe(Effect.map(bytesToHex), Effect.orDie); | |
| 60 | ||
| 61 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | |
| 62 | let changed = false; | |
| 63 | const next = new Map<string, CredentialRecord>(); | |
| 64 | for (const [hash, record] of records) { | |
| 65 | if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { | |
| 66 | next.set(hash, record); | |
| 67 | } else { | |
| 68 | changed = true; | |
| 69 | } | |
| 70 | } | |
| 71 | return changed ? next : records; | |
| 72 | }; | |
| 73 | ||
| 74 | const issue: McpSessionRegistryShape["issue"] = (request) => | |
| 75 | Effect.gen(function* () { | |
| 76 | const issuedAt = now(); | |
| 77 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); | |
| 78 | const rawToken = yield* crypto | |
| 79 | .randomBytes(32) | |
| 80 | .pipe(Effect.map(tokenFromBytes), Effect.orDie); | |
| 81 | const tokenHash = yield* hashToken(rawToken); | |
| 82 | const expiresAt = issuedAt + maximumLifetimeMs; | |
| 83 | const scope: McpInvocationScope = { | |
| 84 | environmentId, | |
| 85 | threadId: ThreadId.make(request.threadId), | |
| 86 | providerSessionId, | |
| 87 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), | |
| 88 | capabilities: new Set(["preview"]), | |
| 89 | issuedAt, | |
| 90 | expiresAt, | |
| 91 | }; | |
| 92 | yield* Ref.update(state, ({ records }) => { | |
| 93 | const next = new Map(pruneExpired(records, issuedAt)); | |
| 94 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); | |
| 95 | return { records: next }; | |
| 96 | }); | |
| 97 | return { | |
| 98 | config: { | |
| 99 | environmentId, | |
| 100 | threadId: scope.threadId, | |
| 101 | providerSessionId, | |
| 102 | providerInstanceId: scope.providerInstanceId, | |
| 103 | endpoint, | |
| 104 | authorizationHeader: `Bearer ${rawToken}`, | |
| 105 | }, | |
| 106 | expiresAt, | |
| 107 | }; | |
| 108 | }); | |
| 109 | ||
| 110 | const resolve: McpSessionRegistryShape["resolve"] = (rawToken) => | |
| 111 | Effect.gen(function* () { | |
| 112 | if (rawToken.length === 0) return undefined; | |
| 113 | const tokenHash = yield* hashToken(rawToken); | |
| 114 | const timestamp = now(); | |
| 115 | let resolved: McpInvocationScope | undefined; | |
| 116 | yield* Ref.update(state, ({ records }) => { | |
| 117 | const current = pruneExpired(records, timestamp); | |
| 118 | const record = current.get(tokenHash); | |
| 119 | if (!record) return { records: current }; | |
| 120 | resolved = record.scope; | |
| 121 | const next = new Map(current); | |
| 122 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); | |
| 123 | return { records: next }; | |
| 124 | }); | |
| 125 | return resolved; | |
| 126 | }); | |
| 127 | ||
| 128 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => | |
| 129 | Ref.update(state, ({ records }) => ({ | |
| 130 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), | |
| 131 | })); | |
| 132 | ||
| 133 | return McpSessionRegistry.of({ | |
| 134 | issue, | |
| 135 | resolve, | |
| 136 | revokeProviderSession: (providerSessionId) => | |
| 137 | revokeWhere((record) => record.scope.providerSessionId === providerSessionId), | |
| 138 | revokeThread: (threadId) => revokeWhere((record) => record.scope.threadId === threadId), | |
| 139 | revokeAll: Ref.set(state, { records: new Map() }), | |
| 140 | }); | |
| 141 | }); | |
| 142 | ||
| 143 | let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; | |
| 144 | ||
| 145 | export const McpSessionRegistryLive: Layer.Layer< | |
| 146 | McpSessionRegistry, | |
| 147 | never, | |
| 148 | Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer | |
| 149 | > = Layer.effect( | |
| 150 | McpSessionRegistry, | |
| 151 | makeMcpSessionRegistry().pipe( | |
| 152 | Effect.tap((registry) => | |
| 153 | Effect.sync(() => { | |
| 154 | activeMcpSessionRegistry = registry; | |
| 155 | }), | |
| 156 | ), | |
| 157 | ), | |
| 158 | ); | |
| 159 | ||
| 160 | export const issueActiveMcpCredential = ( | |
| 161 | request: McpCredentialRequest, | |
| 162 | ): Effect.Effect<McpIssuedCredential | undefined> => | |
| 163 | activeMcpSessionRegistry | |
| 164 | ? activeMcpSessionRegistry | |
| 165 | .revokeThread(request.threadId) | |
| 166 | .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) | |
| 167 | : Effect.sync((): McpIssuedCredential | undefined => undefined); | |
| 168 | ||
| 169 | export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => | |
| 170 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; | |
| 171 | ||
| 172 | export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => | |
| 173 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; |
apps/server/src/mcp/Layers/PreviewAutomationBroker.test.ts deleted-89| ... | ... | @@ -1,89 +0,0 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | PreviewAutomationNoFocusedOwnerError, | |
| 5 | ProviderInstanceId, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | ||
| 11 | import { makePreviewAutomationBroker } from "./PreviewAutomationBroker.ts"; | |
| 12 | ||
| 13 | const scope = { | |
| 14 | environmentId: EnvironmentId.make("environment-1"), | |
| 15 | threadId: ThreadId.make("thread-1"), | |
| 16 | providerSessionId: "provider-session-1", | |
| 17 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 18 | capabilities: new Set(["preview"] as const), | |
| 19 | issuedAt: 1, | |
| 20 | expiresAt: 2, | |
| 21 | }; | |
| 22 | ||
| 23 | it.effect("routes a request to the focused owner and correlates its response", () => | |
| 24 | Effect.scoped( | |
| 25 | Effect.gen(function* () { | |
| 26 | const broker = makePreviewAutomationBroker(); | |
| 27 | const requests = yield* broker.connect("client-1"); | |
| 28 | yield* Stream.runForEach(requests, (request) => | |
| 29 | broker.respond({ | |
| 30 | requestId: request.requestId, | |
| 31 | ok: true, | |
| 32 | result: { available: true }, | |
| 33 | }), | |
| 34 | ).pipe(Effect.forkScoped); | |
| 35 | yield* Effect.yieldNow; | |
| 36 | yield* broker.reportOwner({ | |
| 37 | clientId: "client-1", | |
| 38 | environmentId: scope.environmentId, | |
| 39 | threadId: scope.threadId, | |
| 40 | tabId: null, | |
| 41 | visible: false, | |
| 42 | supportsAutomation: true, | |
| 43 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 44 | }); | |
| 45 | ||
| 46 | const result = yield* broker.invoke<{ available: boolean }>({ | |
| 47 | scope, | |
| 48 | operation: "open", | |
| 49 | input: {}, | |
| 50 | }); | |
| 51 | ||
| 52 | expect(result).toEqual({ available: true }); | |
| 53 | }), | |
| 54 | ), | |
| 55 | ); | |
| 56 | ||
| 57 | it.effect("rejects calls when no focused owner exists", () => | |
| 58 | Effect.gen(function* () { | |
| 59 | const broker = makePreviewAutomationBroker(); | |
| 60 | const error = yield* broker | |
| 61 | .invoke<void>({ scope, operation: "status", input: {} }) | |
| 62 | .pipe(Effect.flip); | |
| 63 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("routes interactive commands to a hidden durable browser host", () => | |
| 68 | Effect.scoped( | |
| 69 | Effect.gen(function* () { | |
| 70 | const broker = makePreviewAutomationBroker(); | |
| 71 | const requests = yield* broker.connect("client-hidden"); | |
| 72 | yield* Stream.runForEach(requests, (request) => | |
| 73 | broker.respond({ requestId: request.requestId, ok: true }), | |
| 74 | ).pipe(Effect.forkScoped); | |
| 75 | yield* Effect.yieldNow; | |
| 76 | yield* broker.reportOwner({ | |
| 77 | clientId: "client-hidden", | |
| 78 | environmentId: scope.environmentId, | |
| 79 | threadId: scope.threadId, | |
| 80 | tabId: "tab-hidden", | |
| 81 | visible: false, | |
| 82 | supportsAutomation: true, | |
| 83 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 84 | }); | |
| 85 | ||
| 86 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); | |
| 87 | }), | |
| 88 | ), | |
| 89 | ); |
apps/server/src/mcp/Layers/PreviewAutomationBroker.ts deleted-270| ... | ... | @@ -1,270 +0,0 @@ |
| 1 | import { | |
| 2 | PreviewAutomationControlInterruptedError, | |
| 3 | PreviewAutomationExecutionError, | |
| 4 | PreviewAutomationInvalidSelectorError, | |
| 5 | PreviewAutomationNoFocusedOwnerError, | |
| 6 | PreviewAutomationResultTooLargeError, | |
| 7 | PreviewAutomationTabNotFoundError, | |
| 8 | PreviewAutomationTimeoutError, | |
| 9 | PreviewAutomationUnavailableError, | |
| 10 | PreviewAutomationUnsupportedClientError, | |
| 11 | type PreviewAutomationError, | |
| 12 | type PreviewAutomationOwner, | |
| 13 | type PreviewAutomationResponse, | |
| 14 | } from "@t3tools/contracts"; | |
| 15 | import * as Deferred from "effect/Deferred"; | |
| 16 | import * as Effect from "effect/Effect"; | |
| 17 | import * as Layer from "effect/Layer"; | |
| 18 | import * as Option from "effect/Option"; | |
| 19 | import * as Queue from "effect/Queue"; | |
| 20 | import * as Ref from "effect/Ref"; | |
| 21 | import * as Stream from "effect/Stream"; | |
| 22 | ||
| 23 | import { | |
| 24 | PreviewAutomationBroker, | |
| 25 | type PreviewAutomationBrokerShape, | |
| 26 | } from "../Services/PreviewAutomationBroker.ts"; | |
| 27 | ||
| 28 | interface ClientConnection { | |
| 29 | readonly clientId: string; | |
| 30 | readonly queue: Queue.Queue< | |
| 31 | Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never | |
| 32 | ? never | |
| 33 | : import("@t3tools/contracts").PreviewAutomationRequest | |
| 34 | >; | |
| 35 | } | |
| 36 | ||
| 37 | interface PendingRequest { | |
| 38 | readonly clientId: string; | |
| 39 | readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; | |
| 40 | } | |
| 41 | ||
| 42 | interface BrokerState { | |
| 43 | readonly clients: ReadonlyMap<string, ClientConnection>; | |
| 44 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; | |
| 45 | readonly pending: ReadonlyMap<string, PendingRequest>; | |
| 46 | } | |
| 47 | ||
| 48 | const makeResponseError = ( | |
| 49 | error: NonNullable<PreviewAutomationResponse["error"]>, | |
| 50 | ): PreviewAutomationError => { | |
| 51 | switch (error._tag) { | |
| 52 | case "PreviewAutomationNoFocusedOwnerError": | |
| 53 | return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); | |
| 54 | case "PreviewAutomationUnsupportedClientError": | |
| 55 | return new PreviewAutomationUnsupportedClientError({ message: error.message }); | |
| 56 | case "PreviewAutomationTabNotFoundError": | |
| 57 | return new PreviewAutomationTabNotFoundError({ message: error.message }); | |
| 58 | case "PreviewAutomationTimeoutError": | |
| 59 | return new PreviewAutomationTimeoutError({ message: error.message }); | |
| 60 | case "PreviewAutomationControlInterruptedError": | |
| 61 | return new PreviewAutomationControlInterruptedError({ message: error.message }); | |
| 62 | case "PreviewAutomationInvalidSelectorError": { | |
| 63 | const detail = | |
| 64 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | |
| 65 | return new PreviewAutomationInvalidSelectorError({ | |
| 66 | message: error.message, | |
| 67 | selector: | |
| 68 | detail && "selector" in detail && typeof detail.selector === "string" | |
| 69 | ? detail.selector | |
| 70 | : "", | |
| 71 | }); | |
| 72 | } | |
| 73 | case "PreviewAutomationResultTooLargeError": { | |
| 74 | const detail = | |
| 75 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | |
| 76 | return new PreviewAutomationResultTooLargeError({ | |
| 77 | message: error.message, | |
| 78 | maximumBytes: | |
| 79 | detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" | |
| 80 | ? detail.maximumBytes | |
| 81 | : 64_000, | |
| 82 | }); | |
| 83 | } | |
| 84 | case "PreviewAutomationUnavailableError": | |
| 85 | return new PreviewAutomationUnavailableError({ message: error.message }); | |
| 86 | default: | |
| 87 | return new PreviewAutomationExecutionError({ | |
| 88 | message: error.message, | |
| 89 | detail: error.detail, | |
| 90 | }); | |
| 91 | } | |
| 92 | }; | |
| 93 | ||
| 94 | export const makePreviewAutomationBroker = (): PreviewAutomationBrokerShape => { | |
| 95 | const state = Effect.runSync( | |
| 96 | Ref.make<BrokerState>({ | |
| 97 | clients: new Map(), | |
| 98 | owners: new Map(), | |
| 99 | pending: new Map(), | |
| 100 | }), | |
| 101 | ); | |
| 102 | let requestSequence = 0; | |
| 103 | ||
| 104 | const disconnect = (clientId: string, queue: ClientConnection["queue"]) => | |
| 105 | Effect.gen(function* () { | |
| 106 | const toFail: PendingRequest[] = []; | |
| 107 | yield* Ref.update(state, (current) => { | |
| 108 | if (current.clients.get(clientId)?.queue !== queue) return current; | |
| 109 | const clients = new Map(current.clients); | |
| 110 | const owners = new Map(current.owners); | |
| 111 | const pending = new Map(current.pending); | |
| 112 | clients.delete(clientId); | |
| 113 | owners.delete(clientId); | |
| 114 | for (const [requestId, entry] of pending) { | |
| 115 | if (entry.clientId === clientId) { | |
| 116 | pending.delete(requestId); | |
| 117 | toFail.push(entry); | |
| 118 | } | |
| 119 | } | |
| 120 | return { clients, owners, pending }; | |
| 121 | }); | |
| 122 | yield* Effect.forEach( | |
| 123 | toFail, | |
| 124 | ({ deferred }) => | |
| 125 | Deferred.fail( | |
| 126 | deferred, | |
| 127 | new PreviewAutomationUnavailableError({ | |
| 128 | message: "The preview automation client disconnected.", | |
| 129 | }), | |
| 130 | ), | |
| 131 | { discard: true }, | |
| 132 | ); | |
| 133 | yield* Queue.shutdown(queue); | |
| 134 | }); | |
| 135 | ||
| 136 | const connect: PreviewAutomationBrokerShape["connect"] = (clientId) => | |
| 137 | Effect.gen(function* () { | |
| 138 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); | |
| 139 | let previous: ClientConnection | undefined; | |
| 140 | yield* Ref.update(state, (current) => { | |
| 141 | previous = current.clients.get(clientId); | |
| 142 | const clients = new Map(current.clients); | |
| 143 | clients.set(clientId, { clientId, queue }); | |
| 144 | return { ...current, clients }; | |
| 145 | }); | |
| 146 | if (previous) yield* disconnect(clientId, previous.queue); | |
| 147 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); | |
| 148 | }); | |
| 149 | ||
| 150 | const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = (owner) => | |
| 151 | Ref.update(state, (current) => { | |
| 152 | const owners = new Map(current.owners); | |
| 153 | owners.set(owner.clientId, owner); | |
| 154 | return { ...current, owners }; | |
| 155 | }); | |
| 156 | ||
| 157 | const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = (clientId) => | |
| 158 | Ref.update(state, (current) => { | |
| 159 | const owners = new Map(current.owners); | |
| 160 | owners.delete(clientId); | |
| 161 | return { ...current, owners }; | |
| 162 | }); | |
| 163 | ||
| 164 | const respond: PreviewAutomationBrokerShape["respond"] = (response) => | |
| 165 | Effect.gen(function* () { | |
| 166 | let pending: PendingRequest | undefined; | |
| 167 | yield* Ref.update(state, (current) => { | |
| 168 | pending = current.pending.get(response.requestId); | |
| 169 | if (!pending) return current; | |
| 170 | const next = new Map(current.pending); | |
| 171 | next.delete(response.requestId); | |
| 172 | return { ...current, pending: next }; | |
| 173 | }); | |
| 174 | if (!pending) return; | |
| 175 | if (response.ok) { | |
| 176 | yield* Deferred.succeed(pending.deferred, response.result); | |
| 177 | } else { | |
| 178 | yield* Deferred.fail( | |
| 179 | pending.deferred, | |
| 180 | response.error | |
| 181 | ? makeResponseError(response.error) | |
| 182 | : new PreviewAutomationExecutionError({ | |
| 183 | message: "Preview automation failed without an error payload.", | |
| 184 | }), | |
| 185 | ); | |
| 186 | } | |
| 187 | }); | |
| 188 | ||
| 189 | const invoke = <A = unknown>( | |
| 190 | input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], | |
| 191 | ): Effect.Effect<A, PreviewAutomationError> => | |
| 192 | Effect.gen(function* () { | |
| 193 | const current = yield* Ref.get(state); | |
| 194 | const candidates = Array.from(current.owners.values()) | |
| 195 | .filter( | |
| 196 | (owner) => | |
| 197 | owner.environmentId === input.scope.environmentId && | |
| 198 | owner.threadId === input.scope.threadId && | |
| 199 | owner.supportsAutomation, | |
| 200 | ) | |
| 201 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); | |
| 202 | const owner = candidates[0]; | |
| 203 | if (!owner) { | |
| 204 | return yield* new PreviewAutomationNoFocusedOwnerError({ | |
| 205 | message: "No desktop browser host is available for this thread.", | |
| 206 | }); | |
| 207 | } | |
| 208 | const connection = current.clients.get(owner.clientId); | |
| 209 | if (!connection) { | |
| 210 | return yield* new PreviewAutomationUnavailableError({ | |
| 211 | message: "The browser host is not connected.", | |
| 212 | }); | |
| 213 | } | |
| 214 | if ( | |
| 215 | input.operation !== "open" && | |
| 216 | input.operation !== "status" && | |
| 217 | !owner.tabId && | |
| 218 | !input.tabId | |
| 219 | ) { | |
| 220 | return yield* new PreviewAutomationTabNotFoundError({ | |
| 221 | message: "The browser host does not have an active tab.", | |
| 222 | }); | |
| 223 | } | |
| 224 | const requestId = `preview-${requestSequence++}`; | |
| 225 | const timeoutMs = input.timeoutMs ?? 15_000; | |
| 226 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | |
| 227 | yield* Ref.update(state, (next) => { | |
| 228 | const pending = new Map(next.pending); | |
| 229 | pending.set(requestId, { clientId: owner.clientId, deferred }); | |
| 230 | return { ...next, pending }; | |
| 231 | }); | |
| 232 | const offered = yield* Queue.offer(connection.queue, { | |
| 233 | requestId, | |
| 234 | threadId: input.scope.threadId, | |
| 235 | tabId: input.tabId ?? owner.tabId ?? undefined, | |
| 236 | operation: input.operation, | |
| 237 | input: input.input, | |
| 238 | timeoutMs, | |
| 239 | }); | |
| 240 | if (!offered) { | |
| 241 | return yield* new PreviewAutomationUnavailableError({ | |
| 242 | message: "The preview automation client is no longer accepting requests.", | |
| 243 | }); | |
| 244 | } | |
| 245 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | |
| 246 | yield* Ref.update(state, (next) => { | |
| 247 | const pending = new Map(next.pending); | |
| 248 | pending.delete(requestId); | |
| 249 | return { ...next, pending }; | |
| 250 | }); | |
| 251 | return yield* Option.match(result, { | |
| 252 | onNone: () => | |
| 253 | Effect.fail( | |
| 254 | new PreviewAutomationTimeoutError({ | |
| 255 | message: `Preview automation timed out after ${timeoutMs}ms.`, | |
| 256 | }), | |
| 257 | ), | |
| 258 | onSome: (value) => Effect.succeed(value as A), | |
| 259 | }); | |
| 260 | }); | |
| 261 | ||
| 262 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); | |
| 263 | }; | |
| 264 | ||
| 265 | export const previewAutomationBroker = makePreviewAutomationBroker(); | |
| 266 | ||
| 267 | export const PreviewAutomationBrokerLive: Layer.Layer<PreviewAutomationBroker> = Layer.succeed( | |
| 268 | PreviewAutomationBroker, | |
| 269 | previewAutomationBroker, | |
| 270 | ); |
apps/server/src/mcp/McpHttpServer.test.ts created+165| ... | ... | @@ -0,0 +1,165 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Stream from "effect/Stream"; | |
| 6 | import { McpSchema, McpServer } from "effect/unstable/ai"; | |
| 7 | import { HttpServerResponse } from "effect/unstable/http"; | |
| 8 | ||
| 9 | import * as McpHttpServer from "./McpHttpServer.ts"; | |
| 10 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | |
| 11 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; | |
| 12 | ||
| 13 | const environmentId = EnvironmentId.make("environment-mcp-test"); | |
| 14 | const threadId = ThreadId.make("thread-mcp-test"); | |
| 15 | const tabId = PreviewTabId.make("tab-mcp-test"); | |
| 16 | const invocation = { | |
| 17 | environmentId, | |
| 18 | threadId, | |
| 19 | providerSessionId: "provider-session-mcp-test", | |
| 20 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 21 | capabilities: new Set(["preview"] as const), | |
| 22 | issuedAt: 1, | |
| 23 | expiresAt: Number.MAX_SAFE_INTEGER, | |
| 24 | }; | |
| 25 | const client = McpSchema.McpServerClient.of({ | |
| 26 | clientId: 1, | |
| 27 | initializePayload: { | |
| 28 | protocolVersion: "2025-03-26", | |
| 29 | capabilities: {}, | |
| 30 | clientInfo: { name: "mcp-test", version: "1.0.0" }, | |
| 31 | }, | |
| 32 | getClient: Effect.die("unused"), | |
| 33 | }); | |
| 34 | const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe( | |
| 35 | Layer.provideMerge(McpServer.McpServer.layer), | |
| 36 | Layer.provideMerge(PreviewAutomationBroker.layer), | |
| 37 | ); | |
| 38 | ||
| 39 | it("normalizes empty successful notification responses to accepted", () => { | |
| 40 | const notificationResponse = McpHttpServer.normalizeMcpHttpResponse( | |
| 41 | HttpServerResponse.text("", { status: 200, contentType: "application/json" }), | |
| 42 | ); | |
| 43 | expect(notificationResponse.status).toBe(202); | |
| 44 | ||
| 45 | const resultResponse = McpHttpServer.normalizeMcpHttpResponse( | |
| 46 | HttpServerResponse.jsonUnsafe({ jsonrpc: "2.0", id: 1, result: {} }), | |
| 47 | ); | |
| 48 | expect(resultResponse.status).toBe(200); | |
| 49 | }); | |
| 50 | ||
| 51 | it.effect("registers annotated tools and preserves authenticated request context", () => | |
| 52 | Effect.scoped( | |
| 53 | Effect.gen(function* () { | |
| 54 | const server = yield* McpServer.McpServer; | |
| 55 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | |
| 56 | const requests = yield* broker.connect("mcp-test-client"); | |
| 57 | yield* Stream.runForEach(requests, (request) => | |
| 58 | broker.respond({ | |
| 59 | requestId: request.requestId, | |
| 60 | ok: true, | |
| 61 | result: | |
| 62 | request.operation === "snapshot" | |
| 63 | ? { | |
| 64 | url: "http://example.test/", | |
| 65 | title: "Example", | |
| 66 | loading: false, | |
| 67 | visibleText: "Example", | |
| 68 | interactiveElements: [], | |
| 69 | accessibilityTree: {}, | |
| 70 | consoleEntries: [], | |
| 71 | networkEntries: [], | |
| 72 | actionTimeline: [], | |
| 73 | screenshot: { | |
| 74 | mimeType: "image/png", | |
| 75 | data: Buffer.from("png").toString("base64"), | |
| 76 | width: 10, | |
| 77 | height: 5, | |
| 78 | }, | |
| 79 | } | |
| 80 | : request.operation === "press" | |
| 81 | ? undefined | |
| 82 | : { | |
| 83 | available: true, | |
| 84 | visible: true, | |
| 85 | tabId, | |
| 86 | url: "http://example.test/", | |
| 87 | title: "Example", | |
| 88 | loading: false, | |
| 89 | }, | |
| 90 | }), | |
| 91 | ).pipe(Effect.forkScoped); | |
| 92 | yield* Effect.yieldNow; | |
| 93 | yield* broker.reportOwner({ | |
| 94 | clientId: "mcp-test-client", | |
| 95 | environmentId, | |
| 96 | threadId, | |
| 97 | tabId, | |
| 98 | visible: true, | |
| 99 | supportsAutomation: true, | |
| 100 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 101 | }); | |
| 102 | ||
| 103 | const statusTool = server.tools.find(({ tool }) => tool.name === "preview_status"); | |
| 104 | expect(statusTool?.tool.annotations?.readOnlyHint).toBe(true); | |
| 105 | expect(statusTool?.tool.annotations?.idempotentHint).toBe(true); | |
| 106 | expect(statusTool?.tool.annotations?.destructiveHint).toBe(false); | |
| 107 | ||
| 108 | const snapshotTool = server.tools.find(({ tool }) => tool.name === "preview_snapshot"); | |
| 109 | expect(snapshotTool?.tool.annotations?.readOnlyHint).toBe(true); | |
| 110 | expect(snapshotTool?.tool.annotations?.idempotentHint).toBe(true); | |
| 111 | expect(snapshotTool?.tool.annotations?.openWorldHint).toBe(true); | |
| 112 | ||
| 113 | const clickTool = server.tools.find(({ tool }) => tool.name === "preview_click"); | |
| 114 | expect(clickTool?.tool.annotations?.readOnlyHint).toBe(false); | |
| 115 | expect(clickTool?.tool.annotations?.destructiveHint).toBe(true); | |
| 116 | expect(clickTool?.tool.annotations?.openWorldHint).toBe(true); | |
| 117 | ||
| 118 | const navigateTool = server.tools.find(({ tool }) => tool.name === "preview_navigate"); | |
| 119 | expect(navigateTool?.tool.annotations?.destructiveHint).toBe(false); | |
| 120 | expect(navigateTool?.tool.annotations?.openWorldHint).toBe(true); | |
| 121 | ||
| 122 | const status = yield* server | |
| 123 | .callTool({ name: "preview_status", arguments: {} }) | |
| 124 | .pipe( | |
| 125 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 126 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 127 | ); | |
| 128 | expect(status.isError).toBe(false); | |
| 129 | expect(status.structuredContent).toMatchObject({ | |
| 130 | available: true, | |
| 131 | tabId, | |
| 132 | }); | |
| 133 | ||
| 134 | const malformed = yield* server | |
| 135 | .callTool({ name: "preview_click", arguments: { selector: "" } }) | |
| 136 | .pipe( | |
| 137 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 138 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 139 | ); | |
| 140 | expect(malformed.isError).toBe(true); | |
| 141 | ||
| 142 | const snapshot = yield* server | |
| 143 | .callTool({ name: "preview_snapshot", arguments: {} }) | |
| 144 | .pipe( | |
| 145 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 146 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 147 | ); | |
| 148 | expect(snapshot.isError).toBe(false); | |
| 149 | expect(snapshot.content.some((content) => content.type === "image")).toBe(true); | |
| 150 | expect(snapshot.structuredContent).toMatchObject({ | |
| 151 | screenshot: { mimeType: "image/png", width: 10, height: 5 }, | |
| 152 | }); | |
| 153 | ||
| 154 | const press = yield* server | |
| 155 | .callTool({ name: "preview_press", arguments: { key: "Enter" } }) | |
| 156 | .pipe( | |
| 157 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 158 | Effect.provideService(McpSchema.McpServerClient, client), | |
| 159 | ); | |
| 160 | expect(press.isError).toBe(false); | |
| 161 | expect(press.structuredContent).toBeUndefined(); | |
| 162 | expect(press.content).toEqual([{ type: "text", text: "null" }]); | |
| 163 | }), | |
| 164 | ).pipe(Effect.provide(TestLayer)), | |
| 165 | ); |
apps/server/src/mcp/McpHttpServer.ts created+166| ... | ... | @@ -0,0 +1,166 @@ |
| 1 | import * as Cause from "effect/Cause"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Effect from "effect/Effect"; | |
| 4 | import * as Layer from "effect/Layer"; | |
| 5 | import * as Option from "effect/Option"; | |
| 6 | import * as Sink from "effect/Sink"; | |
| 7 | import * as Stream from "effect/Stream"; | |
| 8 | import { McpSchema, McpServer, Tool } from "effect/unstable/ai"; | |
| 9 | import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; | |
| 10 | ||
| 11 | import packageJson from "../../package.json" with { type: "json" }; | |
| 12 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | |
| 13 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; | |
| 14 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; | |
| 15 | import { PreviewToolkitHandlersLive } from "./toolkits/preview/handlers.ts"; | |
| 16 | import { PreviewToolkit } from "./toolkits/preview/tools.ts"; | |
| 17 | ||
| 18 | const unauthorized = HttpServerResponse.jsonUnsafe( | |
| 19 | { | |
| 20 | error: "invalid_mcp_credential", | |
| 21 | message: "A valid provider-scoped MCP bearer credential is required.", | |
| 22 | }, | |
| 23 | { | |
| 24 | status: 401, | |
| 25 | headers: { | |
| 26 | "cache-control": "no-store", | |
| 27 | "www-authenticate": "Bearer", | |
| 28 | }, | |
| 29 | }, | |
| 30 | ); | |
| 31 | ||
| 32 | export const normalizeMcpHttpResponse = ( | |
| 33 | response: HttpServerResponse.HttpServerResponse, | |
| 34 | ): HttpServerResponse.HttpServerResponse => { | |
| 35 | const bodyIsEmpty = | |
| 36 | response.body._tag === "Empty" || | |
| 37 | (response.body._tag === "Uint8Array" && response.body.contentLength === 0) || | |
| 38 | (response.body._tag === "Raw" && response.body.contentLength === 0); | |
| 39 | return response.status === 200 && bodyIsEmpty | |
| 40 | ? HttpServerResponse.setStatus(response, 202) | |
| 41 | : response; | |
| 42 | }; | |
| 43 | ||
| 44 | const McpAuthMiddlewareLive = HttpRouter.middleware<{ | |
| 45 | provides: McpInvocationContext.McpInvocationContext; | |
| 46 | }>()( | |
| 47 | Effect.gen(function* () { | |
| 48 | const registry = yield* McpSessionRegistry.McpSessionRegistry; | |
| 49 | return Effect.fn("McpHttpServer.authenticateRequest")(function* (httpEffect) { | |
| 50 | const request = yield* HttpServerRequest.HttpServerRequest; | |
| 51 | const authorization = request.headers.authorization; | |
| 52 | const token = | |
| 53 | authorization?.startsWith("Bearer ") === true | |
| 54 | ? authorization.slice("Bearer ".length).trim() | |
| 55 | : ""; | |
| 56 | const invocation = yield* registry.resolve(token); | |
| 57 | if (!invocation) return unauthorized; | |
| 58 | return yield* httpEffect.pipe( | |
| 59 | Effect.provideService(McpInvocationContext.McpInvocationContext, invocation), | |
| 60 | Effect.map(normalizeMcpHttpResponse), | |
| 61 | ); | |
| 62 | }); | |
| 63 | }), | |
| 64 | ).layer; | |
| 65 | ||
| 66 | const McpTransportLive = McpServer.layerHttp({ | |
| 67 | name: "T3 Code", | |
| 68 | version: packageJson.version, | |
| 69 | path: "/mcp", | |
| 70 | }).pipe(Layer.provide(McpAuthMiddlewareLive)); | |
| 71 | ||
| 72 | export const PreviewToolkitRegistrationLive = Layer.effectDiscard( | |
| 73 | Effect.gen(function* () { | |
| 74 | const server = yield* McpServer.McpServer; | |
| 75 | const built = yield* PreviewToolkit; | |
| 76 | const handleTool = built.handle as unknown as ( | |
| 77 | name: keyof typeof built.tools, | |
| 78 | payload: unknown, | |
| 79 | ) => Effect.Effect< | |
| 80 | Stream.Stream<{ readonly encodedResult: unknown }, Error>, | |
| 81 | Error, | |
| 82 | McpInvocationContext.McpInvocationContext | |
| 83 | >; | |
| 84 | for (const tool of Object.values(built.tools)) { | |
| 85 | yield* server.addTool({ | |
| 86 | tool: new McpSchema.Tool({ | |
| 87 | name: tool.name, | |
| 88 | description: Tool.getDescription(tool), | |
| 89 | inputSchema: Tool.getJsonSchema(tool), | |
| 90 | annotations: { | |
| 91 | ...Context.getOption(tool.annotations, Tool.Title).pipe( | |
| 92 | Option.map((title) => ({ title })), | |
| 93 | Option.getOrUndefined, | |
| 94 | ), | |
| 95 | readOnlyHint: Context.get(tool.annotations, Tool.Readonly), | |
| 96 | destructiveHint: Context.get(tool.annotations, Tool.Destructive), | |
| 97 | idempotentHint: Context.get(tool.annotations, Tool.Idempotent), | |
| 98 | openWorldHint: Context.get(tool.annotations, Tool.OpenWorld), | |
| 99 | }, | |
| 100 | }), | |
| 101 | annotations: tool.annotations, | |
| 102 | handle: (payload) => | |
| 103 | handleTool(tool.name as keyof typeof built.tools, payload).pipe( | |
| 104 | Stream.unwrap, | |
| 105 | Stream.run(Sink.last()), | |
| 106 | Effect.flatMap(Effect.fromOption), | |
| 107 | Effect.matchCause({ | |
| 108 | onFailure: (cause) => | |
| 109 | new McpSchema.CallToolResult({ | |
| 110 | isError: true, | |
| 111 | content: [{ type: "text", text: Cause.pretty(cause) }], | |
| 112 | }), | |
| 113 | onSuccess: (result) => { | |
| 114 | if (tool.name === "preview_snapshot") { | |
| 115 | const snapshot = result.encodedResult as { | |
| 116 | readonly screenshot: { | |
| 117 | readonly mimeType: "image/png"; | |
| 118 | readonly data: string; | |
| 119 | readonly width: number; | |
| 120 | readonly height: number; | |
| 121 | }; | |
| 122 | readonly [key: string]: unknown; | |
| 123 | }; | |
| 124 | const { screenshot, ...page } = snapshot; | |
| 125 | const metadata = { | |
| 126 | ...page, | |
| 127 | screenshot: { | |
| 128 | mimeType: screenshot.mimeType, | |
| 129 | width: screenshot.width, | |
| 130 | height: screenshot.height, | |
| 131 | }, | |
| 132 | }; | |
| 133 | return new McpSchema.CallToolResult({ | |
| 134 | isError: false, | |
| 135 | structuredContent: metadata, | |
| 136 | content: [ | |
| 137 | { type: "text", text: JSON.stringify(metadata) }, | |
| 138 | { | |
| 139 | type: "image", | |
| 140 | data: new Uint8Array(Buffer.from(screenshot.data, "base64")), | |
| 141 | mimeType: screenshot.mimeType, | |
| 142 | }, | |
| 143 | ], | |
| 144 | }); | |
| 145 | } | |
| 146 | const encodedResultText = JSON.stringify(result.encodedResult) ?? "null"; | |
| 147 | return new McpSchema.CallToolResult({ | |
| 148 | isError: false, | |
| 149 | structuredContent: | |
| 150 | result.encodedResult !== null && typeof result.encodedResult === "object" | |
| 151 | ? result.encodedResult | |
| 152 | : undefined, | |
| 153 | content: [{ type: "text", text: encodedResultText }], | |
| 154 | }); | |
| 155 | }, | |
| 156 | }), | |
| 157 | ) as unknown as Effect.Effect<McpSchema.CallToolResult, never, McpSchema.McpServerClient>, | |
| 158 | }); | |
| 159 | } | |
| 160 | }), | |
| 161 | ).pipe(Layer.provide(PreviewToolkitHandlersLive)); | |
| 162 | ||
| 163 | export const layer = Layer.mergeAll(PreviewToolkitRegistrationLive).pipe( | |
| 164 | Layer.provideMerge(McpTransportLive), | |
| 165 | Layer.provide(PreviewAutomationBroker.layer), | |
| 166 | ); |
apps/server/src/mcp/McpInvocationContext.ts created+33| ... | ... | @@ -0,0 +1,33 @@ |
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | ||
| 6 | export type McpCapability = "preview"; | |
| 7 | ||
| 8 | export interface McpInvocationScope { | |
| 9 | readonly environmentId: EnvironmentId; | |
| 10 | readonly threadId: ThreadId; | |
| 11 | readonly providerSessionId: string; | |
| 12 | readonly providerInstanceId: ProviderInstanceId; | |
| 13 | readonly capabilities: ReadonlySet<McpCapability>; | |
| 14 | readonly issuedAt: number; | |
| 15 | readonly expiresAt: number; | |
| 16 | } | |
| 17 | ||
| 18 | export class McpInvocationContext extends Context.Service< | |
| 19 | McpInvocationContext, | |
| 20 | McpInvocationScope | |
| 21 | >()("t3/mcp/McpInvocationContext") {} | |
| 22 | ||
| 23 | export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( | |
| 24 | capability: McpCapability, | |
| 25 | ) { | |
| 26 | const invocation = yield* McpInvocationContext; | |
| 27 | if (!invocation.capabilities.has(capability)) { | |
| 28 | return yield* new PreviewAutomationUnavailableError({ | |
| 29 | message: `MCP credential does not grant the ${capability} capability.`, | |
| 30 | }); | |
| 31 | } | |
| 32 | return invocation; | |
| 33 | }); |
apps/server/src/mcp/McpProviderSession.ts created+28| ... | ... | @@ -0,0 +1,28 @@ |
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | ||
| 3 | export interface McpProviderSessionConfig { | |
| 4 | readonly environmentId: EnvironmentId; | |
| 5 | readonly threadId: ThreadId; | |
| 6 | readonly providerSessionId: string; | |
| 7 | readonly providerInstanceId: ProviderInstanceId; | |
| 8 | readonly endpoint: string; | |
| 9 | readonly authorizationHeader: string; | |
| 10 | } | |
| 11 | ||
| 12 | const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>(); | |
| 13 | ||
| 14 | export function setMcpProviderSession(config: McpProviderSessionConfig): void { | |
| 15 | sessionsByThread.set(config.threadId, config); | |
| 16 | } | |
| 17 | ||
| 18 | export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined { | |
| 19 | return sessionsByThread.get(threadId); | |
| 20 | } | |
| 21 | ||
| 22 | export function clearMcpProviderSession(threadId: ThreadId): void { | |
| 23 | sessionsByThread.delete(threadId); | |
| 24 | } | |
| 25 | ||
| 26 | export function clearAllMcpProviderSessions(): void { | |
| 27 | sessionsByThread.clear(); | |
| 28 | } |
apps/server/src/mcp/McpSessionRegistry.test.ts created+66| ... | ... | @@ -0,0 +1,66 @@ |
| 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; | |
| 2 | import { expect, it } from "@effect/vitest"; | |
| 3 | import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import { HttpServer } from "effect/unstable/http"; | |
| 6 | ||
| 7 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | |
| 8 | import * as McpSessionRegistry from "./McpSessionRegistry.ts"; | |
| 9 | ||
| 10 | const environmentId = EnvironmentId.make("environment-1"); | |
| 11 | const fakeHttpServer = HttpServer.HttpServer.of({ | |
| 12 | address: { _tag: "TcpAddress", hostname: "127.0.0.1", port: 43123 }, | |
| 13 | serve: (() => Effect.void) as HttpServer.HttpServer["Service"]["serve"], | |
| 14 | }); | |
| 15 | const fakeEnvironment = ServerEnvironment.of({ | |
| 16 | getEnvironmentId: Effect.succeed(environmentId), | |
| 17 | getDescriptor: Effect.die("unused"), | |
| 18 | }); | |
| 19 | ||
| 20 | const makeRegistry = (now: () => number) => | |
| 21 | McpSessionRegistry.makeForTest({ | |
| 22 | now, | |
| 23 | idleTimeoutMs: 100, | |
| 24 | maximumLifetimeMs: 1_000, | |
| 25 | }).pipe( | |
| 26 | Effect.provideService(HttpServer.HttpServer, fakeHttpServer), | |
| 27 | Effect.provideService(ServerEnvironment, fakeEnvironment), | |
| 28 | Effect.provide(NodeServices.layer), | |
| 29 | ); | |
| 30 | ||
| 31 | it.effect("stores only a token hash, resolves the bearer token, and revokes by thread", () => | |
| 32 | Effect.gen(function* () { | |
| 33 | let timestamp = 1_000; | |
| 34 | const registry = yield* makeRegistry(() => timestamp); | |
| 35 | const threadId = ThreadId.make("thread-1"); | |
| 36 | const issued = yield* registry.issue({ | |
| 37 | threadId, | |
| 38 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 39 | }); | |
| 40 | expect(issued.config.endpoint).toBe("http://127.0.0.1:43123/mcp"); | |
| 41 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 42 | expect(token.length).toBeGreaterThan(20); | |
| 43 | ||
| 44 | const resolved = yield* registry.resolve(token); | |
| 45 | expect(resolved?.threadId).toBe(threadId); | |
| 46 | ||
| 47 | yield* registry.revokeThread(threadId); | |
| 48 | expect(yield* registry.resolve(token)).toBeUndefined(); | |
| 49 | ||
| 50 | timestamp += 2_000; | |
| 51 | }), | |
| 52 | ); | |
| 53 | ||
| 54 | it.effect("expires credentials after inactivity", () => | |
| 55 | Effect.gen(function* () { | |
| 56 | let timestamp = 1_000; | |
| 57 | const registry = yield* makeRegistry(() => timestamp); | |
| 58 | const issued = yield* registry.issue({ | |
| 59 | threadId: ThreadId.make("thread-2"), | |
| 60 | providerInstanceId: ProviderInstanceId.make("claude"), | |
| 61 | }); | |
| 62 | const token = issued.config.authorizationHeader.replace(/^Bearer\s+/, ""); | |
| 63 | timestamp += 101; | |
| 64 | expect(yield* registry.resolve(token)).toBeUndefined(); | |
| 65 | }), | |
| 66 | ); |
apps/server/src/mcp/McpSessionRegistry.ts created+202| ... | ... | @@ -0,0 +1,202 @@ |
| 1 | import { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import * as Crypto from "effect/Crypto"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | import * as Layer from "effect/Layer"; | |
| 6 | import * as Ref from "effect/Ref"; | |
| 7 | import { HttpServer } from "effect/unstable/http"; | |
| 8 | ||
| 9 | import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts"; | |
| 10 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | |
| 11 | import * as McpProviderSession from "./McpProviderSession.ts"; | |
| 12 | ||
| 13 | export interface McpCredentialRequest { | |
| 14 | readonly threadId: ThreadId; | |
| 15 | readonly providerInstanceId: ProviderInstanceId; | |
| 16 | } | |
| 17 | ||
| 18 | export interface McpIssuedCredential { | |
| 19 | readonly config: McpProviderSession.McpProviderSessionConfig; | |
| 20 | readonly expiresAt: number; | |
| 21 | } | |
| 22 | ||
| 23 | export interface McpSessionRegistryShape { | |
| 24 | readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential>; | |
| 25 | readonly resolve: ( | |
| 26 | rawToken: string, | |
| 27 | ) => Effect.Effect<McpInvocationContext.McpInvocationScope | undefined>; | |
| 28 | readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; | |
| 29 | readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; | |
| 30 | readonly revokeAll: Effect.Effect<void>; | |
| 31 | } | |
| 32 | ||
| 33 | export class McpSessionRegistry extends Context.Service< | |
| 34 | McpSessionRegistry, | |
| 35 | McpSessionRegistryShape | |
| 36 | >()("t3/mcp/McpSessionRegistry") {} | |
| 37 | ||
| 38 | interface CredentialRecord { | |
| 39 | readonly tokenHash: string; | |
| 40 | readonly scope: McpInvocationContext.McpInvocationScope; | |
| 41 | readonly lastUsedAt: number; | |
| 42 | } | |
| 43 | ||
| 44 | interface RegistryState { | |
| 45 | readonly records: ReadonlyMap<string, CredentialRecord>; | |
| 46 | } | |
| 47 | ||
| 48 | export interface McpSessionRegistryOptions { | |
| 49 | readonly idleTimeoutMs?: number; | |
| 50 | readonly maximumLifetimeMs?: number; | |
| 51 | readonly now?: () => number; | |
| 52 | } | |
| 53 | ||
| 54 | const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000; | |
| 55 | const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000; | |
| 56 | ||
| 57 | const bytesToHex = (bytes: Uint8Array): string => | |
| 58 | Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join(""); | |
| 59 | ||
| 60 | const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url"); | |
| 61 | ||
| 62 | const make = Effect.fn("McpSessionRegistry.make")(function* ( | |
| 63 | options: McpSessionRegistryOptions = {}, | |
| 64 | ) { | |
| 65 | const crypto = yield* Crypto.Crypto; | |
| 66 | const environment = yield* ServerEnvironment; | |
| 67 | const environmentId = yield* environment.getEnvironmentId; | |
| 68 | const httpServer = yield* HttpServer.HttpServer; | |
| 69 | const state = yield* Ref.make<RegistryState>({ records: new Map() }); | |
| 70 | const now = options.now ?? Date.now; | |
| 71 | const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_IDLE_TIMEOUT_MS; | |
| 72 | const maximumLifetimeMs = options.maximumLifetimeMs ?? DEFAULT_MAXIMUM_LIFETIME_MS; | |
| 73 | const endpoint = | |
| 74 | httpServer.address._tag === "TcpAddress" | |
| 75 | ? `http://127.0.0.1:${httpServer.address.port}/mcp` | |
| 76 | : "http://127.0.0.1/mcp"; | |
| 77 | ||
| 78 | const hashToken = (token: string) => | |
| 79 | crypto | |
| 80 | .digest("SHA-256", new TextEncoder().encode(token)) | |
| 81 | .pipe(Effect.map(bytesToHex), Effect.orDie); | |
| 82 | ||
| 83 | const pruneExpired = (records: ReadonlyMap<string, CredentialRecord>, timestamp: number) => { | |
| 84 | let changed = false; | |
| 85 | const next = new Map<string, CredentialRecord>(); | |
| 86 | for (const [hash, record] of records) { | |
| 87 | if (timestamp <= record.scope.expiresAt && timestamp - record.lastUsedAt <= idleTimeoutMs) { | |
| 88 | next.set(hash, record); | |
| 89 | } else { | |
| 90 | changed = true; | |
| 91 | } | |
| 92 | } | |
| 93 | return changed ? next : records; | |
| 94 | }; | |
| 95 | ||
| 96 | const issue: McpSessionRegistryShape["issue"] = Effect.fn("McpSessionRegistry.issue")( | |
| 97 | function* (request) { | |
| 98 | const issuedAt = now(); | |
| 99 | const providerSessionId = yield* crypto.randomUUIDv4.pipe(Effect.orDie); | |
| 100 | const rawToken = yield* crypto.randomBytes(32).pipe(Effect.map(tokenFromBytes), Effect.orDie); | |
| 101 | const tokenHash = yield* hashToken(rawToken); | |
| 102 | const expiresAt = issuedAt + maximumLifetimeMs; | |
| 103 | const scope: McpInvocationContext.McpInvocationScope = { | |
| 104 | environmentId, | |
| 105 | threadId: ThreadId.make(request.threadId), | |
| 106 | providerSessionId, | |
| 107 | providerInstanceId: ProviderInstanceId.make(request.providerInstanceId), | |
| 108 | capabilities: new Set(["preview"]), | |
| 109 | issuedAt, | |
| 110 | expiresAt, | |
| 111 | }; | |
| 112 | yield* Ref.update(state, ({ records }) => { | |
| 113 | const next = new Map(pruneExpired(records, issuedAt)); | |
| 114 | next.set(tokenHash, { tokenHash, scope, lastUsedAt: issuedAt }); | |
| 115 | return { records: next }; | |
| 116 | }); | |
| 117 | return { | |
| 118 | config: { | |
| 119 | environmentId, | |
| 120 | threadId: scope.threadId, | |
| 121 | providerSessionId, | |
| 122 | providerInstanceId: scope.providerInstanceId, | |
| 123 | endpoint, | |
| 124 | authorizationHeader: `Bearer ${rawToken}`, | |
| 125 | }, | |
| 126 | expiresAt, | |
| 127 | }; | |
| 128 | }, | |
| 129 | ); | |
| 130 | ||
| 131 | const resolve: McpSessionRegistryShape["resolve"] = Effect.fn("McpSessionRegistry.resolve")( | |
| 132 | function* (rawToken) { | |
| 133 | if (rawToken.length === 0) return undefined; | |
| 134 | const tokenHash = yield* hashToken(rawToken); | |
| 135 | const timestamp = now(); | |
| 136 | let resolved: McpInvocationContext.McpInvocationScope | undefined; | |
| 137 | yield* Ref.update(state, ({ records }) => { | |
| 138 | const current = pruneExpired(records, timestamp); | |
| 139 | const record = current.get(tokenHash); | |
| 140 | if (!record) return { records: current }; | |
| 141 | resolved = record.scope; | |
| 142 | const next = new Map(current); | |
| 143 | next.set(tokenHash, { ...record, lastUsedAt: timestamp }); | |
| 144 | return { records: next }; | |
| 145 | }); | |
| 146 | return resolved; | |
| 147 | }, | |
| 148 | ); | |
| 149 | ||
| 150 | const revokeWhere = (predicate: (record: CredentialRecord) => boolean) => | |
| 151 | Ref.update(state, ({ records }) => ({ | |
| 152 | records: new Map(Array.from(records).filter(([, record]) => !predicate(record))), | |
| 153 | })); | |
| 154 | ||
| 155 | return McpSessionRegistry.of({ | |
| 156 | issue, | |
| 157 | resolve, | |
| 158 | revokeProviderSession: Effect.fn("McpSessionRegistry.revokeProviderSession")( | |
| 159 | function* (providerSessionId) { | |
| 160 | yield* revokeWhere((record) => record.scope.providerSessionId === providerSessionId); | |
| 161 | }, | |
| 162 | ), | |
| 163 | revokeThread: Effect.fn("McpSessionRegistry.revokeThread")(function* (threadId) { | |
| 164 | yield* revokeWhere((record) => record.scope.threadId === threadId); | |
| 165 | }), | |
| 166 | revokeAll: Ref.set(state, { records: new Map() }), | |
| 167 | }); | |
| 168 | }); | |
| 169 | ||
| 170 | let activeMcpSessionRegistry: McpSessionRegistryShape | undefined; | |
| 171 | ||
| 172 | export const layer: Layer.Layer< | |
| 173 | McpSessionRegistry, | |
| 174 | never, | |
| 175 | Crypto.Crypto | ServerEnvironment | HttpServer.HttpServer | |
| 176 | > = Layer.effect( | |
| 177 | McpSessionRegistry, | |
| 178 | make().pipe( | |
| 179 | Effect.tap((registry) => | |
| 180 | Effect.sync(() => { | |
| 181 | activeMcpSessionRegistry = registry; | |
| 182 | }), | |
| 183 | ), | |
| 184 | ), | |
| 185 | ); | |
| 186 | ||
| 187 | export const makeForTest = make; | |
| 188 | ||
| 189 | export const issueActiveMcpCredential = ( | |
| 190 | request: McpCredentialRequest, | |
| 191 | ): Effect.Effect<McpIssuedCredential | undefined> => | |
| 192 | activeMcpSessionRegistry | |
| 193 | ? activeMcpSessionRegistry | |
| 194 | .revokeThread(request.threadId) | |
| 195 | .pipe(Effect.andThen(activeMcpSessionRegistry.issue(request))) | |
| 196 | : Effect.sync((): McpIssuedCredential | undefined => undefined); | |
| 197 | ||
| 198 | export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> => | |
| 199 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void; | |
| 200 | ||
| 201 | export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> => | |
| 202 | activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void; |
apps/server/src/mcp/PreviewAutomationBroker.test.ts created+89| ... | ... | @@ -0,0 +1,89 @@ |
| 1 | import { expect, it } from "@effect/vitest"; | |
| 2 | import { | |
| 3 | EnvironmentId, | |
| 4 | PreviewAutomationNoFocusedOwnerError, | |
| 5 | ProviderInstanceId, | |
| 6 | ThreadId, | |
| 7 | } from "@t3tools/contracts"; | |
| 8 | import * as Effect from "effect/Effect"; | |
| 9 | import * as Stream from "effect/Stream"; | |
| 10 | ||
| 11 | import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts"; | |
| 12 | ||
| 13 | const scope = { | |
| 14 | environmentId: EnvironmentId.make("environment-1"), | |
| 15 | threadId: ThreadId.make("thread-1"), | |
| 16 | providerSessionId: "provider-session-1", | |
| 17 | providerInstanceId: ProviderInstanceId.make("codex"), | |
| 18 | capabilities: new Set(["preview"] as const), | |
| 19 | issuedAt: 1, | |
| 20 | expiresAt: 2, | |
| 21 | }; | |
| 22 | ||
| 23 | it.effect("routes a request to the focused owner and correlates its response", () => | |
| 24 | Effect.scoped( | |
| 25 | Effect.gen(function* () { | |
| 26 | const broker = yield* PreviewAutomationBroker.makeForTest; | |
| 27 | const requests = yield* broker.connect("client-1"); | |
| 28 | yield* Stream.runForEach(requests, (request) => | |
| 29 | broker.respond({ | |
| 30 | requestId: request.requestId, | |
| 31 | ok: true, | |
| 32 | result: { available: true }, | |
| 33 | }), | |
| 34 | ).pipe(Effect.forkScoped); | |
| 35 | yield* Effect.yieldNow; | |
| 36 | yield* broker.reportOwner({ | |
| 37 | clientId: "client-1", | |
| 38 | environmentId: scope.environmentId, | |
| 39 | threadId: scope.threadId, | |
| 40 | tabId: null, | |
| 41 | visible: false, | |
| 42 | supportsAutomation: true, | |
| 43 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 44 | }); | |
| 45 | ||
| 46 | const result = yield* broker.invoke<{ available: boolean }>({ | |
| 47 | scope, | |
| 48 | operation: "open", | |
| 49 | input: {}, | |
| 50 | }); | |
| 51 | ||
| 52 | expect(result).toEqual({ available: true }); | |
| 53 | }), | |
| 54 | ), | |
| 55 | ); | |
| 56 | ||
| 57 | it.effect("rejects calls when no focused owner exists", () => | |
| 58 | Effect.gen(function* () { | |
| 59 | const broker = yield* PreviewAutomationBroker.makeForTest; | |
| 60 | const error = yield* broker | |
| 61 | .invoke<void>({ scope, operation: "status", input: {} }) | |
| 62 | .pipe(Effect.flip); | |
| 63 | expect(error).toBeInstanceOf(PreviewAutomationNoFocusedOwnerError); | |
| 64 | }), | |
| 65 | ); | |
| 66 | ||
| 67 | it.effect("routes interactive commands to a hidden durable browser host", () => | |
| 68 | Effect.scoped( | |
| 69 | Effect.gen(function* () { | |
| 70 | const broker = yield* PreviewAutomationBroker.makeForTest; | |
| 71 | const requests = yield* broker.connect("client-hidden"); | |
| 72 | yield* Stream.runForEach(requests, (request) => | |
| 73 | broker.respond({ requestId: request.requestId, ok: true }), | |
| 74 | ).pipe(Effect.forkScoped); | |
| 75 | yield* Effect.yieldNow; | |
| 76 | yield* broker.reportOwner({ | |
| 77 | clientId: "client-hidden", | |
| 78 | environmentId: scope.environmentId, | |
| 79 | threadId: scope.threadId, | |
| 80 | tabId: "tab-hidden", | |
| 81 | visible: false, | |
| 82 | supportsAutomation: true, | |
| 83 | focusedAt: "2026-06-11T00:00:00.000Z", | |
| 84 | }); | |
| 85 | ||
| 86 | yield* broker.invoke<void>({ scope, operation: "click", input: { x: 10, y: 10 } }); | |
| 87 | }), | |
| 88 | ), | |
| 89 | ); |
apps/server/src/mcp/PreviewAutomationBroker.ts created+306| ... | ... | @@ -0,0 +1,306 @@ |
| 1 | import { | |
| 2 | PreviewAutomationControlInterruptedError, | |
| 3 | PreviewAutomationExecutionError, | |
| 4 | PreviewAutomationInvalidSelectorError, | |
| 5 | PreviewAutomationNoFocusedOwnerError, | |
| 6 | PreviewAutomationResultTooLargeError, | |
| 7 | PreviewAutomationTabNotFoundError, | |
| 8 | PreviewAutomationTimeoutError, | |
| 9 | PreviewAutomationUnavailableError, | |
| 10 | PreviewAutomationUnsupportedClientError, | |
| 11 | type PreviewAutomationError, | |
| 12 | type PreviewAutomationOperation, | |
| 13 | type PreviewAutomationOwner, | |
| 14 | type PreviewAutomationRequest, | |
| 15 | type PreviewAutomationResponse, | |
| 16 | type PreviewTabId, | |
| 17 | } from "@t3tools/contracts"; | |
| 18 | import * as Context from "effect/Context"; | |
| 19 | import * as Deferred from "effect/Deferred"; | |
| 20 | import * as Effect from "effect/Effect"; | |
| 21 | import * as Layer from "effect/Layer"; | |
| 22 | import * as Option from "effect/Option"; | |
| 23 | import * as Queue from "effect/Queue"; | |
| 24 | import * as Ref from "effect/Ref"; | |
| 25 | import * as Stream from "effect/Stream"; | |
| 26 | ||
| 27 | import * as McpInvocationContext from "./McpInvocationContext.ts"; | |
| 28 | ||
| 29 | export interface PreviewAutomationInvokeInput { | |
| 30 | readonly scope: McpInvocationContext.McpInvocationScope; | |
| 31 | readonly operation: PreviewAutomationOperation; | |
| 32 | readonly input: unknown; | |
| 33 | readonly tabId?: PreviewTabId; | |
| 34 | readonly timeoutMs?: number; | |
| 35 | } | |
| 36 | ||
| 37 | export interface PreviewAutomationBrokerShape { | |
| 38 | readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; | |
| 39 | readonly reportOwner: ( | |
| 40 | owner: PreviewAutomationOwner, | |
| 41 | ) => Effect.Effect<void, PreviewAutomationError>; | |
| 42 | readonly clearOwner: (clientId: string) => Effect.Effect<void>; | |
| 43 | readonly respond: ( | |
| 44 | response: PreviewAutomationResponse, | |
| 45 | ) => Effect.Effect<void, PreviewAutomationError>; | |
| 46 | readonly invoke: <A = unknown>( | |
| 47 | request: PreviewAutomationInvokeInput, | |
| 48 | ) => Effect.Effect<A, PreviewAutomationError>; | |
| 49 | } | |
| 50 | ||
| 51 | export class PreviewAutomationBroker extends Context.Service< | |
| 52 | PreviewAutomationBroker, | |
| 53 | PreviewAutomationBrokerShape | |
| 54 | >()("t3/mcp/PreviewAutomationBroker") {} | |
| 55 | ||
| 56 | interface ClientConnection { | |
| 57 | readonly clientId: string; | |
| 58 | readonly queue: Queue.Queue< | |
| 59 | Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never | |
| 60 | ? never | |
| 61 | : import("@t3tools/contracts").PreviewAutomationRequest | |
| 62 | >; | |
| 63 | } | |
| 64 | ||
| 65 | interface PendingRequest { | |
| 66 | readonly clientId: string; | |
| 67 | readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>; | |
| 68 | } | |
| 69 | ||
| 70 | interface BrokerState { | |
| 71 | readonly clients: ReadonlyMap<string, ClientConnection>; | |
| 72 | readonly owners: ReadonlyMap<string, PreviewAutomationOwner>; | |
| 73 | readonly pending: ReadonlyMap<string, PendingRequest>; | |
| 74 | } | |
| 75 | ||
| 76 | const makeResponseError = ( | |
| 77 | error: NonNullable<PreviewAutomationResponse["error"]>, | |
| 78 | ): PreviewAutomationError => { | |
| 79 | switch (error._tag) { | |
| 80 | case "PreviewAutomationNoFocusedOwnerError": | |
| 81 | return new PreviewAutomationNoFocusedOwnerError({ message: error.message }); | |
| 82 | case "PreviewAutomationUnsupportedClientError": | |
| 83 | return new PreviewAutomationUnsupportedClientError({ message: error.message }); | |
| 84 | case "PreviewAutomationTabNotFoundError": | |
| 85 | return new PreviewAutomationTabNotFoundError({ message: error.message }); | |
| 86 | case "PreviewAutomationTimeoutError": | |
| 87 | return new PreviewAutomationTimeoutError({ message: error.message }); | |
| 88 | case "PreviewAutomationControlInterruptedError": | |
| 89 | return new PreviewAutomationControlInterruptedError({ message: error.message }); | |
| 90 | case "PreviewAutomationInvalidSelectorError": { | |
| 91 | const detail = | |
| 92 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | |
| 93 | return new PreviewAutomationInvalidSelectorError({ | |
| 94 | message: error.message, | |
| 95 | selector: | |
| 96 | detail && "selector" in detail && typeof detail.selector === "string" | |
| 97 | ? detail.selector | |
| 98 | : "", | |
| 99 | }); | |
| 100 | } | |
| 101 | case "PreviewAutomationResultTooLargeError": { | |
| 102 | const detail = | |
| 103 | typeof error.detail === "object" && error.detail !== null ? error.detail : undefined; | |
| 104 | return new PreviewAutomationResultTooLargeError({ | |
| 105 | message: error.message, | |
| 106 | maximumBytes: | |
| 107 | detail && "maximumBytes" in detail && typeof detail.maximumBytes === "number" | |
| 108 | ? detail.maximumBytes | |
| 109 | : 64_000, | |
| 110 | }); | |
| 111 | } | |
| 112 | case "PreviewAutomationUnavailableError": | |
| 113 | return new PreviewAutomationUnavailableError({ message: error.message }); | |
| 114 | default: | |
| 115 | return new PreviewAutomationExecutionError({ | |
| 116 | message: error.message, | |
| 117 | detail: error.detail, | |
| 118 | }); | |
| 119 | } | |
| 120 | }; | |
| 121 | ||
| 122 | const makeService = (): PreviewAutomationBrokerShape => { | |
| 123 | const state = Effect.runSync( | |
| 124 | Ref.make<BrokerState>({ | |
| 125 | clients: new Map(), | |
| 126 | owners: new Map(), | |
| 127 | pending: new Map(), | |
| 128 | }), | |
| 129 | ); | |
| 130 | let requestSequence = 0; | |
| 131 | ||
| 132 | const disconnect = Effect.fn("PreviewAutomationBroker.disconnect")(function* ( | |
| 133 | clientId: string, | |
| 134 | queue: ClientConnection["queue"], | |
| 135 | ) { | |
| 136 | const toFail: PendingRequest[] = []; | |
| 137 | yield* Ref.update(state, (current) => { | |
| 138 | if (current.clients.get(clientId)?.queue !== queue) return current; | |
| 139 | const clients = new Map(current.clients); | |
| 140 | const owners = new Map(current.owners); | |
| 141 | const pending = new Map(current.pending); | |
| 142 | clients.delete(clientId); | |
| 143 | owners.delete(clientId); | |
| 144 | for (const [requestId, entry] of pending) { | |
| 145 | if (entry.clientId === clientId) { | |
| 146 | pending.delete(requestId); | |
| 147 | toFail.push(entry); | |
| 148 | } | |
| 149 | } | |
| 150 | return { clients, owners, pending }; | |
| 151 | }); | |
| 152 | yield* Effect.forEach( | |
| 153 | toFail, | |
| 154 | ({ deferred }) => | |
| 155 | Deferred.fail( | |
| 156 | deferred, | |
| 157 | new PreviewAutomationUnavailableError({ | |
| 158 | message: "The preview automation client disconnected.", | |
| 159 | }), | |
| 160 | ), | |
| 161 | { discard: true }, | |
| 162 | ); | |
| 163 | yield* Queue.shutdown(queue); | |
| 164 | }); | |
| 165 | ||
| 166 | const connect: PreviewAutomationBrokerShape["connect"] = Effect.fn( | |
| 167 | "PreviewAutomationBroker.connect", | |
| 168 | )(function* (clientId) { | |
| 169 | const queue = yield* Queue.unbounded<import("@t3tools/contracts").PreviewAutomationRequest>(); | |
| 170 | let previous: ClientConnection | undefined; | |
| 171 | yield* Ref.update(state, (current) => { | |
| 172 | previous = current.clients.get(clientId); | |
| 173 | const clients = new Map(current.clients); | |
| 174 | clients.set(clientId, { clientId, queue }); | |
| 175 | return { ...current, clients }; | |
| 176 | }); | |
| 177 | if (previous) yield* disconnect(clientId, previous.queue); | |
| 178 | return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue))); | |
| 179 | }); | |
| 180 | ||
| 181 | const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn( | |
| 182 | "PreviewAutomationBroker.reportOwner", | |
| 183 | )(function* (owner) { | |
| 184 | yield* Ref.update(state, (current) => { | |
| 185 | const owners = new Map(current.owners); | |
| 186 | owners.set(owner.clientId, owner); | |
| 187 | return { ...current, owners }; | |
| 188 | }); | |
| 189 | }); | |
| 190 | ||
| 191 | const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn( | |
| 192 | "PreviewAutomationBroker.clearOwner", | |
| 193 | )(function* (clientId) { | |
| 194 | yield* Ref.update(state, (current) => { | |
| 195 | const owners = new Map(current.owners); | |
| 196 | owners.delete(clientId); | |
| 197 | return { ...current, owners }; | |
| 198 | }); | |
| 199 | }); | |
| 200 | ||
| 201 | const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn( | |
| 202 | "PreviewAutomationBroker.respond", | |
| 203 | )(function* (response) { | |
| 204 | let pending: PendingRequest | undefined; | |
| 205 | yield* Ref.update(state, (current) => { | |
| 206 | pending = current.pending.get(response.requestId); | |
| 207 | if (!pending) return current; | |
| 208 | const next = new Map(current.pending); | |
| 209 | next.delete(response.requestId); | |
| 210 | return { ...current, pending: next }; | |
| 211 | }); | |
| 212 | if (!pending) return; | |
| 213 | if (response.ok) { | |
| 214 | yield* Deferred.succeed(pending.deferred, response.result); | |
| 215 | } else { | |
| 216 | yield* Deferred.fail( | |
| 217 | pending.deferred, | |
| 218 | response.error | |
| 219 | ? makeResponseError(response.error) | |
| 220 | : new PreviewAutomationExecutionError({ | |
| 221 | message: "Preview automation failed without an error payload.", | |
| 222 | }), | |
| 223 | ); | |
| 224 | } | |
| 225 | }); | |
| 226 | ||
| 227 | const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>( | |
| 228 | input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0], | |
| 229 | ): Effect.fn.Return<A, PreviewAutomationError> { | |
| 230 | const current = yield* Ref.get(state); | |
| 231 | const candidates = Array.from(current.owners.values()) | |
| 232 | .filter( | |
| 233 | (owner) => | |
| 234 | owner.environmentId === input.scope.environmentId && | |
| 235 | owner.threadId === input.scope.threadId && | |
| 236 | owner.supportsAutomation, | |
| 237 | ) | |
| 238 | .sort((left, right) => right.focusedAt.localeCompare(left.focusedAt)); | |
| 239 | const owner = candidates[0]; | |
| 240 | if (!owner) { | |
| 241 | return yield* new PreviewAutomationNoFocusedOwnerError({ | |
| 242 | message: "No desktop browser host is available for this thread.", | |
| 243 | }); | |
| 244 | } | |
| 245 | const connection = current.clients.get(owner.clientId); | |
| 246 | if (!connection) { | |
| 247 | return yield* new PreviewAutomationUnavailableError({ | |
| 248 | message: "The browser host is not connected.", | |
| 249 | }); | |
| 250 | } | |
| 251 | if ( | |
| 252 | input.operation !== "open" && | |
| 253 | input.operation !== "status" && | |
| 254 | !owner.tabId && | |
| 255 | !input.tabId | |
| 256 | ) { | |
| 257 | return yield* new PreviewAutomationTabNotFoundError({ | |
| 258 | message: "The browser host does not have an active tab.", | |
| 259 | }); | |
| 260 | } | |
| 261 | const requestId = `preview-${requestSequence++}`; | |
| 262 | const timeoutMs = input.timeoutMs ?? 15_000; | |
| 263 | const deferred = yield* Deferred.make<unknown, PreviewAutomationError>(); | |
| 264 | yield* Ref.update(state, (next) => { | |
| 265 | const pending = new Map(next.pending); | |
| 266 | pending.set(requestId, { clientId: owner.clientId, deferred }); | |
| 267 | return { ...next, pending }; | |
| 268 | }); | |
| 269 | const offered = yield* Queue.offer(connection.queue, { | |
| 270 | requestId, | |
| 271 | threadId: input.scope.threadId, | |
| 272 | tabId: input.tabId ?? owner.tabId ?? undefined, | |
| 273 | operation: input.operation, | |
| 274 | input: input.input, | |
| 275 | timeoutMs, | |
| 276 | }); | |
| 277 | if (!offered) { | |
| 278 | return yield* new PreviewAutomationUnavailableError({ | |
| 279 | message: "The preview automation client is no longer accepting requests.", | |
| 280 | }); | |
| 281 | } | |
| 282 | const result = yield* Deferred.await(deferred).pipe(Effect.timeoutOption(timeoutMs)); | |
| 283 | yield* Ref.update(state, (next) => { | |
| 284 | const pending = new Map(next.pending); | |
| 285 | pending.delete(requestId); | |
| 286 | return { ...next, pending }; | |
| 287 | }); | |
| 288 | return yield* Option.match(result, { | |
| 289 | onNone: () => | |
| 290 | Effect.fail( | |
| 291 | new PreviewAutomationTimeoutError({ | |
| 292 | message: `Preview automation timed out after ${timeoutMs}ms.`, | |
| 293 | }), | |
| 294 | ), | |
| 295 | onSome: (value) => Effect.succeed(value as A), | |
| 296 | }); | |
| 297 | }); | |
| 298 | ||
| 299 | return PreviewAutomationBroker.of({ connect, reportOwner, clearOwner, respond, invoke }); | |
| 300 | }; | |
| 301 | ||
| 302 | const service = makeService(); | |
| 303 | const make = Effect.succeed(service); | |
| 304 | export const layer = Layer.effect(PreviewAutomationBroker, make); | |
| 305 | ||
| 306 | export const makeForTest = Effect.sync(makeService); |
apps/server/src/mcp/Services/McpInvocationContext.ts deleted-33| ... | ... | @@ -1,33 +0,0 @@ |
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import { PreviewAutomationUnavailableError } from "@t3tools/contracts"; | |
| 3 | import * as Context from "effect/Context"; | |
| 4 | import * as Effect from "effect/Effect"; | |
| 5 | ||
| 6 | export type McpCapability = "preview"; | |
| 7 | ||
| 8 | export interface McpInvocationScope { | |
| 9 | readonly environmentId: EnvironmentId; | |
| 10 | readonly threadId: ThreadId; | |
| 11 | readonly providerSessionId: string; | |
| 12 | readonly providerInstanceId: ProviderInstanceId; | |
| 13 | readonly capabilities: ReadonlySet<McpCapability>; | |
| 14 | readonly issuedAt: number; | |
| 15 | readonly expiresAt: number; | |
| 16 | } | |
| 17 | ||
| 18 | export class McpInvocationContext extends Context.Service< | |
| 19 | McpInvocationContext, | |
| 20 | McpInvocationScope | |
| 21 | >()("t3/mcp/Services/McpInvocationContext") {} | |
| 22 | ||
| 23 | export const requireMcpCapability = Effect.fn("mcp.requireCapability")(function* ( | |
| 24 | capability: McpCapability, | |
| 25 | ) { | |
| 26 | const invocation = yield* McpInvocationContext; | |
| 27 | if (!invocation.capabilities.has(capability)) { | |
| 28 | return yield* new PreviewAutomationUnavailableError({ | |
| 29 | message: `MCP credential does not grant the ${capability} capability.`, | |
| 30 | }); | |
| 31 | } | |
| 32 | return invocation; | |
| 33 | }); |
apps/server/src/mcp/Services/McpProviderSession.ts deleted-34| ... | ... | @@ -1,34 +0,0 @@ |
| 1 | import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | ||
| 4 | export interface McpProviderSessionConfig { | |
| 5 | readonly environmentId: EnvironmentId; | |
| 6 | readonly threadId: ThreadId; | |
| 7 | readonly providerSessionId: string; | |
| 8 | readonly providerInstanceId: ProviderInstanceId; | |
| 9 | readonly endpoint: string; | |
| 10 | readonly authorizationHeader: string; | |
| 11 | } | |
| 12 | ||
| 13 | export class McpProviderSession extends Context.Service< | |
| 14 | McpProviderSession, | |
| 15 | McpProviderSessionConfig | |
| 16 | >()("t3/mcp/Services/McpProviderSession") {} | |
| 17 | ||
| 18 | const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>(); | |
| 19 | ||
| 20 | export function setMcpProviderSession(config: McpProviderSessionConfig): void { | |
| 21 | sessionsByThread.set(config.threadId, config); | |
| 22 | } | |
| 23 | ||
| 24 | export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined { | |
| 25 | return sessionsByThread.get(threadId); | |
| 26 | } | |
| 27 | ||
| 28 | export function clearMcpProviderSession(threadId: ThreadId): void { | |
| 29 | sessionsByThread.delete(threadId); | |
| 30 | } | |
| 31 | ||
| 32 | export function clearAllMcpProviderSessions(): void { | |
| 33 | sessionsByThread.clear(); | |
| 34 | } |
apps/server/src/mcp/Services/McpSessionRegistry.ts deleted-29| ... | ... | @@ -1,29 +0,0 @@ |
| 1 | import type { ProviderInstanceId, ThreadId } from "@t3tools/contracts"; | |
| 2 | import * as Context from "effect/Context"; | |
| 3 | import type * as Effect from "effect/Effect"; | |
| 4 | ||
| 5 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | |
| 6 | import type { McpProviderSessionConfig } from "./McpProviderSession.ts"; | |
| 7 | ||
| 8 | export interface McpCredentialRequest { | |
| 9 | readonly threadId: ThreadId; | |
| 10 | readonly providerInstanceId: ProviderInstanceId; | |
| 11 | } | |
| 12 | ||
| 13 | export interface McpIssuedCredential { | |
| 14 | readonly config: McpProviderSessionConfig; | |
| 15 | readonly expiresAt: number; | |
| 16 | } | |
| 17 | ||
| 18 | export interface McpSessionRegistryShape { | |
| 19 | readonly issue: (request: McpCredentialRequest) => Effect.Effect<McpIssuedCredential, never>; | |
| 20 | readonly resolve: (rawToken: string) => Effect.Effect<McpInvocationScope | undefined, never>; | |
| 21 | readonly revokeProviderSession: (providerSessionId: string) => Effect.Effect<void>; | |
| 22 | readonly revokeThread: (threadId: ThreadId) => Effect.Effect<void>; | |
| 23 | readonly revokeAll: Effect.Effect<void>; | |
| 24 | } | |
| 25 | ||
| 26 | export class McpSessionRegistry extends Context.Service< | |
| 27 | McpSessionRegistry, | |
| 28 | McpSessionRegistryShape | |
| 29 | >()("t3/mcp/Services/McpSessionRegistry") {} |
apps/server/src/mcp/Services/PreviewAutomationBroker.ts deleted-40| ... | ... | @@ -1,40 +0,0 @@ |
| 1 | import type { | |
| 2 | PreviewAutomationError, | |
| 3 | PreviewAutomationOperation, | |
| 4 | PreviewAutomationOwner, | |
| 5 | PreviewAutomationRequest, | |
| 6 | PreviewAutomationResponse, | |
| 7 | PreviewTabId, | |
| 8 | } from "@t3tools/contracts"; | |
| 9 | import * as Context from "effect/Context"; | |
| 10 | import type * as Effect from "effect/Effect"; | |
| 11 | import type * as Stream from "effect/Stream"; | |
| 12 | ||
| 13 | import type { McpInvocationScope } from "./McpInvocationContext.ts"; | |
| 14 | ||
| 15 | export interface PreviewAutomationInvokeInput { | |
| 16 | readonly scope: McpInvocationScope; | |
| 17 | readonly operation: PreviewAutomationOperation; | |
| 18 | readonly input: unknown; | |
| 19 | readonly tabId?: PreviewTabId; | |
| 20 | readonly timeoutMs?: number; | |
| 21 | } | |
| 22 | ||
| 23 | export interface PreviewAutomationBrokerShape { | |
| 24 | readonly connect: (clientId: string) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>; | |
| 25 | readonly reportOwner: ( | |
| 26 | owner: PreviewAutomationOwner, | |
| 27 | ) => Effect.Effect<void, PreviewAutomationError>; | |
| 28 | readonly clearOwner: (clientId: string) => Effect.Effect<void>; | |
| 29 | readonly respond: ( | |
| 30 | response: PreviewAutomationResponse, | |
| 31 | ) => Effect.Effect<void, PreviewAutomationError>; | |
| 32 | readonly invoke: <A = unknown>( | |
| 33 | request: PreviewAutomationInvokeInput, | |
| 34 | ) => Effect.Effect<A, PreviewAutomationError>; | |
| 35 | } | |
| 36 | ||
| 37 | export class PreviewAutomationBroker extends Context.Service< | |
| 38 | PreviewAutomationBroker, | |
| 39 | PreviewAutomationBrokerShape | |
| 40 | >()("t3/mcp/Services/PreviewAutomationBroker") {} |
apps/server/src/mcp/toolkits/preview/handlers.ts+14-14| ... | ... | @@ -7,28 +7,28 @@ import type { |
| 7 | 7 | PreviewAutomationStatus, |
| 8 | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | |
| 10 | import { requireMcpCapability } from "../../Services/McpInvocationContext.ts"; | |
| 11 | import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts"; | |
| 10 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 11 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; | |
| 12 | 12 | import { PreviewToolkit } from "./tools.ts"; |
| 13 | 13 | |
| 14 | const invoke = <A>( | |
| 14 | const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( | |
| 15 | 15 | operation: PreviewAutomationOperation, |
| 16 | 16 | input: unknown, |
| 17 | 17 | timeoutMs?: number, |
| 18 | ): Effect.Effect< | |
| 18 | ): Effect.fn.Return< | |
| 19 | 19 | A, |
| 20 | 20 | import("@t3tools/contracts").PreviewAutomationError, |
| 21 | import("../../Services/McpInvocationContext.ts").McpInvocationContext | |
| 22 | > => | |
| 23 | Effect.gen(function* () { | |
| 24 | const scope = yield* requireMcpCapability("preview"); | |
| 25 | return yield* previewAutomationBroker.invoke<A>({ | |
| 26 | scope, | |
| 27 | operation, | |
| 28 | input, | |
| 29 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | |
| 30 | }); | |
| 21 | McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker | |
| 22 | > { | |
| 23 | const scope = yield* McpInvocationContext.requireMcpCapability("preview"); | |
| 24 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | |
| 25 | return yield* broker.invoke<A>({ | |
| 26 | scope, | |
| 27 | operation, | |
| 28 | input, | |
| 29 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | |
| 31 | 30 | }); |
| 31 | }); | |
| 32 | 32 | |
| 33 | 33 | export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ |
| 34 | 34 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), |
apps/server/src/mcp/toolkits/preview/tools.ts+19-13| ... | ... | @@ -16,7 +16,13 @@ import { |
| 16 | 16 | import * as Schema from "effect/Schema"; |
| 17 | 17 | import { Tool, Toolkit } from "effect/unstable/ai"; |
| 18 | 18 | |
| 19 | import { McpInvocationContext } from "../../Services/McpInvocationContext.ts"; | |
| 19 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; | |
| 20 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; | |
| 21 | ||
| 22 | const dependencies = [ | |
| 23 | McpInvocationContext.McpInvocationContext, | |
| 24 | PreviewAutomationBroker.PreviewAutomationBroker, | |
| 25 | ]; | |
| 20 | 26 | |
| 21 | 27 | const browserTool = <T extends Tool.Any>(tool: T): T => |
| 22 | 28 | tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; |
| ... | ... | @@ -32,7 +38,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { |
| 32 | 38 | "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", |
| 33 | 39 | success: PreviewAutomationStatus, |
| 34 | 40 | failure: PreviewAutomationError, |
| 35 | dependencies: [McpInvocationContext], | |
| 41 | dependencies, | |
| 36 | 42 | }) |
| 37 | 43 | .annotate(Tool.Title, "Get preview status") |
| 38 | 44 | .annotate(Tool.Readonly, true) |
| ... | ... | @@ -46,7 +52,7 @@ export const PreviewOpenTool = browserTool( |
| 46 | 52 | parameters: PreviewAutomationOpenInput, |
| 47 | 53 | success: PreviewAutomationStatus, |
| 48 | 54 | failure: PreviewAutomationError, |
| 49 | dependencies: [McpInvocationContext], | |
| 55 | dependencies, | |
| 50 | 56 | }) |
| 51 | 57 | .annotate(Tool.Title, "Open browser preview") |
| 52 | 58 | .annotate(Tool.Destructive, false), |
| ... | ... | @@ -59,7 +65,7 @@ export const PreviewNavigateTool = safeBrowserTool( |
| 59 | 65 | parameters: PreviewAutomationNavigateInput, |
| 60 | 66 | success: PreviewAutomationStatus, |
| 61 | 67 | failure: PreviewAutomationError, |
| 62 | dependencies: [McpInvocationContext], | |
| 68 | dependencies, | |
| 63 | 69 | }).annotate(Tool.Title, "Navigate browser preview"), |
| 64 | 70 | ); |
| 65 | 71 | |
| ... | ... | @@ -69,7 +75,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( |
| 69 | 75 | "Inspect the current page before interacting. Returns URL/title/loading state, visible text, semantic interactive elements with reusable selectors and coordinates, accessibility data, recent console/network failures, action history, and a PNG screenshot.", |
| 70 | 76 | success: PreviewAutomationSnapshot, |
| 71 | 77 | failure: PreviewAutomationError, |
| 72 | dependencies: [McpInvocationContext], | |
| 78 | dependencies, | |
| 73 | 79 | }).annotate(Tool.Title, "Inspect browser page"), |
| 74 | 80 | ); |
| 75 | 81 | |
| ... | ... | @@ -79,7 +85,7 @@ export const PreviewClickTool = browserTool( |
| 79 | 85 | "Click exactly one page target. Prefer locator with a Playwright selector such as role=button[name='Send']; selector accepts legacy CSS; x and y are viewport CSS pixels and must be supplied together. Call preview_snapshot first when the target is unknown.", |
| 80 | 86 | parameters: PreviewAutomationClickInput, |
| 81 | 87 | failure: PreviewAutomationError, |
| 82 | dependencies: [McpInvocationContext], | |
| 88 | dependencies, | |
| 83 | 89 | }).annotate(Tool.Title, "Click preview page"), |
| 84 | 90 | ); |
| 85 | 91 | |
| ... | ... | @@ -89,7 +95,7 @@ export const PreviewTypeTool = browserTool( |
| 89 | 95 | "Insert literal text into one input. Prefer locator with a Playwright role/text selector; selector accepts legacy CSS. If neither is supplied, types into the currently focused element. Set clear=true to replace existing text.", |
| 90 | 96 | parameters: PreviewAutomationTypeInput, |
| 91 | 97 | failure: PreviewAutomationError, |
| 92 | dependencies: [McpInvocationContext], | |
| 98 | dependencies, | |
| 93 | 99 | }).annotate(Tool.Title, "Type into preview page"), |
| 94 | 100 | ); |
| 95 | 101 | |
| ... | ... | @@ -99,7 +105,7 @@ export const PreviewPressTool = browserTool( |
| 99 | 105 | "Press one keyboard key in the active page, for example {key:'Enter'}, {key:'Escape'}, or {key:'a',modifiers:['Meta']}. This targets the page's current focus.", |
| 100 | 106 | parameters: PreviewAutomationPressInput, |
| 101 | 107 | failure: PreviewAutomationError, |
| 102 | dependencies: [McpInvocationContext], | |
| 108 | dependencies, | |
| 103 | 109 | }).annotate(Tool.Title, "Press key in preview page"), |
| 104 | 110 | ); |
| 105 | 111 | |
| ... | ... | @@ -109,7 +115,7 @@ export const PreviewScrollTool = safeBrowserTool( |
| 109 | 115 | "Scroll by CSS pixels. Positive deltaY scrolls down and positive deltaX scrolls right. Without locator/selector it scrolls the viewport; otherwise it scrolls that container. At least one delta is required.", |
| 110 | 116 | parameters: PreviewAutomationScrollInput, |
| 111 | 117 | failure: PreviewAutomationError, |
| 112 | dependencies: [McpInvocationContext], | |
| 118 | dependencies, | |
| 113 | 119 | }).annotate(Tool.Title, "Scroll preview page"), |
| 114 | 120 | ); |
| 115 | 121 | |
| ... | ... | @@ -120,7 +126,7 @@ export const PreviewEvaluateTool = browserTool( |
| 120 | 126 | parameters: PreviewAutomationEvaluateInput, |
| 121 | 127 | success: Schema.Unknown, |
| 122 | 128 | failure: PreviewAutomationError, |
| 123 | dependencies: [McpInvocationContext], | |
| 129 | dependencies, | |
| 124 | 130 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), |
| 125 | 131 | ); |
| 126 | 132 | |
| ... | ... | @@ -130,7 +136,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( |
| 130 | 136 | "Wait until all supplied conditions match: a Playwright locator, legacy CSS selector, visible-text substring, and/or URL substring. Provide at least one condition. Defaults to 15 seconds, maximum 60 seconds.", |
| 131 | 137 | parameters: PreviewAutomationWaitForInput, |
| 132 | 138 | failure: PreviewAutomationError, |
| 133 | dependencies: [McpInvocationContext], | |
| 139 | dependencies, | |
| 134 | 140 | }).annotate(Tool.Title, "Wait for preview page condition"), |
| 135 | 141 | ); |
| 136 | 142 | |
| ... | ... | @@ -140,7 +146,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( |
| 140 | 146 | "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", |
| 141 | 147 | success: PreviewAutomationRecordingStatus, |
| 142 | 148 | failure: PreviewAutomationError, |
| 143 | dependencies: [McpInvocationContext], | |
| 149 | dependencies, | |
| 144 | 150 | }).annotate(Tool.Title, "Start browser recording"), |
| 145 | 151 | ); |
| 146 | 152 | |
| ... | ... | @@ -149,7 +155,7 @@ export const PreviewRecordingStopTool = safeBrowserTool( |
| 149 | 155 | description: "Stop the active browser recording and save it as a local evidence artifact.", |
| 150 | 156 | success: PreviewAutomationRecordingArtifact, |
| 151 | 157 | failure: PreviewAutomationError, |
| 152 | dependencies: [McpInvocationContext], | |
| 158 | dependencies, | |
| 153 | 159 | }).annotate(Tool.Title, "Stop browser recording"), |
| 154 | 160 | ); |
| 155 | 161 |
apps/server/src/preview/Layers/Manager.test.ts deleted-261| ... | ... | @@ -1,261 +0,0 @@ |
| 1 | import { it } from "@effect/vitest"; | |
| 2 | import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { Effect, PubSub } from "effect"; | |
| 4 | import { expect } from "vite-plus/test"; | |
| 5 | ||
| 6 | import { PreviewManager } from "../Services/Manager.ts"; | |
| 7 | import { PreviewManagerLive } from "./Manager.ts"; | |
| 8 | ||
| 9 | const DRAIN_LIMIT = 100; | |
| 10 | ||
| 11 | interface EventCollector { | |
| 12 | /** Drain everything published since the last call (or since subscribe). */ | |
| 13 | readonly drain: Effect.Effect<ReadonlyArray<PreviewEvent>>; | |
| 14 | } | |
| 15 | ||
| 16 | /** | |
| 17 | * Each `it.effect` shares the live PreviewManager layer across the whole | |
| 18 | * `it.layer` block, so tests that assert per-thread counts must use a unique | |
| 19 | * thread id to avoid bleeding state from earlier tests. | |
| 20 | */ | |
| 21 | let nextThreadId = 0; | |
| 22 | const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); | |
| 23 | ||
| 24 | /** | |
| 25 | * Subscribe to the manager's event stream BEFORE the test publishes. We | |
| 26 | * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so | |
| 27 | * no event can land between subscribe and the consumer drain. | |
| 28 | */ | |
| 29 | const collectEvents = Effect.gen(function* () { | |
| 30 | const manager = yield* PreviewManager; | |
| 31 | const subscription = yield* manager.subscribeEvents; | |
| 32 | const collector: EventCollector = { | |
| 33 | drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT), | |
| 34 | }; | |
| 35 | return collector; | |
| 36 | }).pipe(Effect.withSpan("preview.test.collectEvents")); | |
| 37 | ||
| 38 | it.layer(PreviewManagerLive)("PreviewManager", (it) => { | |
| 39 | it.effect("opens a session and emits opened with normalized URL", () => | |
| 40 | Effect.gen(function* () { | |
| 41 | const threadId = freshThreadId(); | |
| 42 | const manager = yield* PreviewManager; | |
| 43 | const collector = yield* collectEvents; | |
| 44 | ||
| 45 | const snapshot = yield* manager.open({ threadId, url: "localhost:5173" }); | |
| 46 | expect(snapshot.tabId.startsWith("tab_")).toBe(true); | |
| 47 | expect(snapshot.navStatus._tag).toBe("Loading"); | |
| 48 | if (snapshot.navStatus._tag === "Loading") { | |
| 49 | expect(snapshot.navStatus.url).toBe("http://localhost:5173/"); | |
| 50 | } | |
| 51 | ||
| 52 | const events = yield* collector.drain; | |
| 53 | expect(events).toHaveLength(1); | |
| 54 | expect(events[0]?.type).toBe("opened"); | |
| 55 | if (events[0]?.type === "opened") { | |
| 56 | expect(events[0].tabId).toBe(snapshot.tabId); | |
| 57 | } | |
| 58 | }), | |
| 59 | ); | |
| 60 | ||
| 61 | it.effect("opens an Idle tab when no URL is supplied", () => | |
| 62 | Effect.gen(function* () { | |
| 63 | const threadId = freshThreadId(); | |
| 64 | const manager = yield* PreviewManager; | |
| 65 | const snapshot = yield* manager.open({ threadId }); | |
| 66 | expect(snapshot.navStatus._tag).toBe("Idle"); | |
| 67 | }), | |
| 68 | ); | |
| 69 | ||
| 70 | it.effect("treats bare hosts as https", () => | |
| 71 | Effect.gen(function* () { | |
| 72 | const threadId = freshThreadId(); | |
| 73 | const manager = yield* PreviewManager; | |
| 74 | const snapshot = yield* manager.open({ threadId, url: "example.com" }); | |
| 75 | if (snapshot.navStatus._tag === "Loading") { | |
| 76 | expect(snapshot.navStatus.url).toBe("https://example.com/"); | |
| 77 | } | |
| 78 | }), | |
| 79 | ); | |
| 80 | ||
| 81 | it.effect("rejects empty URL with PreviewInvalidUrlError", () => | |
| 82 | Effect.gen(function* () { | |
| 83 | const threadId = freshThreadId(); | |
| 84 | const manager = yield* PreviewManager; | |
| 85 | const error = yield* Effect.flip(manager.open({ threadId, url: " " })); | |
| 86 | expect(error._tag).toBe("PreviewInvalidUrlError"); | |
| 87 | }), | |
| 88 | ); | |
| 89 | ||
| 90 | it.effect("navigate updates snapshot and emits navigated", () => | |
| 91 | Effect.gen(function* () { | |
| 92 | const threadId = freshThreadId(); | |
| 93 | const manager = yield* PreviewManager; | |
| 94 | const collector = yield* collectEvents; | |
| 95 | ||
| 96 | const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 97 | const snapshot = yield* manager.navigate({ | |
| 98 | threadId, | |
| 99 | tabId: opened.tabId, | |
| 100 | url: "http://localhost:5173/about", | |
| 101 | resolvedTitle: "About", | |
| 102 | }); | |
| 103 | ||
| 104 | expect(snapshot.navStatus._tag).toBe("Success"); | |
| 105 | if (snapshot.navStatus._tag === "Success") { | |
| 106 | expect(snapshot.navStatus.url).toBe("http://localhost:5173/about"); | |
| 107 | expect(snapshot.navStatus.title).toBe("About"); | |
| 108 | } | |
| 109 | const events = yield* collector.drain; | |
| 110 | expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]); | |
| 111 | }), | |
| 112 | ); | |
| 113 | ||
| 114 | it.effect("navigate fails for unknown tab", () => | |
| 115 | Effect.gen(function* () { | |
| 116 | const threadId = freshThreadId(); | |
| 117 | const manager = yield* PreviewManager; | |
| 118 | const error = yield* Effect.flip( | |
| 119 | manager.navigate({ | |
| 120 | threadId, | |
| 121 | tabId: "tab_missing", | |
| 122 | url: "http://localhost:5173", | |
| 123 | }), | |
| 124 | ); | |
| 125 | expect(error._tag).toBe("PreviewSessionLookupError"); | |
| 126 | }), | |
| 127 | ); | |
| 128 | ||
| 129 | it.effect("reportStatus emits failed for LoadFailed nav", () => | |
| 130 | Effect.gen(function* () { | |
| 131 | const threadId = freshThreadId(); | |
| 132 | const manager = yield* PreviewManager; | |
| 133 | const collector = yield* collectEvents; | |
| 134 | ||
| 135 | const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 136 | yield* manager.reportStatus({ | |
| 137 | threadId, | |
| 138 | tabId: opened.tabId, | |
| 139 | navStatus: { | |
| 140 | _tag: "LoadFailed", | |
| 141 | url: "http://localhost:5173", | |
| 142 | title: "", | |
| 143 | code: -105, | |
| 144 | description: "ERR_NAME_NOT_RESOLVED", | |
| 145 | }, | |
| 146 | canGoBack: false, | |
| 147 | canGoForward: false, | |
| 148 | }); | |
| 149 | ||
| 150 | const events = yield* collector.drain; | |
| 151 | const failed = events.find((e) => e.type === "failed"); | |
| 152 | expect(failed?.type).toBe("failed"); | |
| 153 | if (failed?.type === "failed") { | |
| 154 | expect(failed.code).toBe(-105); | |
| 155 | expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED"); | |
| 156 | } | |
| 157 | }), | |
| 158 | ); | |
| 159 | ||
| 160 | it.effect("close removes the session and emits closed", () => | |
| 161 | Effect.gen(function* () { | |
| 162 | const threadId = freshThreadId(); | |
| 163 | const manager = yield* PreviewManager; | |
| 164 | const collector = yield* collectEvents; | |
| 165 | ||
| 166 | yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 167 | yield* manager.close({ threadId }); | |
| 168 | ||
| 169 | const result = yield* manager.list({ threadId }); | |
| 170 | expect(result.sessions).toHaveLength(0); | |
| 171 | const events = yield* collector.drain; | |
| 172 | const closed = events.find((e) => e.type === "closed"); | |
| 173 | expect(closed?.type).toBe("closed"); | |
| 174 | }), | |
| 175 | ); | |
| 176 | ||
| 177 | it.effect("close is idempotent for unknown threads", () => | |
| 178 | Effect.gen(function* () { | |
| 179 | const threadId = freshThreadId(); | |
| 180 | const manager = yield* PreviewManager; | |
| 181 | yield* manager.close({ threadId }); | |
| 182 | const result = yield* manager.list({ threadId }); | |
| 183 | expect(result.sessions).toHaveLength(0); | |
| 184 | }), | |
| 185 | ); | |
| 186 | ||
| 187 | it.effect("list returns every snapshot for the thread sorted by updatedAt", () => | |
| 188 | Effect.gen(function* () { | |
| 189 | const threadId = freshThreadId(); | |
| 190 | const manager = yield* PreviewManager; | |
| 191 | const first = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 192 | const second = yield* manager.open({ threadId, url: "http://localhost:3000" }); | |
| 193 | const result = yield* manager.list({ threadId }); | |
| 194 | expect(result.sessions).toHaveLength(2); | |
| 195 | const ids = result.sessions.map((s) => s.tabId); | |
| 196 | expect(ids).toContain(first.tabId); | |
| 197 | expect(ids).toContain(second.tabId); | |
| 198 | }), | |
| 199 | ); | |
| 200 | ||
| 201 | it.effect("open creates an independent tab on every call", () => | |
| 202 | Effect.gen(function* () { | |
| 203 | const threadId = freshThreadId(); | |
| 204 | const manager = yield* PreviewManager; | |
| 205 | const collector = yield* collectEvents; | |
| 206 | ||
| 207 | const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 208 | const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" }); | |
| 209 | ||
| 210 | expect(a.tabId).not.toBe(b.tabId); | |
| 211 | const list = yield* manager.list({ threadId }); | |
| 212 | expect(list.sessions).toHaveLength(2); | |
| 213 | ||
| 214 | const events = yield* collector.drain; | |
| 215 | expect(events.map((e) => e.type)).toEqual(["opened", "opened"]); | |
| 216 | }), | |
| 217 | ); | |
| 218 | ||
| 219 | it.effect("close with mismatching tabId is a no-op", () => | |
| 220 | Effect.gen(function* () { | |
| 221 | const threadId = freshThreadId(); | |
| 222 | const manager = yield* PreviewManager; | |
| 223 | yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 224 | yield* manager.close({ threadId, tabId: "tab_missing" }); | |
| 225 | ||
| 226 | const list = yield* manager.list({ threadId }); | |
| 227 | expect(list.sessions).toHaveLength(1); | |
| 228 | }), | |
| 229 | ); | |
| 230 | ||
| 231 | it.effect("close with explicit tabId removes only that tab", () => | |
| 232 | Effect.gen(function* () { | |
| 233 | const threadId = freshThreadId(); | |
| 234 | const manager = yield* PreviewManager; | |
| 235 | const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 236 | const b = yield* manager.open({ threadId, url: "http://localhost:3000" }); | |
| 237 | ||
| 238 | yield* manager.close({ threadId, tabId: a.tabId }); | |
| 239 | ||
| 240 | const list = yield* manager.list({ threadId }); | |
| 241 | expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]); | |
| 242 | }), | |
| 243 | ); | |
| 244 | ||
| 245 | it.effect("multiple subscribers receive every event independently", () => | |
| 246 | Effect.gen(function* () { | |
| 247 | const threadId = freshThreadId(); | |
| 248 | const manager = yield* PreviewManager; | |
| 249 | const aSub = yield* manager.subscribeEvents; | |
| 250 | const bSub = yield* manager.subscribeEvents; | |
| 251 | ||
| 252 | yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 253 | yield* manager.open({ threadId, url: "http://localhost:3000" }); | |
| 254 | ||
| 255 | const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT); | |
| 256 | const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT); | |
| 257 | expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]); | |
| 258 | expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]); | |
| 259 | }), | |
| 260 | ); | |
| 261 | }); |
apps/server/src/preview/Layers/Manager.ts deleted-321| ... | ... | @@ -1,321 +0,0 @@ |
| 1 | /** | |
| 2 | * In-memory PreviewManager implementation. | |
| 3 | * | |
| 4 | * Sessions are keyed by `(threadId, tabId)`; a single thread can host | |
| 5 | * multiple tabs (browser-style). `open` always creates a new tab — tab | |
| 6 | * lifecycle is owned by the renderer. | |
| 7 | * | |
| 8 | * Events are published via Effect's `PubSub`, so subscriber failures are | |
| 9 | * isolated from the publishing call (a closed WS subscriber queue cannot | |
| 10 | * fail an in-progress `navigate()`). | |
| 11 | */ | |
| 12 | import { | |
| 13 | type PreviewEvent, | |
| 14 | PreviewInvalidUrlError, | |
| 15 | type PreviewListResult, | |
| 16 | PreviewSessionLookupError, | |
| 17 | type PreviewSessionSnapshot, | |
| 18 | } from "@t3tools/contracts"; | |
| 19 | import { | |
| 20 | newPreviewTabId, | |
| 21 | normalizePreviewUrl, | |
| 22 | PreviewUrlNormalizationError, | |
| 23 | } from "@t3tools/shared/preview"; | |
| 24 | import { DateTime, Effect, Layer, PubSub, Stream, SynchronizedRef } from "effect"; | |
| 25 | ||
| 26 | import { PreviewManager, type PreviewManagerShape } from "../Services/Manager.ts"; | |
| 27 | ||
| 28 | interface PreviewSessionState { | |
| 29 | readonly threadId: string; | |
| 30 | readonly tabId: string; | |
| 31 | readonly snapshot: PreviewSessionSnapshot; | |
| 32 | } | |
| 33 | ||
| 34 | interface ManagerState { | |
| 35 | /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ | |
| 36 | readonly sessions: ReadonlyMap<string, PreviewSessionState>; | |
| 37 | } | |
| 38 | ||
| 39 | const initialState: ManagerState = { sessions: new Map() }; | |
| 40 | ||
| 41 | const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; | |
| 42 | ||
| 43 | const sessionsForThread = ( | |
| 44 | state: ManagerState, | |
| 45 | threadId: string, | |
| 46 | ): ReadonlyArray<PreviewSessionState> => { | |
| 47 | const out: PreviewSessionState[] = []; | |
| 48 | for (const session of state.sessions.values()) { | |
| 49 | if (session.threadId === threadId) out.push(session); | |
| 50 | } | |
| 51 | return out; | |
| 52 | }; | |
| 53 | ||
| 54 | const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> => | |
| 55 | Effect.try({ | |
| 56 | try: () => normalizePreviewUrl(rawUrl), | |
| 57 | catch: (cause) => | |
| 58 | new PreviewInvalidUrlError({ | |
| 59 | rawUrl, | |
| 60 | detail: | |
| 61 | cause instanceof PreviewUrlNormalizationError | |
| 62 | ? cause.detail | |
| 63 | : cause instanceof Error | |
| 64 | ? cause.message | |
| 65 | : String(cause), | |
| 66 | }), | |
| 67 | }); | |
| 68 | ||
| 69 | const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); | |
| 70 | ||
| 71 | const buildLoadingSnapshot = (input: { | |
| 72 | readonly threadId: string; | |
| 73 | readonly tabId: string; | |
| 74 | readonly url: string; | |
| 75 | readonly title: string; | |
| 76 | readonly updatedAt: string; | |
| 77 | }): PreviewSessionSnapshot => ({ | |
| 78 | threadId: input.threadId, | |
| 79 | tabId: input.tabId, | |
| 80 | navStatus: { _tag: "Loading", url: input.url, title: input.title }, | |
| 81 | canGoBack: false, | |
| 82 | canGoForward: false, | |
| 83 | updatedAt: input.updatedAt, | |
| 84 | }); | |
| 85 | ||
| 86 | const buildIdleSnapshot = (input: { | |
| 87 | readonly threadId: string; | |
| 88 | readonly tabId: string; | |
| 89 | readonly updatedAt: string; | |
| 90 | }): PreviewSessionSnapshot => ({ | |
| 91 | threadId: input.threadId, | |
| 92 | tabId: input.tabId, | |
| 93 | navStatus: { _tag: "Idle" }, | |
| 94 | canGoBack: false, | |
| 95 | canGoForward: false, | |
| 96 | updatedAt: input.updatedAt, | |
| 97 | }); | |
| 98 | ||
| 99 | export const makePreviewManager = Effect.gen(function* () { | |
| 100 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); | |
| 101 | // Unbounded PubSub is fine here — events are tiny and we don't want to | |
| 102 | // block publishers if a subscriber is slow. WS clients backpressure on | |
| 103 | // their own queues downstream. | |
| 104 | const eventsPubSub = yield* PubSub.unbounded<PreviewEvent>(); | |
| 105 | const events: Stream.Stream<PreviewEvent> = Stream.fromPubSub(eventsPubSub); | |
| 106 | ||
| 107 | /** | |
| 108 | * Atomic read-modify-write over the session for `(threadId, tabId)`. The | |
| 109 | * mutator runs under the SynchronizedRef so concurrent writers cannot | |
| 110 | * interleave. Lookup failures travel through the modify result so both | |
| 111 | * branches yield the same `[A, S]` shape `modifyEffect` requires. | |
| 112 | * | |
| 113 | * The event is published INSIDE the lock so observers see events in the | |
| 114 | * same order as the underlying state transitions. Publishing an unbounded | |
| 115 | * PubSub is non-blocking, so this is cheap. | |
| 116 | */ | |
| 117 | const mutateExistingSession = <R, E>( | |
| 118 | threadId: string, | |
| 119 | tabId: string, | |
| 120 | mutator: ( | |
| 121 | session: PreviewSessionState, | |
| 122 | ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, | |
| 123 | ): Effect.Effect<R, E | PreviewSessionLookupError> => { | |
| 124 | type ModifyResult = | |
| 125 | | { kind: "fail"; error: PreviewSessionLookupError } | |
| 126 | | { kind: "ok"; result: R }; | |
| 127 | ||
| 128 | return SynchronizedRef.modifyEffect(stateRef, (state) => { | |
| 129 | const session = state.sessions.get(compositeKey(threadId, tabId)); | |
| 130 | if (!session) { | |
| 131 | return Effect.succeed([ | |
| 132 | { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) }, | |
| 133 | state, | |
| 134 | ] as readonly [ModifyResult, ManagerState]); | |
| 135 | } | |
| 136 | return mutator(session).pipe( | |
| 137 | Effect.flatMap(({ next, emit, result }) => | |
| 138 | Effect.gen(function* () { | |
| 139 | if (emit) yield* PubSub.publish(eventsPubSub, emit); | |
| 140 | const sessions = new Map(state.sessions); | |
| 141 | sessions.set(compositeKey(threadId, tabId), next); | |
| 142 | return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ | |
| 143 | ModifyResult, | |
| 144 | ManagerState, | |
| 145 | ]; | |
| 146 | }), | |
| 147 | ), | |
| 148 | ); | |
| 149 | }).pipe( | |
| 150 | Effect.flatMap((modify) => | |
| 151 | modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result), | |
| 152 | ), | |
| 153 | ); | |
| 154 | }; | |
| 155 | ||
| 156 | const open: PreviewManagerShape["open"] = (input) => | |
| 157 | Effect.gen(function* () { | |
| 158 | const tabId = newPreviewTabId(); | |
| 159 | const updatedAt = yield* currentIsoTimestamp; | |
| 160 | const snapshot = input.url | |
| 161 | ? buildLoadingSnapshot({ | |
| 162 | threadId: input.threadId, | |
| 163 | tabId, | |
| 164 | url: yield* normalizeUrl(input.url), | |
| 165 | title: "", | |
| 166 | updatedAt, | |
| 167 | }) | |
| 168 | : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); | |
| 169 | yield* SynchronizedRef.update(stateRef, (state) => { | |
| 170 | const sessions = new Map(state.sessions); | |
| 171 | sessions.set(compositeKey(input.threadId, tabId), { | |
| 172 | threadId: input.threadId, | |
| 173 | tabId, | |
| 174 | snapshot, | |
| 175 | }); | |
| 176 | return { sessions }; | |
| 177 | }); | |
| 178 | yield* PubSub.publish(eventsPubSub, { | |
| 179 | type: "opened", | |
| 180 | threadId: input.threadId, | |
| 181 | tabId, | |
| 182 | createdAt: snapshot.updatedAt, | |
| 183 | snapshot, | |
| 184 | }); | |
| 185 | return snapshot; | |
| 186 | }); | |
| 187 | ||
| 188 | const navigate: PreviewManagerShape["navigate"] = (input) => | |
| 189 | Effect.gen(function* () { | |
| 190 | const url = yield* normalizeUrl(input.url); | |
| 191 | return yield* mutateExistingSession(input.threadId, input.tabId, (session) => | |
| 192 | Effect.gen(function* () { | |
| 193 | const updatedAt = yield* currentIsoTimestamp; | |
| 194 | const previousTitle = | |
| 195 | session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; | |
| 196 | const resolvedTitle = input.resolvedTitle ?? previousTitle; | |
| 197 | const snapshot: PreviewSessionSnapshot = { | |
| 198 | threadId: session.threadId, | |
| 199 | tabId: session.tabId, | |
| 200 | navStatus: { _tag: "Success", url, title: resolvedTitle }, | |
| 201 | canGoBack: session.snapshot.canGoBack, | |
| 202 | canGoForward: session.snapshot.canGoForward, | |
| 203 | updatedAt, | |
| 204 | }; | |
| 205 | return { | |
| 206 | next: { ...session, snapshot }, | |
| 207 | emit: { | |
| 208 | type: "navigated", | |
| 209 | threadId: session.threadId, | |
| 210 | tabId: session.tabId, | |
| 211 | createdAt: snapshot.updatedAt, | |
| 212 | snapshot, | |
| 213 | }, | |
| 214 | result: snapshot, | |
| 215 | }; | |
| 216 | }), | |
| 217 | ); | |
| 218 | }); | |
| 219 | ||
| 220 | const reportStatus: PreviewManagerShape["reportStatus"] = (input) => | |
| 221 | mutateExistingSession(input.threadId, input.tabId, (session) => | |
| 222 | Effect.gen(function* () { | |
| 223 | const updatedAt = yield* currentIsoTimestamp; | |
| 224 | const snapshot: PreviewSessionSnapshot = { | |
| 225 | threadId: session.threadId, | |
| 226 | tabId: session.tabId, | |
| 227 | navStatus: input.navStatus, | |
| 228 | canGoBack: input.canGoBack, | |
| 229 | canGoForward: input.canGoForward, | |
| 230 | updatedAt, | |
| 231 | }; | |
| 232 | const emit: PreviewEvent = | |
| 233 | input.navStatus._tag === "LoadFailed" | |
| 234 | ? { | |
| 235 | type: "failed", | |
| 236 | threadId: session.threadId, | |
| 237 | tabId: session.tabId, | |
| 238 | createdAt: snapshot.updatedAt, | |
| 239 | url: input.navStatus.url, | |
| 240 | title: input.navStatus.title, | |
| 241 | code: input.navStatus.code, | |
| 242 | description: input.navStatus.description, | |
| 243 | } | |
| 244 | : { | |
| 245 | type: "navigated", | |
| 246 | threadId: session.threadId, | |
| 247 | tabId: session.tabId, | |
| 248 | createdAt: snapshot.updatedAt, | |
| 249 | snapshot, | |
| 250 | }; | |
| 251 | return { | |
| 252 | next: { ...session, snapshot }, | |
| 253 | emit, | |
| 254 | result: undefined as void, | |
| 255 | }; | |
| 256 | }), | |
| 257 | ); | |
| 258 | ||
| 259 | const refresh: PreviewManagerShape["refresh"] = (input) => | |
| 260 | // Verify the session exists; the desktop bridge handles the actual reload | |
| 261 | // and will report progress back via `reportStatus`. No event emitted. | |
| 262 | mutateExistingSession(input.threadId, input.tabId, (session) => | |
| 263 | Effect.succeed({ next: session, emit: null, result: undefined as void }), | |
| 264 | ); | |
| 265 | ||
| 266 | const close: PreviewManagerShape["close"] = (input) => | |
| 267 | Effect.gen(function* () { | |
| 268 | const createdAt = yield* currentIsoTimestamp; | |
| 269 | const events = yield* SynchronizedRef.modify(stateRef, (state) => { | |
| 270 | const eventsToEmit: PreviewEvent[] = []; | |
| 271 | const sessions = new Map(state.sessions); | |
| 272 | const targets = input.tabId | |
| 273 | ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( | |
| 274 | (entry): entry is PreviewSessionState => entry !== undefined, | |
| 275 | ) | |
| 276 | : sessionsForThread(state, input.threadId); | |
| 277 | for (const target of targets) { | |
| 278 | sessions.delete(compositeKey(target.threadId, target.tabId)); | |
| 279 | eventsToEmit.push({ | |
| 280 | type: "closed", | |
| 281 | threadId: target.threadId, | |
| 282 | tabId: target.tabId, | |
| 283 | createdAt, | |
| 284 | }); | |
| 285 | } | |
| 286 | if (eventsToEmit.length === 0) { | |
| 287 | return [eventsToEmit, state] as const; | |
| 288 | } | |
| 289 | return [eventsToEmit, { sessions }] as const; | |
| 290 | }); | |
| 291 | if (events.length > 0) { | |
| 292 | yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { | |
| 293 | discard: true, | |
| 294 | }); | |
| 295 | } | |
| 296 | }); | |
| 297 | ||
| 298 | const list: PreviewManagerShape["list"] = (input) => | |
| 299 | SynchronizedRef.get(stateRef).pipe( | |
| 300 | Effect.map( | |
| 301 | (state): PreviewListResult => ({ | |
| 302 | sessions: sessionsForThread(state, input.threadId) | |
| 303 | .map((s) => s.snapshot) | |
| 304 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), | |
| 305 | }), | |
| 306 | ), | |
| 307 | ); | |
| 308 | ||
| 309 | return { | |
| 310 | open, | |
| 311 | navigate, | |
| 312 | reportStatus, | |
| 313 | refresh, | |
| 314 | close, | |
| 315 | list, | |
| 316 | events, | |
| 317 | subscribeEvents: PubSub.subscribe(eventsPubSub), | |
| 318 | } satisfies PreviewManagerShape; | |
| 319 | }); | |
| 320 | ||
| 321 | export const PreviewManagerLive = Layer.effect(PreviewManager, makePreviewManager); |
apps/server/src/preview/Layers/PortScanner.test.ts deleted-241| ... | ... | @@ -1,241 +0,0 @@ |
| 1 | import * as net from "node:net"; | |
| 2 | ||
| 3 | import { it as effectIt } from "@effect/vitest"; | |
| 4 | import { ThreadId } from "@t3tools/contracts"; | |
| 5 | import { Effect, Layer } from "effect"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { COMMON_DEV_PORTS, PortDiscovery } from "../Services/PortScanner.ts"; | |
| 9 | import { ProcessRunner } from "../../processRunner.ts"; | |
| 10 | import { __testing, PortDiscoveryLive } from "./PortScanner.ts"; | |
| 11 | ||
| 12 | const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = | |
| 13 | __testing; | |
| 14 | const TestProcessRunner = Layer.succeed(ProcessRunner, { | |
| 15 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), | |
| 16 | }); | |
| 17 | const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner)); | |
| 18 | ||
| 19 | describe("parsePortFromLsofName", () => { | |
| 20 | it("parses *:port", () => { | |
| 21 | expect(parsePortFromLsofName("*:5173")).toBe(5173); | |
| 22 | }); | |
| 23 | ||
| 24 | it("parses 127.0.0.1:port", () => { | |
| 25 | expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173); | |
| 26 | }); | |
| 27 | ||
| 28 | it("parses localhost:port", () => { | |
| 29 | expect(parsePortFromLsofName("localhost:5173")).toBe(5173); | |
| 30 | }); | |
| 31 | ||
| 32 | it("parses [::1]:port", () => { | |
| 33 | expect(parsePortFromLsofName("[::1]:5173")).toBe(5173); | |
| 34 | }); | |
| 35 | ||
| 36 | it("ignores non-local hosts", () => { | |
| 37 | expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull(); | |
| 38 | }); | |
| 39 | ||
| 40 | it("strips trailing description", () => { | |
| 41 | expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173); | |
| 42 | }); | |
| 43 | ||
| 44 | it("rejects garbage", () => { | |
| 45 | expect(parsePortFromLsofName("")).toBeNull(); | |
| 46 | expect(parsePortFromLsofName("not-a-port")).toBeNull(); | |
| 47 | expect(parsePortFromLsofName("*:0")).toBeNull(); | |
| 48 | expect(parsePortFromLsofName("*:99999")).toBeNull(); | |
| 49 | }); | |
| 50 | }); | |
| 51 | ||
| 52 | describe("parseLsofOutput", () => { | |
| 53 | it("parses a typical lsof -F pcn output", () => { | |
| 54 | const sample = [ | |
| 55 | "p12345", | |
| 56 | "cnode", | |
| 57 | "n*:5173", | |
| 58 | "p67890", | |
| 59 | "cnext-server", | |
| 60 | "n127.0.0.1:3000", | |
| 61 | "n127.0.0.1:9229", // node debug port too — same process | |
| 62 | "p13579", | |
| 63 | "cChrome", | |
| 64 | "n192.168.1.10:443", // not local — ignored | |
| 65 | ].join("\n"); | |
| 66 | ||
| 67 | const servers = parseLsofOutput(sample); | |
| 68 | expect(servers).toEqual([ | |
| 69 | { | |
| 70 | host: "localhost", | |
| 71 | port: 3000, | |
| 72 | url: "http://localhost:3000", | |
| 73 | processName: "next-server", | |
| 74 | pid: 67890, | |
| 75 | terminal: null, | |
| 76 | }, | |
| 77 | { | |
| 78 | host: "localhost", | |
| 79 | port: 5173, | |
| 80 | url: "http://localhost:5173", | |
| 81 | processName: "node", | |
| 82 | pid: 12345, | |
| 83 | terminal: null, | |
| 84 | }, | |
| 85 | { | |
| 86 | host: "localhost", | |
| 87 | port: 9229, | |
| 88 | url: "http://localhost:9229", | |
| 89 | processName: "next-server", | |
| 90 | pid: 67890, | |
| 91 | terminal: null, | |
| 92 | }, | |
| 93 | ]); | |
| 94 | }); | |
| 95 | ||
| 96 | it("handles empty input", () => { | |
| 97 | expect(parseLsofOutput("")).toEqual([]); | |
| 98 | }); | |
| 99 | ||
| 100 | it("dedupes by host:port", () => { | |
| 101 | const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n"); | |
| 102 | const servers = parseLsofOutput(sample); | |
| 103 | expect(servers).toHaveLength(1); | |
| 104 | expect(servers[0]?.port).toBe(5173); | |
| 105 | }); | |
| 106 | ||
| 107 | it("attributes listeners to a registered terminal process", () => { | |
| 108 | const servers = parseLsofOutput( | |
| 109 | ["p12345", "cnode", "n*:5173"].join("\n"), | |
| 110 | new Map([ | |
| 111 | [ | |
| 112 | 12345, | |
| 113 | { | |
| 114 | threadId: ThreadId.make("thread-1"), | |
| 115 | terminalId: "terminal-1", | |
| 116 | }, | |
| 117 | ], | |
| 118 | ]), | |
| 119 | ); | |
| 120 | ||
| 121 | expect(servers[0]?.terminal).toEqual({ | |
| 122 | threadId: "thread-1", | |
| 123 | terminalId: "terminal-1", | |
| 124 | }); | |
| 125 | }); | |
| 126 | }); | |
| 127 | ||
| 128 | describe("serversEqual", () => { | |
| 129 | const a = { | |
| 130 | host: "localhost", | |
| 131 | port: 5173, | |
| 132 | url: "http://localhost:5173", | |
| 133 | processName: "node", | |
| 134 | pid: 1, | |
| 135 | terminal: null, | |
| 136 | }; | |
| 137 | it("returns true for identical lists", () => { | |
| 138 | expect(serversEqual([a], [{ ...a }])).toBe(true); | |
| 139 | }); | |
| 140 | it("returns false for different lengths", () => { | |
| 141 | expect(serversEqual([a], [])).toBe(false); | |
| 142 | }); | |
| 143 | it("returns false for different processName", () => { | |
| 144 | expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false); | |
| 145 | }); | |
| 146 | }); | |
| 147 | ||
| 148 | describe("parseWindowsListenerOutput", () => { | |
| 149 | it("parses and attributes PowerShell listener records", () => { | |
| 150 | const servers = parseWindowsListenerOutput( | |
| 151 | "0.0.0.0|5173|12345|node", | |
| 152 | new Map([ | |
| 153 | [ | |
| 154 | 12345, | |
| 155 | { | |
| 156 | threadId: ThreadId.make("thread-1"), | |
| 157 | terminalId: "terminal-1", | |
| 158 | }, | |
| 159 | ], | |
| 160 | ]), | |
| 161 | ); | |
| 162 | ||
| 163 | expect(servers).toEqual([ | |
| 164 | { | |
| 165 | host: "localhost", | |
| 166 | port: 5173, | |
| 167 | url: "http://localhost:5173", | |
| 168 | processName: "node", | |
| 169 | pid: 12345, | |
| 170 | terminal: { | |
| 171 | threadId: "thread-1", | |
| 172 | terminalId: "terminal-1", | |
| 173 | }, | |
| 174 | }, | |
| 175 | ]); | |
| 176 | }); | |
| 177 | }); | |
| 178 | ||
| 179 | /** | |
| 180 | * Integration tests against a real TCP listener. We force the Windows code | |
| 181 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the | |
| 182 | * duration of the test so we don't depend on `lsof` being installed. | |
| 183 | */ | |
| 184 | describe("PortDiscovery integration (TCP probe fallback)", () => { | |
| 185 | let originalPlatform: NodeJS.Platform; | |
| 186 | let server: net.Server; | |
| 187 | let port: number; | |
| 188 | ||
| 189 | beforeEach(async () => { | |
| 190 | originalPlatform = process.platform; | |
| 191 | Object.defineProperty(process, "platform", { value: "win32", configurable: true }); | |
| 192 | for (const candidate of COMMON_DEV_PORTS) { | |
| 193 | const candidateServer = net.createServer(); | |
| 194 | const listening = await new Promise<boolean>((resolve) => { | |
| 195 | candidateServer.once("error", () => resolve(false)); | |
| 196 | candidateServer.listen(candidate, "127.0.0.1", () => resolve(true)); | |
| 197 | }); | |
| 198 | if (listening) { | |
| 199 | server = candidateServer; | |
| 200 | port = candidate; | |
| 201 | return; | |
| 202 | } | |
| 203 | candidateServer.close(); | |
| 204 | } | |
| 205 | throw new Error("No common development port was available for the preview scanner test"); | |
| 206 | }); | |
| 207 | ||
| 208 | afterEach(async () => { | |
| 209 | Object.defineProperty(process, "platform", { | |
| 210 | value: originalPlatform, | |
| 211 | configurable: true, | |
| 212 | }); | |
| 213 | await new Promise<void>((resolve) => server.close(() => resolve())); | |
| 214 | }); | |
| 215 | ||
| 216 | effectIt.effect("scan() returns a server we just opened on a curated dev port", () => | |
| 217 | Effect.gen(function* () { | |
| 218 | const scanner = yield* PortDiscovery; | |
| 219 | const result = yield* scanner.scan(); | |
| 220 | const found = result.find((server) => server.port === port); | |
| 221 | expect(found).toBeDefined(); | |
| 222 | expect(found?.host).toBe("localhost"); | |
| 223 | }).pipe(Effect.provide(TestPortDiscoveryLive)), | |
| 224 | ); | |
| 225 | ||
| 226 | effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { | |
| 227 | const received: number[] = []; | |
| 228 | return Effect.gen(function* () { | |
| 229 | const scanner = yield* PortDiscovery; | |
| 230 | const unsubscribe = yield* scanner.subscribe((servers) => | |
| 231 | Effect.sync(() => { | |
| 232 | for (const server of servers) received.push(server.port); | |
| 233 | }), | |
| 234 | ); | |
| 235 | const release = yield* scanner.retain(); | |
| 236 | unsubscribe(); | |
| 237 | release(); | |
| 238 | expect(received).toContain(port); | |
| 239 | }).pipe(Effect.provide(TestPortDiscoveryLive)); | |
| 240 | }); | |
| 241 | }); |
apps/server/src/preview/Layers/PortScanner.ts deleted-333| ... | ... | @@ -1,333 +0,0 @@ |
| 1 | /** | |
| 2 | * In-process PortScanner implementation. | |
| 3 | * | |
| 4 | * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a | |
| 5 | * stable line-prefixed field format; this is the only `lsof` flag set we rely | |
| 6 | * on). | |
| 7 | * | |
| 8 | * Windows / lsof missing: TCP-connects to a curated list of common dev ports | |
| 9 | * on 127.0.0.1. | |
| 10 | * | |
| 11 | * Polling is reference-counted via `retain()`. A single layer-scoped fiber | |
| 12 | * polls forever, but each tick is a no-op when the retain count is zero. | |
| 13 | */ | |
| 14 | import * as net from "node:net"; | |
| 15 | ||
| 16 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | |
| 18 | import { Cause, Duration, Effect, Layer, Ref, Schedule } from "effect"; | |
| 19 | ||
| 20 | import { ProcessRunner } from "../../processRunner.ts"; | |
| 21 | import { | |
| 22 | COMMON_DEV_PORTS, | |
| 23 | PortDiscovery, | |
| 24 | type PortDiscoveryShape, | |
| 25 | } from "../Services/PortScanner.ts"; | |
| 26 | ||
| 27 | const POLL_INTERVAL = Duration.seconds(3); | |
| 28 | const TCP_PROBE_TIMEOUT_MS = 200; | |
| 29 | const LSOF_TIMEOUT_MS = 5_000; | |
| 30 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; | |
| 31 | ||
| 32 | type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; | |
| 33 | ||
| 34 | interface ScannerState { | |
| 35 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; | |
| 36 | } | |
| 37 | ||
| 38 | interface TerminalProcessOwner { | |
| 39 | readonly threadId: ThreadId; | |
| 40 | readonly terminalId: string; | |
| 41 | } | |
| 42 | ||
| 43 | const terminalOwnerKey = (owner: { | |
| 44 | readonly threadId: string; | |
| 45 | readonly terminalId: string; | |
| 46 | }): string => `${owner.threadId}\u0000${owner.terminalId}`; | |
| 47 | ||
| 48 | const parseLsofOutput = ( | |
| 49 | raw: string, | |
| 50 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), | |
| 51 | ): ReadonlyArray<DiscoveredLocalServer> => { | |
| 52 | const seen = new Map<string, DiscoveredLocalServer>(); | |
| 53 | let pid: number | null = null; | |
| 54 | let processName: string | null = null; | |
| 55 | ||
| 56 | for (const line of raw.split("\n")) { | |
| 57 | if (line.length === 0) continue; | |
| 58 | const tag = line.charAt(0); | |
| 59 | const value = line.slice(1); | |
| 60 | if (tag === "p") { | |
| 61 | const parsed = Number.parseInt(value, 10); | |
| 62 | pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; | |
| 63 | processName = null; | |
| 64 | continue; | |
| 65 | } | |
| 66 | if (tag === "c") { | |
| 67 | processName = value.trim() || null; | |
| 68 | continue; | |
| 69 | } | |
| 70 | if (tag === "n") { | |
| 71 | const portMatch = parsePortFromLsofName(value); | |
| 72 | if (portMatch == null) continue; | |
| 73 | const url = `http://localhost:${portMatch}`; | |
| 74 | const key = `localhost:${portMatch}`; | |
| 75 | if (seen.has(key)) continue; | |
| 76 | seen.set(key, { | |
| 77 | host: "localhost", | |
| 78 | port: portMatch, | |
| 79 | url, | |
| 80 | processName, | |
| 81 | pid, | |
| 82 | terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), | |
| 83 | }); | |
| 84 | } | |
| 85 | } | |
| 86 | ||
| 87 | return Array.from(seen.values()).toSorted((a, b) => a.port - b.port); | |
| 88 | }; | |
| 89 | ||
| 90 | const parsePortFromLsofName = (name: string): number | null => { | |
| 91 | // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173", | |
| 92 | // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local. | |
| 93 | const trimmed = name.split(" ", 1)[0]?.trim() ?? ""; | |
| 94 | if (trimmed.length === 0) return null; | |
| 95 | const lastColon = trimmed.lastIndexOf(":"); | |
| 96 | if (lastColon < 0) return null; | |
| 97 | const hostPart = trimmed.slice(0, lastColon); | |
| 98 | const portPart = trimmed.slice(lastColon + 1); | |
| 99 | if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null; | |
| 100 | const port = Number.parseInt(portPart, 10); | |
| 101 | if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null; | |
| 102 | return port; | |
| 103 | }; | |
| 104 | ||
| 105 | const parseWindowsListenerOutput = ( | |
| 106 | raw: string, | |
| 107 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), | |
| 108 | ): ReadonlyArray<DiscoveredLocalServer> => { | |
| 109 | const seen = new Map<number, DiscoveredLocalServer>(); | |
| 110 | for (const line of raw.split(/\r?\n/g)) { | |
| 111 | const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); | |
| 112 | const host = hostRaw?.trim() ?? ""; | |
| 113 | if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; | |
| 114 | const port = Number(portRaw); | |
| 115 | const pid = Number(pidRaw); | |
| 116 | if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; | |
| 117 | const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; | |
| 118 | if (seen.has(port)) continue; | |
| 119 | seen.set(port, { | |
| 120 | host: "localhost", | |
| 121 | port, | |
| 122 | url: `http://localhost:${port}`, | |
| 123 | processName: processNameRaw?.trim() || null, | |
| 124 | pid: normalizedPid, | |
| 125 | terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), | |
| 126 | }); | |
| 127 | } | |
| 128 | return [...seen.values()].toSorted((left, right) => left.port - right.port); | |
| 129 | }; | |
| 130 | ||
| 131 | const probeTcpPort = (port: number): Promise<boolean> => | |
| 132 | new Promise((resolve) => { | |
| 133 | const socket = new net.Socket(); | |
| 134 | let settled = false; | |
| 135 | const finish = (ok: boolean) => { | |
| 136 | if (settled) return; | |
| 137 | settled = true; | |
| 138 | socket.destroy(); | |
| 139 | resolve(ok); | |
| 140 | }; | |
| 141 | socket.setTimeout(TCP_PROBE_TIMEOUT_MS); | |
| 142 | socket.once("timeout", () => finish(false)); | |
| 143 | socket.once("error", () => finish(false)); | |
| 144 | socket.once("connect", () => finish(true)); | |
| 145 | socket.connect({ host: "127.0.0.1", port }); | |
| 146 | }); | |
| 147 | ||
| 148 | const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => | |
| 149 | Effect.promise(async () => { | |
| 150 | const results = await Promise.all( | |
| 151 | COMMON_DEV_PORTS.map(async (port) => ({ port, listening: await probeTcpPort(port) })), | |
| 152 | ); | |
| 153 | return results | |
| 154 | .filter((r) => r.listening) | |
| 155 | .map<DiscoveredLocalServer>((r) => ({ | |
| 156 | host: "localhost", | |
| 157 | port: r.port, | |
| 158 | url: `http://localhost:${r.port}`, | |
| 159 | processName: null, | |
| 160 | pid: null, | |
| 161 | terminal: null, | |
| 162 | })); | |
| 163 | }); | |
| 164 | ||
| 165 | const serversEqual = ( | |
| 166 | left: ReadonlyArray<DiscoveredLocalServer>, | |
| 167 | right: ReadonlyArray<DiscoveredLocalServer>, | |
| 168 | ): boolean => { | |
| 169 | if (left.length !== right.length) return false; | |
| 170 | for (let i = 0; i < left.length; i += 1) { | |
| 171 | const a = left[i]; | |
| 172 | const b = right[i]; | |
| 173 | if (!a || !b) return false; | |
| 174 | if ( | |
| 175 | a.host !== b.host || | |
| 176 | a.port !== b.port || | |
| 177 | a.url !== b.url || | |
| 178 | a.processName !== b.processName || | |
| 179 | a.pid !== b.pid || | |
| 180 | a.terminal?.threadId !== b.terminal?.threadId || | |
| 181 | a.terminal?.terminalId !== b.terminal?.terminalId | |
| 182 | ) { | |
| 183 | return false; | |
| 184 | } | |
| 185 | } | |
| 186 | return true; | |
| 187 | }; | |
| 188 | ||
| 189 | export const makePortDiscovery = Effect.gen(function* () { | |
| 190 | const processRunner = yield* ProcessRunner; | |
| 191 | const stateRef = yield* Ref.make<ScannerState>({ | |
| 192 | lastSnapshot: [], | |
| 193 | }); | |
| 194 | const listeners = new Set<Listener>(); | |
| 195 | const terminalProcesses = new Map< | |
| 196 | string, | |
| 197 | { | |
| 198 | readonly owner: TerminalProcessOwner; | |
| 199 | readonly processIds: ReadonlySet<number>; | |
| 200 | } | |
| 201 | >(); | |
| 202 | // Plain integer because the release callback returned by `retain()` runs | |
| 203 | // outside any Effect context (the WS subscriber's release path) and must | |
| 204 | // be a synchronous side-effect-only function. | |
| 205 | let retainCount = 0; | |
| 206 | ||
| 207 | const scanOnce = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => | |
| 208 | Effect.gen(function* () { | |
| 209 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); | |
| 210 | for (const registration of terminalProcesses.values()) { | |
| 211 | for (const processId of registration.processIds) { | |
| 212 | terminalByProcessId.set(processId, registration.owner); | |
| 213 | } | |
| 214 | } | |
| 215 | if (process.platform === "win32") { | |
| 216 | const command = | |
| 217 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; | |
| 218 | const listeners = yield* processRunner | |
| 219 | .run({ | |
| 220 | command: "powershell.exe", | |
| 221 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 222 | timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), | |
| 223 | maxOutputBytes: 1024 * 1024, | |
| 224 | outputMode: "truncate", | |
| 225 | }) | |
| 226 | .pipe( | |
| 227 | Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), | |
| 228 | Effect.catchCause(() => Effect.succeed(null)), | |
| 229 | ); | |
| 230 | if (listeners !== null) return listeners; | |
| 231 | return yield* probeCommonPorts(); | |
| 232 | } | |
| 233 | const lsofResult = yield* processRunner | |
| 234 | .run({ | |
| 235 | command: "lsof", | |
| 236 | args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], | |
| 237 | timeout: Duration.millis(LSOF_TIMEOUT_MS), | |
| 238 | maxOutputBytes: 1024 * 1024, | |
| 239 | outputMode: "truncate", | |
| 240 | }) | |
| 241 | .pipe( | |
| 242 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), | |
| 243 | Effect.catchCause(() => Effect.succeed(null)), | |
| 244 | ); | |
| 245 | if (lsofResult !== null) return lsofResult; | |
| 246 | return yield* probeCommonPorts(); | |
| 247 | }); | |
| 248 | ||
| 249 | const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => | |
| 250 | Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); | |
| 251 | ||
| 252 | const pollTick = Effect.gen(function* () { | |
| 253 | if (retainCount <= 0) return; | |
| 254 | const next = yield* scanOnce(); | |
| 255 | const state = yield* Ref.get(stateRef); | |
| 256 | if (serversEqual(state.lastSnapshot, next)) return; | |
| 257 | yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); | |
| 258 | yield* broadcast(next); | |
| 259 | }).pipe( | |
| 260 | Effect.catchCause((cause: Cause.Cause<never>) => | |
| 261 | Effect.logWarning("preview port scan failed", Cause.pretty(cause)), | |
| 262 | ), | |
| 263 | ); | |
| 264 | ||
| 265 | // Single layer-scoped polling fiber. Ticks are no-ops when no client is | |
| 266 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. | |
| 267 | yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); | |
| 268 | ||
| 269 | const retain: PortDiscoveryShape["retain"] = () => | |
| 270 | Effect.gen(function* () { | |
| 271 | const wasIdle = retainCount === 0; | |
| 272 | retainCount += 1; | |
| 273 | if (wasIdle) { | |
| 274 | // Run an immediate scan + broadcast so the new retainer doesn't have | |
| 275 | // to wait up to POLL_INTERVAL for the first emission. | |
| 276 | yield* pollTick; | |
| 277 | } | |
| 278 | let released = false; | |
| 279 | return () => { | |
| 280 | if (released) return; | |
| 281 | released = true; | |
| 282 | retainCount = Math.max(0, retainCount - 1); | |
| 283 | }; | |
| 284 | }); | |
| 285 | ||
| 286 | const subscribe: PortDiscoveryShape["subscribe"] = (listener) => | |
| 287 | Effect.sync(() => { | |
| 288 | listeners.add(listener); | |
| 289 | return () => { | |
| 290 | listeners.delete(listener); | |
| 291 | }; | |
| 292 | }); | |
| 293 | ||
| 294 | const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = (input) => | |
| 295 | Effect.sync(() => { | |
| 296 | const owner = { | |
| 297 | threadId: ThreadId.make(input.threadId), | |
| 298 | terminalId: input.terminalId, | |
| 299 | }; | |
| 300 | const processIds = new Set( | |
| 301 | input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), | |
| 302 | ); | |
| 303 | const key = terminalOwnerKey(owner); | |
| 304 | if (processIds.size === 0) { | |
| 305 | terminalProcesses.delete(key); | |
| 306 | return; | |
| 307 | } | |
| 308 | terminalProcesses.set(key, { owner, processIds }); | |
| 309 | }); | |
| 310 | ||
| 311 | const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = (input) => | |
| 312 | Effect.sync(() => { | |
| 313 | terminalProcesses.delete(terminalOwnerKey(input)); | |
| 314 | }); | |
| 315 | ||
| 316 | return { | |
| 317 | scan: scanOnce, | |
| 318 | subscribe, | |
| 319 | retain, | |
| 320 | registerTerminalProcesses, | |
| 321 | unregisterTerminal, | |
| 322 | } satisfies PortDiscoveryShape; | |
| 323 | }); | |
| 324 | ||
| 325 | export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery); | |
| 326 | ||
| 327 | /** Exposed for tests. */ | |
| 328 | export const __testing = { | |
| 329 | parseLsofOutput, | |
| 330 | parsePortFromLsofName, | |
| 331 | parseWindowsListenerOutput, | |
| 332 | serversEqual, | |
| 333 | }; |
apps/server/src/preview/Manager.test.ts created+260| ... | ... | @@ -0,0 +1,260 @@ |
| 1 | import { it } from "@effect/vitest"; | |
| 2 | import { type PreviewEvent, ThreadId } from "@t3tools/contracts"; | |
| 3 | import { Effect, PubSub } from "effect"; | |
| 4 | import { expect } from "vite-plus/test"; | |
| 5 | ||
| 6 | import * as PreviewManager from "./Manager.ts"; | |
| 7 | ||
| 8 | const DRAIN_LIMIT = 100; | |
| 9 | ||
| 10 | interface EventCollector { | |
| 11 | /** Drain everything published since the last call (or since subscribe). */ | |
| 12 | readonly drain: Effect.Effect<ReadonlyArray<PreviewEvent>>; | |
| 13 | } | |
| 14 | ||
| 15 | /** | |
| 16 | * Each `it.effect` shares the live PreviewManager layer across the whole | |
| 17 | * `it.layer` block, so tests that assert per-thread counts must use a unique | |
| 18 | * thread id to avoid bleeding state from earlier tests. | |
| 19 | */ | |
| 20 | let nextThreadId = 0; | |
| 21 | const freshThreadId = () => ThreadId.make(`thread-${++nextThreadId}`); | |
| 22 | ||
| 23 | /** | |
| 24 | * Subscribe to the manager's event stream BEFORE the test publishes. We | |
| 25 | * use `subscribeEvents` (synchronous PubSub.subscribe under the hood) so | |
| 26 | * no event can land between subscribe and the consumer drain. | |
| 27 | */ | |
| 28 | const collectEvents = Effect.gen(function* () { | |
| 29 | const manager = yield* PreviewManager.PreviewManager; | |
| 30 | const subscription = yield* manager.subscribeEvents; | |
| 31 | const collector: EventCollector = { | |
| 32 | drain: PubSub.takeUpTo(subscription, DRAIN_LIMIT), | |
| 33 | }; | |
| 34 | return collector; | |
| 35 | }).pipe(Effect.withSpan("preview.test.collectEvents")); | |
| 36 | ||
| 37 | it.layer(PreviewManager.layer)("PreviewManager", (it) => { | |
| 38 | it.effect("opens a session and emits opened with normalized URL", () => | |
| 39 | Effect.gen(function* () { | |
| 40 | const threadId = freshThreadId(); | |
| 41 | const manager = yield* PreviewManager.PreviewManager; | |
| 42 | const collector = yield* collectEvents; | |
| 43 | ||
| 44 | const snapshot = yield* manager.open({ threadId, url: "localhost:5173" }); | |
| 45 | expect(snapshot.tabId.startsWith("tab_")).toBe(true); | |
| 46 | expect(snapshot.navStatus._tag).toBe("Loading"); | |
| 47 | if (snapshot.navStatus._tag === "Loading") { | |
| 48 | expect(snapshot.navStatus.url).toBe("http://localhost:5173/"); | |
| 49 | } | |
| 50 | ||
| 51 | const events = yield* collector.drain; | |
| 52 | expect(events).toHaveLength(1); | |
| 53 | expect(events[0]?.type).toBe("opened"); | |
| 54 | if (events[0]?.type === "opened") { | |
| 55 | expect(events[0].tabId).toBe(snapshot.tabId); | |
| 56 | } | |
| 57 | }), | |
| 58 | ); | |
| 59 | ||
| 60 | it.effect("opens an Idle tab when no URL is supplied", () => | |
| 61 | Effect.gen(function* () { | |
| 62 | const threadId = freshThreadId(); | |
| 63 | const manager = yield* PreviewManager.PreviewManager; | |
| 64 | const snapshot = yield* manager.open({ threadId }); | |
| 65 | expect(snapshot.navStatus._tag).toBe("Idle"); | |
| 66 | }), | |
| 67 | ); | |
| 68 | ||
| 69 | it.effect("treats bare hosts as https", () => | |
| 70 | Effect.gen(function* () { | |
| 71 | const threadId = freshThreadId(); | |
| 72 | const manager = yield* PreviewManager.PreviewManager; | |
| 73 | const snapshot = yield* manager.open({ threadId, url: "example.com" }); | |
| 74 | if (snapshot.navStatus._tag === "Loading") { | |
| 75 | expect(snapshot.navStatus.url).toBe("https://example.com/"); | |
| 76 | } | |
| 77 | }), | |
| 78 | ); | |
| 79 | ||
| 80 | it.effect("rejects empty URL with PreviewInvalidUrlError", () => | |
| 81 | Effect.gen(function* () { | |
| 82 | const threadId = freshThreadId(); | |
| 83 | const manager = yield* PreviewManager.PreviewManager; | |
| 84 | const error = yield* Effect.flip(manager.open({ threadId, url: " " })); | |
| 85 | expect(error._tag).toBe("PreviewInvalidUrlError"); | |
| 86 | }), | |
| 87 | ); | |
| 88 | ||
| 89 | it.effect("navigate updates snapshot and emits navigated", () => | |
| 90 | Effect.gen(function* () { | |
| 91 | const threadId = freshThreadId(); | |
| 92 | const manager = yield* PreviewManager.PreviewManager; | |
| 93 | const collector = yield* collectEvents; | |
| 94 | ||
| 95 | const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 96 | const snapshot = yield* manager.navigate({ | |
| 97 | threadId, | |
| 98 | tabId: opened.tabId, | |
| 99 | url: "http://localhost:5173/about", | |
| 100 | resolvedTitle: "About", | |
| 101 | }); | |
| 102 | ||
| 103 | expect(snapshot.navStatus._tag).toBe("Success"); | |
| 104 | if (snapshot.navStatus._tag === "Success") { | |
| 105 | expect(snapshot.navStatus.url).toBe("http://localhost:5173/about"); | |
| 106 | expect(snapshot.navStatus.title).toBe("About"); | |
| 107 | } | |
| 108 | const events = yield* collector.drain; | |
| 109 | expect(events.map((e) => e.type)).toEqual(["opened", "navigated"]); | |
| 110 | }), | |
| 111 | ); | |
| 112 | ||
| 113 | it.effect("navigate fails for unknown tab", () => | |
| 114 | Effect.gen(function* () { | |
| 115 | const threadId = freshThreadId(); | |
| 116 | const manager = yield* PreviewManager.PreviewManager; | |
| 117 | const error = yield* Effect.flip( | |
| 118 | manager.navigate({ | |
| 119 | threadId, | |
| 120 | tabId: "tab_missing", | |
| 121 | url: "http://localhost:5173", | |
| 122 | }), | |
| 123 | ); | |
| 124 | expect(error._tag).toBe("PreviewSessionLookupError"); | |
| 125 | }), | |
| 126 | ); | |
| 127 | ||
| 128 | it.effect("reportStatus emits failed for LoadFailed nav", () => | |
| 129 | Effect.gen(function* () { | |
| 130 | const threadId = freshThreadId(); | |
| 131 | const manager = yield* PreviewManager.PreviewManager; | |
| 132 | const collector = yield* collectEvents; | |
| 133 | ||
| 134 | const opened = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 135 | yield* manager.reportStatus({ | |
| 136 | threadId, | |
| 137 | tabId: opened.tabId, | |
| 138 | navStatus: { | |
| 139 | _tag: "LoadFailed", | |
| 140 | url: "http://localhost:5173", | |
| 141 | title: "", | |
| 142 | code: -105, | |
| 143 | description: "ERR_NAME_NOT_RESOLVED", | |
| 144 | }, | |
| 145 | canGoBack: false, | |
| 146 | canGoForward: false, | |
| 147 | }); | |
| 148 | ||
| 149 | const events = yield* collector.drain; | |
| 150 | const failed = events.find((e) => e.type === "failed"); | |
| 151 | expect(failed?.type).toBe("failed"); | |
| 152 | if (failed?.type === "failed") { | |
| 153 | expect(failed.code).toBe(-105); | |
| 154 | expect(failed.description).toBe("ERR_NAME_NOT_RESOLVED"); | |
| 155 | } | |
| 156 | }), | |
| 157 | ); | |
| 158 | ||
| 159 | it.effect("close removes the session and emits closed", () => | |
| 160 | Effect.gen(function* () { | |
| 161 | const threadId = freshThreadId(); | |
| 162 | const manager = yield* PreviewManager.PreviewManager; | |
| 163 | const collector = yield* collectEvents; | |
| 164 | ||
| 165 | yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 166 | yield* manager.close({ threadId }); | |
| 167 | ||
| 168 | const result = yield* manager.list({ threadId }); | |
| 169 | expect(result.sessions).toHaveLength(0); | |
| 170 | const events = yield* collector.drain; | |
| 171 | const closed = events.find((e) => e.type === "closed"); | |
| 172 | expect(closed?.type).toBe("closed"); | |
| 173 | }), | |
| 174 | ); | |
| 175 | ||
| 176 | it.effect("close is idempotent for unknown threads", () => | |
| 177 | Effect.gen(function* () { | |
| 178 | const threadId = freshThreadId(); | |
| 179 | const manager = yield* PreviewManager.PreviewManager; | |
| 180 | yield* manager.close({ threadId }); | |
| 181 | const result = yield* manager.list({ threadId }); | |
| 182 | expect(result.sessions).toHaveLength(0); | |
| 183 | }), | |
| 184 | ); | |
| 185 | ||
| 186 | it.effect("list returns every snapshot for the thread sorted by updatedAt", () => | |
| 187 | Effect.gen(function* () { | |
| 188 | const threadId = freshThreadId(); | |
| 189 | const manager = yield* PreviewManager.PreviewManager; | |
| 190 | const first = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 191 | const second = yield* manager.open({ threadId, url: "http://localhost:3000" }); | |
| 192 | const result = yield* manager.list({ threadId }); | |
| 193 | expect(result.sessions).toHaveLength(2); | |
| 194 | const ids = result.sessions.map((s) => s.tabId); | |
| 195 | expect(ids).toContain(first.tabId); | |
| 196 | expect(ids).toContain(second.tabId); | |
| 197 | }), | |
| 198 | ); | |
| 199 | ||
| 200 | it.effect("open creates an independent tab on every call", () => | |
| 201 | Effect.gen(function* () { | |
| 202 | const threadId = freshThreadId(); | |
| 203 | const manager = yield* PreviewManager.PreviewManager; | |
| 204 | const collector = yield* collectEvents; | |
| 205 | ||
| 206 | const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 207 | const b = yield* manager.open({ threadId, url: "http://localhost:3000/path" }); | |
| 208 | ||
| 209 | expect(a.tabId).not.toBe(b.tabId); | |
| 210 | const list = yield* manager.list({ threadId }); | |
| 211 | expect(list.sessions).toHaveLength(2); | |
| 212 | ||
| 213 | const events = yield* collector.drain; | |
| 214 | expect(events.map((e) => e.type)).toEqual(["opened", "opened"]); | |
| 215 | }), | |
| 216 | ); | |
| 217 | ||
| 218 | it.effect("close with mismatching tabId is a no-op", () => | |
| 219 | Effect.gen(function* () { | |
| 220 | const threadId = freshThreadId(); | |
| 221 | const manager = yield* PreviewManager.PreviewManager; | |
| 222 | yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 223 | yield* manager.close({ threadId, tabId: "tab_missing" }); | |
| 224 | ||
| 225 | const list = yield* manager.list({ threadId }); | |
| 226 | expect(list.sessions).toHaveLength(1); | |
| 227 | }), | |
| 228 | ); | |
| 229 | ||
| 230 | it.effect("close with explicit tabId removes only that tab", () => | |
| 231 | Effect.gen(function* () { | |
| 232 | const threadId = freshThreadId(); | |
| 233 | const manager = yield* PreviewManager.PreviewManager; | |
| 234 | const a = yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 235 | const b = yield* manager.open({ threadId, url: "http://localhost:3000" }); | |
| 236 | ||
| 237 | yield* manager.close({ threadId, tabId: a.tabId }); | |
| 238 | ||
| 239 | const list = yield* manager.list({ threadId }); | |
| 240 | expect(list.sessions.map((s) => s.tabId)).toEqual([b.tabId]); | |
| 241 | }), | |
| 242 | ); | |
| 243 | ||
| 244 | it.effect("multiple subscribers receive every event independently", () => | |
| 245 | Effect.gen(function* () { | |
| 246 | const threadId = freshThreadId(); | |
| 247 | const manager = yield* PreviewManager.PreviewManager; | |
| 248 | const aSub = yield* manager.subscribeEvents; | |
| 249 | const bSub = yield* manager.subscribeEvents; | |
| 250 | ||
| 251 | yield* manager.open({ threadId, url: "http://localhost:5173" }); | |
| 252 | yield* manager.open({ threadId, url: "http://localhost:3000" }); | |
| 253 | ||
| 254 | const aEvents = yield* PubSub.takeUpTo(aSub, DRAIN_LIMIT); | |
| 255 | const bEvents = yield* PubSub.takeUpTo(bSub, DRAIN_LIMIT); | |
| 256 | expect(aEvents.map((e) => e.type)).toEqual(["opened", "opened"]); | |
| 257 | expect(bEvents.map((e) => e.type)).toEqual(["opened", "opened"]); | |
| 258 | }), | |
| 259 | ); | |
| 260 | }); |
apps/server/src/preview/Manager.ts created+362| ... | ... | @@ -0,0 +1,362 @@ |
| 1 | /** | |
| 2 | * In-memory PreviewManager implementation. | |
| 3 | * | |
| 4 | * Sessions are keyed by `(threadId, tabId)`; a single thread can host | |
| 5 | * multiple tabs (browser-style). `open` always creates a new tab — tab | |
| 6 | * lifecycle is owned by the renderer. | |
| 7 | * | |
| 8 | * Events are published via Effect's `PubSub`, so subscriber failures are | |
| 9 | * isolated from the publishing call (a closed WS subscriber queue cannot | |
| 10 | * fail an in-progress `navigate()`). | |
| 11 | */ | |
| 12 | import { | |
| 13 | type PreviewCloseInput, | |
| 14 | type PreviewEvent, | |
| 15 | type PreviewError, | |
| 16 | PreviewInvalidUrlError, | |
| 17 | type PreviewListInput, | |
| 18 | type PreviewListResult, | |
| 19 | type PreviewNavigateInput, | |
| 20 | type PreviewOpenInput, | |
| 21 | type PreviewRefreshInput, | |
| 22 | type PreviewReportStatusInput, | |
| 23 | PreviewSessionLookupError, | |
| 24 | type PreviewSessionSnapshot, | |
| 25 | } from "@t3tools/contracts"; | |
| 26 | import { | |
| 27 | newPreviewTabId, | |
| 28 | normalizePreviewUrl, | |
| 29 | PreviewUrlNormalizationError, | |
| 30 | } from "@t3tools/shared/preview"; | |
| 31 | import { | |
| 32 | Context, | |
| 33 | DateTime, | |
| 34 | Effect, | |
| 35 | Layer, | |
| 36 | PubSub, | |
| 37 | type Scope, | |
| 38 | Stream, | |
| 39 | SynchronizedRef, | |
| 40 | } from "effect"; | |
| 41 | ||
| 42 | export interface PreviewManagerShape { | |
| 43 | readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 44 | readonly navigate: ( | |
| 45 | input: PreviewNavigateInput, | |
| 46 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 47 | readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; | |
| 48 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; | |
| 49 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; | |
| 50 | readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; | |
| 51 | readonly events: Stream.Stream<PreviewEvent>; | |
| 52 | readonly subscribeEvents: Effect.Effect<PubSub.Subscription<PreviewEvent>, never, Scope.Scope>; | |
| 53 | } | |
| 54 | ||
| 55 | export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( | |
| 56 | "t3/preview/Manager/PreviewManager", | |
| 57 | ) {} | |
| 58 | ||
| 59 | interface PreviewSessionState { | |
| 60 | readonly threadId: string; | |
| 61 | readonly tabId: string; | |
| 62 | readonly snapshot: PreviewSessionSnapshot; | |
| 63 | } | |
| 64 | ||
| 65 | interface ManagerState { | |
| 66 | /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */ | |
| 67 | readonly sessions: ReadonlyMap<string, PreviewSessionState>; | |
| 68 | } | |
| 69 | ||
| 70 | const initialState: ManagerState = { sessions: new Map() }; | |
| 71 | ||
| 72 | const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`; | |
| 73 | ||
| 74 | const sessionsForThread = ( | |
| 75 | state: ManagerState, | |
| 76 | threadId: string, | |
| 77 | ): ReadonlyArray<PreviewSessionState> => { | |
| 78 | const out: PreviewSessionState[] = []; | |
| 79 | for (const session of state.sessions.values()) { | |
| 80 | if (session.threadId === threadId) out.push(session); | |
| 81 | } | |
| 82 | return out; | |
| 83 | }; | |
| 84 | ||
| 85 | const normalizeUrl = (rawUrl: string): Effect.Effect<string, PreviewInvalidUrlError> => | |
| 86 | Effect.try({ | |
| 87 | try: () => normalizePreviewUrl(rawUrl), | |
| 88 | catch: (cause) => | |
| 89 | new PreviewInvalidUrlError({ | |
| 90 | rawUrl, | |
| 91 | detail: | |
| 92 | cause instanceof PreviewUrlNormalizationError | |
| 93 | ? cause.detail | |
| 94 | : cause instanceof Error | |
| 95 | ? cause.message | |
| 96 | : String(cause), | |
| 97 | }), | |
| 98 | }); | |
| 99 | ||
| 100 | const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso)); | |
| 101 | ||
| 102 | const buildLoadingSnapshot = (input: { | |
| 103 | readonly threadId: string; | |
| 104 | readonly tabId: string; | |
| 105 | readonly url: string; | |
| 106 | readonly title: string; | |
| 107 | readonly updatedAt: string; | |
| 108 | }): PreviewSessionSnapshot => ({ | |
| 109 | threadId: input.threadId, | |
| 110 | tabId: input.tabId, | |
| 111 | navStatus: { _tag: "Loading", url: input.url, title: input.title }, | |
| 112 | canGoBack: false, | |
| 113 | canGoForward: false, | |
| 114 | updatedAt: input.updatedAt, | |
| 115 | }); | |
| 116 | ||
| 117 | const buildIdleSnapshot = (input: { | |
| 118 | readonly threadId: string; | |
| 119 | readonly tabId: string; | |
| 120 | readonly updatedAt: string; | |
| 121 | }): PreviewSessionSnapshot => ({ | |
| 122 | threadId: input.threadId, | |
| 123 | tabId: input.tabId, | |
| 124 | navStatus: { _tag: "Idle" }, | |
| 125 | canGoBack: false, | |
| 126 | canGoForward: false, | |
| 127 | updatedAt: input.updatedAt, | |
| 128 | }); | |
| 129 | ||
| 130 | const make = Effect.gen(function* () { | |
| 131 | const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState); | |
| 132 | // Unbounded PubSub is fine here — events are tiny and we don't want to | |
| 133 | // block publishers if a subscriber is slow. WS clients backpressure on | |
| 134 | // their own queues downstream. | |
| 135 | const eventsPubSub = yield* PubSub.unbounded<PreviewEvent>(); | |
| 136 | const events: Stream.Stream<PreviewEvent> = Stream.fromPubSub(eventsPubSub); | |
| 137 | ||
| 138 | /** | |
| 139 | * Atomic read-modify-write over the session for `(threadId, tabId)`. The | |
| 140 | * mutator runs under the SynchronizedRef so concurrent writers cannot | |
| 141 | * interleave. Lookup failures travel through the modify result so both | |
| 142 | * branches yield the same `[A, S]` shape `modifyEffect` requires. | |
| 143 | * | |
| 144 | * The event is published INSIDE the lock so observers see events in the | |
| 145 | * same order as the underlying state transitions. Publishing an unbounded | |
| 146 | * PubSub is non-blocking, so this is cheap. | |
| 147 | */ | |
| 148 | const mutateExistingSession = <R, E>( | |
| 149 | threadId: string, | |
| 150 | tabId: string, | |
| 151 | mutator: ( | |
| 152 | session: PreviewSessionState, | |
| 153 | ) => Effect.Effect<{ next: PreviewSessionState; emit: PreviewEvent | null; result: R }, E>, | |
| 154 | ): Effect.Effect<R, E | PreviewSessionLookupError> => { | |
| 155 | type ModifyResult = | |
| 156 | | { kind: "fail"; error: PreviewSessionLookupError } | |
| 157 | | { kind: "ok"; result: R }; | |
| 158 | ||
| 159 | return SynchronizedRef.modifyEffect(stateRef, (state) => { | |
| 160 | const session = state.sessions.get(compositeKey(threadId, tabId)); | |
| 161 | if (!session) { | |
| 162 | return Effect.succeed([ | |
| 163 | { kind: "fail", error: new PreviewSessionLookupError({ threadId, tabId }) }, | |
| 164 | state, | |
| 165 | ] as readonly [ModifyResult, ManagerState]); | |
| 166 | } | |
| 167 | return mutator(session).pipe( | |
| 168 | Effect.flatMap( | |
| 169 | Effect.fn("PreviewManager.commitMutation")(function* ({ next, emit, result }) { | |
| 170 | if (emit) yield* PubSub.publish(eventsPubSub, emit); | |
| 171 | const sessions = new Map(state.sessions); | |
| 172 | sessions.set(compositeKey(threadId, tabId), next); | |
| 173 | return [{ kind: "ok", result } as ModifyResult, { sessions }] as readonly [ | |
| 174 | ModifyResult, | |
| 175 | ManagerState, | |
| 176 | ]; | |
| 177 | }), | |
| 178 | ), | |
| 179 | ); | |
| 180 | }).pipe( | |
| 181 | Effect.flatMap((modify) => | |
| 182 | modify.kind === "fail" ? Effect.fail(modify.error) : Effect.succeed(modify.result), | |
| 183 | ), | |
| 184 | ); | |
| 185 | }; | |
| 186 | ||
| 187 | const open: PreviewManagerShape["open"] = Effect.fn("PreviewManager.open")(function* (input) { | |
| 188 | const tabId = newPreviewTabId(); | |
| 189 | const updatedAt = yield* currentIsoTimestamp; | |
| 190 | const snapshot = input.url | |
| 191 | ? buildLoadingSnapshot({ | |
| 192 | threadId: input.threadId, | |
| 193 | tabId, | |
| 194 | url: yield* normalizeUrl(input.url), | |
| 195 | title: "", | |
| 196 | updatedAt, | |
| 197 | }) | |
| 198 | : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt }); | |
| 199 | yield* SynchronizedRef.update(stateRef, (state) => { | |
| 200 | const sessions = new Map(state.sessions); | |
| 201 | sessions.set(compositeKey(input.threadId, tabId), { | |
| 202 | threadId: input.threadId, | |
| 203 | tabId, | |
| 204 | snapshot, | |
| 205 | }); | |
| 206 | return { sessions }; | |
| 207 | }); | |
| 208 | yield* PubSub.publish(eventsPubSub, { | |
| 209 | type: "opened", | |
| 210 | threadId: input.threadId, | |
| 211 | tabId, | |
| 212 | createdAt: snapshot.updatedAt, | |
| 213 | snapshot, | |
| 214 | }); | |
| 215 | return snapshot; | |
| 216 | }); | |
| 217 | ||
| 218 | const navigate: PreviewManagerShape["navigate"] = Effect.fn("PreviewManager.navigate")( | |
| 219 | function* (input) { | |
| 220 | const url = yield* normalizeUrl(input.url); | |
| 221 | return yield* mutateExistingSession( | |
| 222 | input.threadId, | |
| 223 | input.tabId, | |
| 224 | Effect.fn("PreviewManager.navigateSession")(function* (session) { | |
| 225 | const updatedAt = yield* currentIsoTimestamp; | |
| 226 | const previousTitle = | |
| 227 | session.snapshot.navStatus._tag === "Idle" ? "" : session.snapshot.navStatus.title; | |
| 228 | const resolvedTitle = input.resolvedTitle ?? previousTitle; | |
| 229 | const snapshot: PreviewSessionSnapshot = { | |
| 230 | threadId: session.threadId, | |
| 231 | tabId: session.tabId, | |
| 232 | navStatus: { _tag: "Success", url, title: resolvedTitle }, | |
| 233 | canGoBack: session.snapshot.canGoBack, | |
| 234 | canGoForward: session.snapshot.canGoForward, | |
| 235 | updatedAt, | |
| 236 | }; | |
| 237 | return { | |
| 238 | next: { ...session, snapshot }, | |
| 239 | emit: { | |
| 240 | type: "navigated", | |
| 241 | threadId: session.threadId, | |
| 242 | tabId: session.tabId, | |
| 243 | createdAt: snapshot.updatedAt, | |
| 244 | snapshot, | |
| 245 | }, | |
| 246 | result: snapshot, | |
| 247 | }; | |
| 248 | }), | |
| 249 | ); | |
| 250 | }, | |
| 251 | ); | |
| 252 | ||
| 253 | const reportStatus: PreviewManagerShape["reportStatus"] = Effect.fn( | |
| 254 | "PreviewManager.reportStatus", | |
| 255 | )(function* (input) { | |
| 256 | yield* mutateExistingSession( | |
| 257 | input.threadId, | |
| 258 | input.tabId, | |
| 259 | Effect.fn("PreviewManager.reportSessionStatus")(function* (session) { | |
| 260 | const updatedAt = yield* currentIsoTimestamp; | |
| 261 | const snapshot: PreviewSessionSnapshot = { | |
| 262 | threadId: session.threadId, | |
| 263 | tabId: session.tabId, | |
| 264 | navStatus: input.navStatus, | |
| 265 | canGoBack: input.canGoBack, | |
| 266 | canGoForward: input.canGoForward, | |
| 267 | updatedAt, | |
| 268 | }; | |
| 269 | const emit: PreviewEvent = | |
| 270 | input.navStatus._tag === "LoadFailed" | |
| 271 | ? { | |
| 272 | type: "failed", | |
| 273 | threadId: session.threadId, | |
| 274 | tabId: session.tabId, | |
| 275 | createdAt: snapshot.updatedAt, | |
| 276 | url: input.navStatus.url, | |
| 277 | title: input.navStatus.title, | |
| 278 | code: input.navStatus.code, | |
| 279 | description: input.navStatus.description, | |
| 280 | } | |
| 281 | : { | |
| 282 | type: "navigated", | |
| 283 | threadId: session.threadId, | |
| 284 | tabId: session.tabId, | |
| 285 | createdAt: snapshot.updatedAt, | |
| 286 | snapshot, | |
| 287 | }; | |
| 288 | return { | |
| 289 | next: { ...session, snapshot }, | |
| 290 | emit, | |
| 291 | result: undefined as void, | |
| 292 | }; | |
| 293 | }), | |
| 294 | ); | |
| 295 | }); | |
| 296 | ||
| 297 | const refresh: PreviewManagerShape["refresh"] = Effect.fn("PreviewManager.refresh")( | |
| 298 | function* (input) { | |
| 299 | // Verify the session exists; the desktop bridge handles the actual reload | |
| 300 | // and will report progress back via `reportStatus`. No event emitted. | |
| 301 | yield* mutateExistingSession(input.threadId, input.tabId, (session) => | |
| 302 | Effect.succeed({ next: session, emit: null, result: undefined as void }), | |
| 303 | ); | |
| 304 | }, | |
| 305 | ); | |
| 306 | ||
| 307 | const close: PreviewManagerShape["close"] = Effect.fn("PreviewManager.close")(function* (input) { | |
| 308 | const createdAt = yield* currentIsoTimestamp; | |
| 309 | const events = yield* SynchronizedRef.modify(stateRef, (state) => { | |
| 310 | const eventsToEmit: PreviewEvent[] = []; | |
| 311 | const sessions = new Map(state.sessions); | |
| 312 | const targets = input.tabId | |
| 313 | ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter( | |
| 314 | (entry): entry is PreviewSessionState => entry !== undefined, | |
| 315 | ) | |
| 316 | : sessionsForThread(state, input.threadId); | |
| 317 | for (const target of targets) { | |
| 318 | sessions.delete(compositeKey(target.threadId, target.tabId)); | |
| 319 | eventsToEmit.push({ | |
| 320 | type: "closed", | |
| 321 | threadId: target.threadId, | |
| 322 | tabId: target.tabId, | |
| 323 | createdAt, | |
| 324 | }); | |
| 325 | } | |
| 326 | if (eventsToEmit.length === 0) { | |
| 327 | return [eventsToEmit, state] as const; | |
| 328 | } | |
| 329 | return [eventsToEmit, { sessions }] as const; | |
| 330 | }); | |
| 331 | if (events.length > 0) { | |
| 332 | yield* Effect.forEach(events, (event) => PubSub.publish(eventsPubSub, event), { | |
| 333 | discard: true, | |
| 334 | }); | |
| 335 | } | |
| 336 | }); | |
| 337 | ||
| 338 | const list: PreviewManagerShape["list"] = Effect.fn("PreviewManager.list")(function* (input) { | |
| 339 | return yield* SynchronizedRef.get(stateRef).pipe( | |
| 340 | Effect.map( | |
| 341 | (state): PreviewListResult => ({ | |
| 342 | sessions: sessionsForThread(state, input.threadId) | |
| 343 | .map((s) => s.snapshot) | |
| 344 | .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)), | |
| 345 | }), | |
| 346 | ), | |
| 347 | ); | |
| 348 | }); | |
| 349 | ||
| 350 | return { | |
| 351 | open, | |
| 352 | navigate, | |
| 353 | reportStatus, | |
| 354 | refresh, | |
| 355 | close, | |
| 356 | list, | |
| 357 | events, | |
| 358 | subscribeEvents: PubSub.subscribe(eventsPubSub), | |
| 359 | } satisfies PreviewManagerShape; | |
| 360 | }); | |
| 361 | ||
| 362 | export const layer = Layer.effect(PreviewManager, make); |
apps/server/src/preview/PortScanner.test.ts created+240| ... | ... | @@ -0,0 +1,240 @@ |
| 1 | import * as net from "node:net"; | |
| 2 | ||
| 3 | import { it as effectIt } from "@effect/vitest"; | |
| 4 | import { ThreadId } from "@t3tools/contracts"; | |
| 5 | import { Effect, Layer } from "effect"; | |
| 6 | import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test"; | |
| 7 | ||
| 8 | import { ProcessRunner } from "../processRunner.ts"; | |
| 9 | import * as PortScanner from "./PortScanner.ts"; | |
| 10 | ||
| 11 | const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } = | |
| 12 | PortScanner.__testing; | |
| 13 | const TestProcessRunner = Layer.succeed(ProcessRunner, { | |
| 14 | run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"), | |
| 15 | }); | |
| 16 | const TestPortDiscoveryLive = PortScanner.layer.pipe(Layer.provide(TestProcessRunner)); | |
| 17 | ||
| 18 | describe("parsePortFromLsofName", () => { | |
| 19 | it("parses *:port", () => { | |
| 20 | expect(parsePortFromLsofName("*:5173")).toBe(5173); | |
| 21 | }); | |
| 22 | ||
| 23 | it("parses 127.0.0.1:port", () => { | |
| 24 | expect(parsePortFromLsofName("127.0.0.1:5173")).toBe(5173); | |
| 25 | }); | |
| 26 | ||
| 27 | it("parses localhost:port", () => { | |
| 28 | expect(parsePortFromLsofName("localhost:5173")).toBe(5173); | |
| 29 | }); | |
| 30 | ||
| 31 | it("parses [::1]:port", () => { | |
| 32 | expect(parsePortFromLsofName("[::1]:5173")).toBe(5173); | |
| 33 | }); | |
| 34 | ||
| 35 | it("ignores non-local hosts", () => { | |
| 36 | expect(parsePortFromLsofName("192.168.1.10:5173")).toBeNull(); | |
| 37 | }); | |
| 38 | ||
| 39 | it("strips trailing description", () => { | |
| 40 | expect(parsePortFromLsofName("*:5173 (LISTEN)")).toBe(5173); | |
| 41 | }); | |
| 42 | ||
| 43 | it("rejects garbage", () => { | |
| 44 | expect(parsePortFromLsofName("")).toBeNull(); | |
| 45 | expect(parsePortFromLsofName("not-a-port")).toBeNull(); | |
| 46 | expect(parsePortFromLsofName("*:0")).toBeNull(); | |
| 47 | expect(parsePortFromLsofName("*:99999")).toBeNull(); | |
| 48 | }); | |
| 49 | }); | |
| 50 | ||
| 51 | describe("parseLsofOutput", () => { | |
| 52 | it("parses a typical lsof -F pcn output", () => { | |
| 53 | const sample = [ | |
| 54 | "p12345", | |
| 55 | "cnode", | |
| 56 | "n*:5173", | |
| 57 | "p67890", | |
| 58 | "cnext-server", | |
| 59 | "n127.0.0.1:3000", | |
| 60 | "n127.0.0.1:9229", // node debug port too — same process | |
| 61 | "p13579", | |
| 62 | "cChrome", | |
| 63 | "n192.168.1.10:443", // not local — ignored | |
| 64 | ].join("\n"); | |
| 65 | ||
| 66 | const servers = parseLsofOutput(sample); | |
| 67 | expect(servers).toEqual([ | |
| 68 | { | |
| 69 | host: "localhost", | |
| 70 | port: 3000, | |
| 71 | url: "http://localhost:3000", | |
| 72 | processName: "next-server", | |
| 73 | pid: 67890, | |
| 74 | terminal: null, | |
| 75 | }, | |
| 76 | { | |
| 77 | host: "localhost", | |
| 78 | port: 5173, | |
| 79 | url: "http://localhost:5173", | |
| 80 | processName: "node", | |
| 81 | pid: 12345, | |
| 82 | terminal: null, | |
| 83 | }, | |
| 84 | { | |
| 85 | host: "localhost", | |
| 86 | port: 9229, | |
| 87 | url: "http://localhost:9229", | |
| 88 | processName: "next-server", | |
| 89 | pid: 67890, | |
| 90 | terminal: null, | |
| 91 | }, | |
| 92 | ]); | |
| 93 | }); | |
| 94 | ||
| 95 | it("handles empty input", () => { | |
| 96 | expect(parseLsofOutput("")).toEqual([]); | |
| 97 | }); | |
| 98 | ||
| 99 | it("dedupes by host:port", () => { | |
| 100 | const sample = ["p1", "cnode", "n*:5173", "n127.0.0.1:5173"].join("\n"); | |
| 101 | const servers = parseLsofOutput(sample); | |
| 102 | expect(servers).toHaveLength(1); | |
| 103 | expect(servers[0]?.port).toBe(5173); | |
| 104 | }); | |
| 105 | ||
| 106 | it("attributes listeners to a registered terminal process", () => { | |
| 107 | const servers = parseLsofOutput( | |
| 108 | ["p12345", "cnode", "n*:5173"].join("\n"), | |
| 109 | new Map([ | |
| 110 | [ | |
| 111 | 12345, | |
| 112 | { | |
| 113 | threadId: ThreadId.make("thread-1"), | |
| 114 | terminalId: "terminal-1", | |
| 115 | }, | |
| 116 | ], | |
| 117 | ]), | |
| 118 | ); | |
| 119 | ||
| 120 | expect(servers[0]?.terminal).toEqual({ | |
| 121 | threadId: "thread-1", | |
| 122 | terminalId: "terminal-1", | |
| 123 | }); | |
| 124 | }); | |
| 125 | }); | |
| 126 | ||
| 127 | describe("serversEqual", () => { | |
| 128 | const a = { | |
| 129 | host: "localhost", | |
| 130 | port: 5173, | |
| 131 | url: "http://localhost:5173", | |
| 132 | processName: "node", | |
| 133 | pid: 1, | |
| 134 | terminal: null, | |
| 135 | }; | |
| 136 | it("returns true for identical lists", () => { | |
| 137 | expect(serversEqual([a], [{ ...a }])).toBe(true); | |
| 138 | }); | |
| 139 | it("returns false for different lengths", () => { | |
| 140 | expect(serversEqual([a], [])).toBe(false); | |
| 141 | }); | |
| 142 | it("returns false for different processName", () => { | |
| 143 | expect(serversEqual([a], [{ ...a, processName: "other" }])).toBe(false); | |
| 144 | }); | |
| 145 | }); | |
| 146 | ||
| 147 | describe("parseWindowsListenerOutput", () => { | |
| 148 | it("parses and attributes PowerShell listener records", () => { | |
| 149 | const servers = parseWindowsListenerOutput( | |
| 150 | "0.0.0.0|5173|12345|node", | |
| 151 | new Map([ | |
| 152 | [ | |
| 153 | 12345, | |
| 154 | { | |
| 155 | threadId: ThreadId.make("thread-1"), | |
| 156 | terminalId: "terminal-1", | |
| 157 | }, | |
| 158 | ], | |
| 159 | ]), | |
| 160 | ); | |
| 161 | ||
| 162 | expect(servers).toEqual([ | |
| 163 | { | |
| 164 | host: "localhost", | |
| 165 | port: 5173, | |
| 166 | url: "http://localhost:5173", | |
| 167 | processName: "node", | |
| 168 | pid: 12345, | |
| 169 | terminal: { | |
| 170 | threadId: "thread-1", | |
| 171 | terminalId: "terminal-1", | |
| 172 | }, | |
| 173 | }, | |
| 174 | ]); | |
| 175 | }); | |
| 176 | }); | |
| 177 | ||
| 178 | /** | |
| 179 | * Integration tests against a real TCP listener. We force the Windows code | |
| 180 | * path (TCP-probe fallback) by monkey-patching `process.platform` for the | |
| 181 | * duration of the test so we don't depend on `lsof` being installed. | |
| 182 | */ | |
| 183 | describe("PortDiscovery integration (TCP probe fallback)", () => { | |
| 184 | let originalPlatform: NodeJS.Platform; | |
| 185 | let server: net.Server; | |
| 186 | let port: number; | |
| 187 | ||
| 188 | beforeEach(async () => { | |
| 189 | originalPlatform = process.platform; | |
| 190 | Object.defineProperty(process, "platform", { value: "win32", configurable: true }); | |
| 191 | for (const candidate of PortScanner.COMMON_DEV_PORTS) { | |
| 192 | const candidateServer = net.createServer(); | |
| 193 | const listening = await new Promise<boolean>((resolve) => { | |
| 194 | candidateServer.once("error", () => resolve(false)); | |
| 195 | candidateServer.listen(candidate, "127.0.0.1", () => resolve(true)); | |
| 196 | }); | |
| 197 | if (listening) { | |
| 198 | server = candidateServer; | |
| 199 | port = candidate; | |
| 200 | return; | |
| 201 | } | |
| 202 | candidateServer.close(); | |
| 203 | } | |
| 204 | throw new Error("No common development port was available for the preview scanner test"); | |
| 205 | }); | |
| 206 | ||
| 207 | afterEach(async () => { | |
| 208 | Object.defineProperty(process, "platform", { | |
| 209 | value: originalPlatform, | |
| 210 | configurable: true, | |
| 211 | }); | |
| 212 | await new Promise<void>((resolve) => server.close(() => resolve())); | |
| 213 | }); | |
| 214 | ||
| 215 | effectIt.effect("scan() returns a server we just opened on a curated dev port", () => | |
| 216 | Effect.gen(function* () { | |
| 217 | const scanner = yield* PortScanner.PortDiscovery; | |
| 218 | const result = yield* scanner.scan(); | |
| 219 | const found = result.find((server) => server.port === port); | |
| 220 | expect(found).toBeDefined(); | |
| 221 | expect(found?.host).toBe("localhost"); | |
| 222 | }).pipe(Effect.provide(TestPortDiscoveryLive)), | |
| 223 | ); | |
| 224 | ||
| 225 | effectIt.effect("retain() drives an immediate broadcast to subscribers", () => { | |
| 226 | const received: number[] = []; | |
| 227 | return Effect.gen(function* () { | |
| 228 | const scanner = yield* PortScanner.PortDiscovery; | |
| 229 | const unsubscribe = yield* scanner.subscribe((servers) => | |
| 230 | Effect.sync(() => { | |
| 231 | for (const server of servers) received.push(server.port); | |
| 232 | }), | |
| 233 | ); | |
| 234 | const release = yield* scanner.retain(); | |
| 235 | unsubscribe(); | |
| 236 | release(); | |
| 237 | expect(received).toContain(port); | |
| 238 | }).pipe(Effect.provide(TestPortDiscoveryLive)); | |
| 239 | }); | |
| 240 | }); |
apps/server/src/preview/PortScanner.ts created+360| ... | ... | @@ -0,0 +1,360 @@ |
| 1 | /** | |
| 2 | * In-process PortScanner implementation. | |
| 3 | * | |
| 4 | * macOS/Linux: parses `lsof -iTCP -sTCP:LISTEN -P -n -F pcn` (-F output is a | |
| 5 | * stable line-prefixed field format; this is the only `lsof` flag set we rely | |
| 6 | * on). | |
| 7 | * | |
| 8 | * Windows / lsof missing: TCP-connects to a curated list of common dev ports | |
| 9 | * on 127.0.0.1. | |
| 10 | * | |
| 11 | * Polling is reference-counted via `retain()`. A single layer-scoped fiber | |
| 12 | * polls forever, but each tick is a no-op when the retain count is zero. | |
| 13 | */ | |
| 14 | import * as net from "node:net"; | |
| 15 | ||
| 16 | import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 17 | import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview"; | |
| 18 | import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect"; | |
| 19 | ||
| 20 | import { ProcessRunner } from "../processRunner.ts"; | |
| 21 | ||
| 22 | export interface PortDiscoveryShape { | |
| 23 | readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; | |
| 24 | readonly subscribe: ( | |
| 25 | listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, | |
| 26 | ) => Effect.Effect<() => void>; | |
| 27 | readonly retain: () => Effect.Effect<() => void>; | |
| 28 | readonly registerTerminalProcesses: (input: { | |
| 29 | readonly threadId: string; | |
| 30 | readonly terminalId: string; | |
| 31 | readonly processIds: ReadonlyArray<number>; | |
| 32 | }) => Effect.Effect<void>; | |
| 33 | readonly unregisterTerminal: (input: { | |
| 34 | readonly threadId: string; | |
| 35 | readonly terminalId: string; | |
| 36 | }) => Effect.Effect<void>; | |
| 37 | } | |
| 38 | ||
| 39 | export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()( | |
| 40 | "t3/preview/PortScanner/PortDiscovery", | |
| 41 | ) {} | |
| 42 | ||
| 43 | export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ | |
| 44 | 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, | |
| 45 | ]); | |
| 46 | ||
| 47 | const POLL_INTERVAL = Duration.seconds(3); | |
| 48 | const TCP_PROBE_TIMEOUT_MS = 200; | |
| 49 | const LSOF_TIMEOUT_MS = 5_000; | |
| 50 | const WINDOWS_LISTENER_TIMEOUT_MS = 5_000; | |
| 51 | ||
| 52 | type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>; | |
| 53 | ||
| 54 | interface ScannerState { | |
| 55 | readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>; | |
| 56 | } | |
| 57 | ||
| 58 | interface TerminalProcessOwner { | |
| 59 | readonly threadId: ThreadId; | |
| 60 | readonly terminalId: string; | |
| 61 | } | |
| 62 | ||
| 63 | const terminalOwnerKey = (owner: { | |
| 64 | readonly threadId: string; | |
| 65 | readonly terminalId: string; | |
| 66 | }): string => `${owner.threadId}\u0000${owner.terminalId}`; | |
| 67 | ||
| 68 | const parseLsofOutput = ( | |
| 69 | raw: string, | |
| 70 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), | |
| 71 | ): ReadonlyArray<DiscoveredLocalServer> => { | |
| 72 | const seen = new Map<string, DiscoveredLocalServer>(); | |
| 73 | let pid: number | null = null; | |
| 74 | let processName: string | null = null; | |
| 75 | ||
| 76 | for (const line of raw.split("\n")) { | |
| 77 | if (line.length === 0) continue; | |
| 78 | const tag = line.charAt(0); | |
| 79 | const value = line.slice(1); | |
| 80 | if (tag === "p") { | |
| 81 | const parsed = Number.parseInt(value, 10); | |
| 82 | pid = Number.isFinite(parsed) && parsed > 0 ? parsed : null; | |
| 83 | processName = null; | |
| 84 | continue; | |
| 85 | } | |
| 86 | if (tag === "c") { | |
| 87 | processName = value.trim() || null; | |
| 88 | continue; | |
| 89 | } | |
| 90 | if (tag === "n") { | |
| 91 | const portMatch = parsePortFromLsofName(value); | |
| 92 | if (portMatch == null) continue; | |
| 93 | const url = `http://localhost:${portMatch}`; | |
| 94 | const key = `localhost:${portMatch}`; | |
| 95 | if (seen.has(key)) continue; | |
| 96 | seen.set(key, { | |
| 97 | host: "localhost", | |
| 98 | port: portMatch, | |
| 99 | url, | |
| 100 | processName, | |
| 101 | pid, | |
| 102 | terminal: pid === null ? null : (terminalByProcessId.get(pid) ?? null), | |
| 103 | }); | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | return Array.from(seen.values()).toSorted((a, b) => a.port - b.port); | |
| 108 | }; | |
| 109 | ||
| 110 | const parsePortFromLsofName = (name: string): number | null => { | |
| 111 | // Examples: "*:5173", "127.0.0.1:5173", "[::1]:5173", "localhost:5173", | |
| 112 | // "192.168.1.10:5173 (LISTEN)" — we only care if the host part is local. | |
| 113 | const trimmed = name.split(" ", 1)[0]?.trim() ?? ""; | |
| 114 | if (trimmed.length === 0) return null; | |
| 115 | const lastColon = trimmed.lastIndexOf(":"); | |
| 116 | if (lastColon < 0) return null; | |
| 117 | const hostPart = trimmed.slice(0, lastColon); | |
| 118 | const portPart = trimmed.slice(lastColon + 1); | |
| 119 | if (!LSOF_LOCAL_HOST_TOKENS.has(hostPart)) return null; | |
| 120 | const port = Number.parseInt(portPart, 10); | |
| 121 | if (!Number.isFinite(port) || port <= 0 || port >= 65536) return null; | |
| 122 | return port; | |
| 123 | }; | |
| 124 | ||
| 125 | const parseWindowsListenerOutput = ( | |
| 126 | raw: string, | |
| 127 | terminalByProcessId: ReadonlyMap<number, TerminalProcessOwner> = new Map(), | |
| 128 | ): ReadonlyArray<DiscoveredLocalServer> => { | |
| 129 | const seen = new Map<number, DiscoveredLocalServer>(); | |
| 130 | for (const line of raw.split(/\r?\n/g)) { | |
| 131 | const [hostRaw, portRaw, pidRaw, processNameRaw] = line.trim().split("|", 4); | |
| 132 | const host = hostRaw?.trim() ?? ""; | |
| 133 | if (!LSOF_LOCAL_HOST_TOKENS.has(host) && host !== "::") continue; | |
| 134 | const port = Number(portRaw); | |
| 135 | const pid = Number(pidRaw); | |
| 136 | if (!Number.isInteger(port) || port <= 0 || port >= 65536) continue; | |
| 137 | const normalizedPid = Number.isInteger(pid) && pid > 0 ? pid : null; | |
| 138 | if (seen.has(port)) continue; | |
| 139 | seen.set(port, { | |
| 140 | host: "localhost", | |
| 141 | port, | |
| 142 | url: `http://localhost:${port}`, | |
| 143 | processName: processNameRaw?.trim() || null, | |
| 144 | pid: normalizedPid, | |
| 145 | terminal: normalizedPid === null ? null : (terminalByProcessId.get(normalizedPid) ?? null), | |
| 146 | }); | |
| 147 | } | |
| 148 | return [...seen.values()].toSorted((left, right) => left.port - right.port); | |
| 149 | }; | |
| 150 | ||
| 151 | const probeTcpPort = (port: number): Promise<boolean> => | |
| 152 | new Promise((resolve) => { | |
| 153 | const socket = new net.Socket(); | |
| 154 | let settled = false; | |
| 155 | const finish = (ok: boolean) => { | |
| 156 | if (settled) return; | |
| 157 | settled = true; | |
| 158 | socket.destroy(); | |
| 159 | resolve(ok); | |
| 160 | }; | |
| 161 | socket.setTimeout(TCP_PROBE_TIMEOUT_MS); | |
| 162 | socket.once("timeout", () => finish(false)); | |
| 163 | socket.once("error", () => finish(false)); | |
| 164 | socket.once("connect", () => finish(true)); | |
| 165 | socket.connect({ host: "127.0.0.1", port }); | |
| 166 | }); | |
| 167 | ||
| 168 | const probeCommonPorts = (): Effect.Effect<ReadonlyArray<DiscoveredLocalServer>> => | |
| 169 | Effect.promise(async () => { | |
| 170 | const results = await Promise.all( | |
| 171 | COMMON_DEV_PORTS.map(async (port) => ({ port, listening: await probeTcpPort(port) })), | |
| 172 | ); | |
| 173 | return results | |
| 174 | .filter((r) => r.listening) | |
| 175 | .map<DiscoveredLocalServer>((r) => ({ | |
| 176 | host: "localhost", | |
| 177 | port: r.port, | |
| 178 | url: `http://localhost:${r.port}`, | |
| 179 | processName: null, | |
| 180 | pid: null, | |
| 181 | terminal: null, | |
| 182 | })); | |
| 183 | }); | |
| 184 | ||
| 185 | const serversEqual = ( | |
| 186 | left: ReadonlyArray<DiscoveredLocalServer>, | |
| 187 | right: ReadonlyArray<DiscoveredLocalServer>, | |
| 188 | ): boolean => { | |
| 189 | if (left.length !== right.length) return false; | |
| 190 | for (let i = 0; i < left.length; i += 1) { | |
| 191 | const a = left[i]; | |
| 192 | const b = right[i]; | |
| 193 | if (!a || !b) return false; | |
| 194 | if ( | |
| 195 | a.host !== b.host || | |
| 196 | a.port !== b.port || | |
| 197 | a.url !== b.url || | |
| 198 | a.processName !== b.processName || | |
| 199 | a.pid !== b.pid || | |
| 200 | a.terminal?.threadId !== b.terminal?.threadId || | |
| 201 | a.terminal?.terminalId !== b.terminal?.terminalId | |
| 202 | ) { | |
| 203 | return false; | |
| 204 | } | |
| 205 | } | |
| 206 | return true; | |
| 207 | }; | |
| 208 | ||
| 209 | const make = Effect.gen(function* () { | |
| 210 | const processRunner = yield* ProcessRunner; | |
| 211 | const stateRef = yield* Ref.make<ScannerState>({ | |
| 212 | lastSnapshot: [], | |
| 213 | }); | |
| 214 | const listeners = new Set<Listener>(); | |
| 215 | const terminalProcesses = new Map< | |
| 216 | string, | |
| 217 | { | |
| 218 | readonly owner: TerminalProcessOwner; | |
| 219 | readonly processIds: ReadonlySet<number>; | |
| 220 | } | |
| 221 | >(); | |
| 222 | // Plain integer because the release callback returned by `retain()` runs | |
| 223 | // outside any Effect context (the WS subscriber's release path) and must | |
| 224 | // be a synchronous side-effect-only function. | |
| 225 | let retainCount = 0; | |
| 226 | ||
| 227 | const scanOnce = Effect.fn("PortDiscovery.scan")(function* () { | |
| 228 | const terminalByProcessId = new Map<number, TerminalProcessOwner>(); | |
| 229 | for (const registration of terminalProcesses.values()) { | |
| 230 | for (const processId of registration.processIds) { | |
| 231 | terminalByProcessId.set(processId, registration.owner); | |
| 232 | } | |
| 233 | } | |
| 234 | if (process.platform === "win32") { | |
| 235 | const command = | |
| 236 | 'Get-NetTCPConnection -State Listen -ErrorAction Stop | ForEach-Object { $processName = (Get-Process -Id $_.OwningProcess -ErrorAction SilentlyContinue).ProcessName; Write-Output "$($_.LocalAddress)|$($_.LocalPort)|$($_.OwningProcess)|$processName" }'; | |
| 237 | const listeners = yield* processRunner | |
| 238 | .run({ | |
| 239 | command: "powershell.exe", | |
| 240 | args: ["-NoProfile", "-NonInteractive", "-Command", command], | |
| 241 | timeout: Duration.millis(WINDOWS_LISTENER_TIMEOUT_MS), | |
| 242 | maxOutputBytes: 1024 * 1024, | |
| 243 | outputMode: "truncate", | |
| 244 | }) | |
| 245 | .pipe( | |
| 246 | Effect.map((result) => parseWindowsListenerOutput(result.stdout, terminalByProcessId)), | |
| 247 | Effect.catchCause(() => Effect.succeed(null)), | |
| 248 | ); | |
| 249 | if (listeners !== null) return listeners; | |
| 250 | return yield* probeCommonPorts(); | |
| 251 | } | |
| 252 | const lsofResult = yield* processRunner | |
| 253 | .run({ | |
| 254 | command: "lsof", | |
| 255 | args: ["-iTCP", "-sTCP:LISTEN", "-P", "-n", "-F", "pcn"], | |
| 256 | timeout: Duration.millis(LSOF_TIMEOUT_MS), | |
| 257 | maxOutputBytes: 1024 * 1024, | |
| 258 | outputMode: "truncate", | |
| 259 | }) | |
| 260 | .pipe( | |
| 261 | Effect.map((result) => parseLsofOutput(result.stdout, terminalByProcessId)), | |
| 262 | Effect.catchCause(() => Effect.succeed(null)), | |
| 263 | ); | |
| 264 | if (lsofResult !== null) return lsofResult; | |
| 265 | return yield* probeCommonPorts(); | |
| 266 | }); | |
| 267 | ||
| 268 | const broadcast = (servers: ReadonlyArray<DiscoveredLocalServer>): Effect.Effect<void> => | |
| 269 | Effect.forEach(Array.from(listeners), (listener) => listener(servers), { discard: true }); | |
| 270 | ||
| 271 | const pollTick = Effect.gen(function* () { | |
| 272 | if (retainCount <= 0) return; | |
| 273 | const next = yield* scanOnce(); | |
| 274 | const state = yield* Ref.get(stateRef); | |
| 275 | if (serversEqual(state.lastSnapshot, next)) return; | |
| 276 | yield* Ref.update(stateRef, (s) => ({ ...s, lastSnapshot: next })); | |
| 277 | yield* broadcast(next); | |
| 278 | }).pipe( | |
| 279 | Effect.catchCause((cause: Cause.Cause<never>) => | |
| 280 | Effect.logWarning("preview port scan failed", Cause.pretty(cause)), | |
| 281 | ), | |
| 282 | ); | |
| 283 | ||
| 284 | // Single layer-scoped polling fiber. Ticks are no-ops when no client is | |
| 285 | // currently retained, so the cost is one Ref.get every POLL_INTERVAL. | |
| 286 | yield* Effect.forkScoped(pollTick.pipe(Effect.repeat(Schedule.spaced(POLL_INTERVAL)))); | |
| 287 | ||
| 288 | const retain: PortDiscoveryShape["retain"] = Effect.fn("PortDiscovery.retain")(function* () { | |
| 289 | const wasIdle = retainCount === 0; | |
| 290 | retainCount += 1; | |
| 291 | if (wasIdle) { | |
| 292 | // Run an immediate scan + broadcast so the new retainer doesn't have | |
| 293 | // to wait up to POLL_INTERVAL for the first emission. | |
| 294 | yield* pollTick; | |
| 295 | } | |
| 296 | let released = false; | |
| 297 | return () => { | |
| 298 | if (released) return; | |
| 299 | released = true; | |
| 300 | retainCount = Math.max(0, retainCount - 1); | |
| 301 | }; | |
| 302 | }); | |
| 303 | ||
| 304 | const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")( | |
| 305 | function* (listener) { | |
| 306 | return yield* Effect.sync(() => { | |
| 307 | listeners.add(listener); | |
| 308 | return () => { | |
| 309 | listeners.delete(listener); | |
| 310 | }; | |
| 311 | }); | |
| 312 | }, | |
| 313 | ); | |
| 314 | ||
| 315 | const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn( | |
| 316 | "PortDiscovery.registerTerminalProcesses", | |
| 317 | )(function* (input) { | |
| 318 | yield* Effect.sync(() => { | |
| 319 | const owner = { | |
| 320 | threadId: ThreadId.make(input.threadId), | |
| 321 | terminalId: input.terminalId, | |
| 322 | }; | |
| 323 | const processIds = new Set( | |
| 324 | input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0), | |
| 325 | ); | |
| 326 | const key = terminalOwnerKey(owner); | |
| 327 | if (processIds.size === 0) { | |
| 328 | terminalProcesses.delete(key); | |
| 329 | return; | |
| 330 | } | |
| 331 | terminalProcesses.set(key, { owner, processIds }); | |
| 332 | }); | |
| 333 | }); | |
| 334 | ||
| 335 | const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn( | |
| 336 | "PortDiscovery.unregisterTerminal", | |
| 337 | )(function* (input) { | |
| 338 | yield* Effect.sync(() => { | |
| 339 | terminalProcesses.delete(terminalOwnerKey(input)); | |
| 340 | }); | |
| 341 | }); | |
| 342 | ||
| 343 | return { | |
| 344 | scan: scanOnce, | |
| 345 | subscribe, | |
| 346 | retain, | |
| 347 | registerTerminalProcesses, | |
| 348 | unregisterTerminal, | |
| 349 | } satisfies PortDiscoveryShape; | |
| 350 | }); | |
| 351 | ||
| 352 | export const layer = Layer.effect(PortDiscovery, make); | |
| 353 | ||
| 354 | /** Exposed for tests. */ | |
| 355 | export const __testing = { | |
| 356 | parseLsofOutput, | |
| 357 | parsePortFromLsofName, | |
| 358 | parseWindowsListenerOutput, | |
| 359 | serversEqual, | |
| 360 | }; |
apps/server/src/preview/Services/Manager.ts deleted-94| ... | ... | @@ -1,94 +0,0 @@ |
| 1 | /** | |
| 2 | * PreviewManager - Per-thread browser preview session orchestration. | |
| 3 | * | |
| 4 | * Owns metadata-only preview sessions keyed by `(threadId, tabId)`. A thread | |
| 5 | * can host multiple concurrent tabs (browser-style). The actual Chromium | |
| 6 | * <webview> lives in the desktop renderer; this service tracks the canonical | |
| 7 | * URL/title/nav state per tab so sessions survive reconnects and are visible | |
| 8 | * to multiple connected clients. | |
| 9 | * | |
| 10 | * Event delivery uses Effect's `PubSub` so listener failures (e.g. a | |
| 11 | * disconnected WS subscriber's queue being closed) cannot propagate back | |
| 12 | * into the mutating call's failure channel — matches the codebase | |
| 13 | * convention used by `SessionCredentialService`. | |
| 14 | * | |
| 15 | * @module PreviewManager | |
| 16 | */ | |
| 17 | import { | |
| 18 | type PreviewCloseInput, | |
| 19 | type PreviewError, | |
| 20 | type PreviewEvent, | |
| 21 | type PreviewListInput, | |
| 22 | type PreviewListResult, | |
| 23 | type PreviewNavigateInput, | |
| 24 | type PreviewOpenInput, | |
| 25 | type PreviewRefreshInput, | |
| 26 | type PreviewReportStatusInput, | |
| 27 | type PreviewSessionSnapshot, | |
| 28 | } from "@t3tools/contracts"; | |
| 29 | import { Context, type Effect, type PubSub, type Scope, type Stream } from "effect"; | |
| 30 | ||
| 31 | export interface PreviewManagerShape { | |
| 32 | /** | |
| 33 | * Open a brand new preview tab for `threadId`. When `url` is omitted the | |
| 34 | * tab starts in the `Idle` state so the user can type into the URL bar; | |
| 35 | * otherwise it transitions straight to `Loading`. Always emits `opened`. | |
| 36 | */ | |
| 37 | readonly open: (input: PreviewOpenInput) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 38 | ||
| 39 | /** | |
| 40 | * Update the session's URL/title from the renderer (after the <webview> | |
| 41 | * resolved navigation, including redirects). Emits a `navigated` event. | |
| 42 | */ | |
| 43 | readonly navigate: ( | |
| 44 | input: PreviewNavigateInput, | |
| 45 | ) => Effect.Effect<PreviewSessionSnapshot, PreviewError>; | |
| 46 | ||
| 47 | /** | |
| 48 | * Renderer reports current nav status (Loading/Success/LoadFailed) and | |
| 49 | * back/forward availability. Drives `failed` and `navigated` events. | |
| 50 | */ | |
| 51 | readonly reportStatus: (input: PreviewReportStatusInput) => Effect.Effect<void, PreviewError>; | |
| 52 | ||
| 53 | /** | |
| 54 | * Renderer requested a reload. Server records intent (no state change) so | |
| 55 | * subscribers can reconcile if needed; the desktop bridge actually reloads. | |
| 56 | */ | |
| 57 | readonly refresh: (input: PreviewRefreshInput) => Effect.Effect<void, PreviewError>; | |
| 58 | ||
| 59 | /** | |
| 60 | * Close the session (drop server-side state, emit `closed`). When `tabId` | |
| 61 | * is omitted, closes every session for the thread. | |
| 62 | */ | |
| 63 | readonly close: (input: PreviewCloseInput) => Effect.Effect<void, PreviewError>; | |
| 64 | ||
| 65 | /** | |
| 66 | * List active preview sessions for a thread. Returns an empty array when | |
| 67 | * the thread has no sessions. | |
| 68 | */ | |
| 69 | readonly list: (input: PreviewListInput) => Effect.Effect<PreviewListResult>; | |
| 70 | ||
| 71 | /** | |
| 72 | * Stream of preview events. Each subscriber gets its own buffered stream; | |
| 73 | * subscriber failures are isolated and do not affect other subscribers | |
| 74 | * or publishers. | |
| 75 | * | |
| 76 | * NOTE: `Stream.fromPubSub` defers `PubSub.subscribe` until the stream | |
| 77 | * starts running, so `Stream.runForEach(...).pipe(Effect.forkScoped)` may | |
| 78 | * miss a publish landing between fork and stream-start. Callers that | |
| 79 | * cannot tolerate this gap should use `subscribeEvents` below. | |
| 80 | */ | |
| 81 | readonly events: Stream.Stream<PreviewEvent>; | |
| 82 | ||
| 83 | /** | |
| 84 | * Acquire a PubSub subscription synchronously in the caller's fiber so | |
| 85 | * no publish can land in the narrow gap between subscribe and consumer | |
| 86 | * loop start. The subscription's lifetime is bound to the provided | |
| 87 | * `Scope`; release happens automatically on scope close. | |
| 88 | */ | |
| 89 | readonly subscribeEvents: Effect.Effect<PubSub.Subscription<PreviewEvent>, never, Scope.Scope>; | |
| 90 | } | |
| 91 | ||
| 92 | export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()( | |
| 93 | "t3/preview/Services/Manager/PreviewManager", | |
| 94 | ) {} |
apps/server/src/preview/Services/PortScanner.ts deleted-46| ... | ... | @@ -1,46 +0,0 @@ |
| 1 | /** | |
| 2 | * PortDiscovery - Discovers listening localhost ports and attributes them to | |
| 3 | * registered terminal process families. | |
| 4 | * | |
| 5 | * Reference-counted polling: the scanner only runs when at least one client | |
| 6 | * has called `retain()`. This keeps idle desktops from running `lsof` every | |
| 7 | * 3 seconds for nothing. | |
| 8 | * | |
| 9 | * @module PortDiscovery | |
| 10 | */ | |
| 11 | import type { DiscoveredLocalServer } from "@t3tools/contracts"; | |
| 12 | import { Context, type Effect } from "effect"; | |
| 13 | ||
| 14 | export interface PortDiscoveryShape { | |
| 15 | /** One-shot snapshot of currently listening localhost ports. */ | |
| 16 | readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>; | |
| 17 | /** Subscribe to changes. Listener invoked on every diff. Returns unsubscribe fn. */ | |
| 18 | readonly subscribe: ( | |
| 19 | listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>, | |
| 20 | ) => Effect.Effect<() => void>; | |
| 21 | /** | |
| 22 | * Hint that at least one client is interested → starts polling. Returns | |
| 23 | * release fn. When release count hits 0, polling stops. | |
| 24 | */ | |
| 25 | readonly retain: () => Effect.Effect<() => void>; | |
| 26 | /** Associate a terminal with its current process family for port attribution. */ | |
| 27 | readonly registerTerminalProcesses: (input: { | |
| 28 | readonly threadId: string; | |
| 29 | readonly terminalId: string; | |
| 30 | readonly processIds: ReadonlyArray<number>; | |
| 31 | }) => Effect.Effect<void>; | |
| 32 | /** Remove process attribution for a terminal that stopped or closed. */ | |
| 33 | readonly unregisterTerminal: (input: { | |
| 34 | readonly threadId: string; | |
| 35 | readonly terminalId: string; | |
| 36 | }) => Effect.Effect<void>; | |
| 37 | } | |
| 38 | ||
| 39 | export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()( | |
| 40 | "t3/preview/Services/PortScanner/PortDiscovery", | |
| 41 | ) {} | |
| 42 | ||
| 43 | /** Curated list of common dev-server ports for the Windows TCP-probe fallback. */ | |
| 44 | export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([ | |
| 45 | 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000, | |
| 46 | ]); |
apps/server/src/provider/Layers/ClaudeAdapter.ts+2-2| ... | ... | @@ -69,7 +69,7 @@ import * as Stream from "effect/Stream"; |
| 69 | 69 | |
| 70 | 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 71 | 71 | import { ServerConfig } from "../../config.ts"; |
| 72 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 72 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 73 | 73 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 74 | 74 | import { |
| 75 | 75 | getClaudeModelCapabilities, |
| ... | ... | @@ -3446,7 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3446 | 3446 | ...(fastMode ? { fastMode: true } : {}), |
| 3447 | 3447 | ...(ultracode ? { ultracode: true } : {}), |
| 3448 | 3448 | }; |
| 3449 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 3449 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 3450 | 3450 | const queryOptions: ClaudeQueryOptions = { |
| 3451 | 3451 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 3452 | 3452 | ...(apiModelId ? { model: apiModelId } : {}), |
apps/server/src/provider/Layers/CodexAdapter.ts+2-2| ... | ... | @@ -39,7 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 39 | 39 | |
| 40 | 40 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 41 | 41 | import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; |
| 42 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 42 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 43 | 43 | |
| 44 | 44 | import { |
| 45 | 45 | ProviderAdapterRequestError, |
| ... | ... | @@ -1386,7 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1386 | 1386 | input.modelSelection?.instanceId === boundInstanceId |
| 1387 | 1387 | ? getCodexServiceTierOptionValue(input.modelSelection) |
| 1388 | 1388 | : undefined; |
| 1389 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 1389 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 1390 | 1390 | const runtimeInput: CodexSessionRuntimeOptions = { |
| 1391 | 1391 | threadId: input.threadId, |
| 1392 | 1392 | providerInstanceId: boundInstanceId, |
apps/server/src/provider/Layers/CursorAdapter.ts+2-2| ... | ... | @@ -42,7 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 42 | 42 | |
| 43 | 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 44 | 44 | import { ServerConfig } from "../../config.ts"; |
| 45 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 45 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 46 | 46 | import { |
| 47 | 47 | ProviderAdapterProcessError, |
| 48 | 48 | ProviderAdapterRequestError, |
| ... | ... | @@ -531,7 +531,7 @@ export function makeCursorAdapter( |
| 531 | 531 | ? yield* options.resolveSettings |
| 532 | 532 | : cursorSettings; |
| 533 | 533 | |
| 534 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 534 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 535 | 535 | const acp = yield* makeCursorAcpRuntime({ |
| 536 | 536 | cursorSettings: effectiveCursorSettings, |
| 537 | 537 | ...(options?.environment ? { environment: options.environment } : {}), |
apps/server/src/provider/Layers/GrokAdapter.ts+2-2| ... | ... | @@ -33,7 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 33 | 33 | |
| 34 | 34 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 35 | 35 | import { ServerConfig } from "../../config.ts"; |
| 36 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 36 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 37 | 37 | import { |
| 38 | 38 | ProviderAdapterProcessError, |
| 39 | 39 | ProviderAdapterRequestError, |
| ... | ... | @@ -375,7 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 375 | 375 | threadId: input.threadId, |
| 376 | 376 | }); |
| 377 | 377 | |
| 378 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 378 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 379 | 379 | const acp = yield* makeGrokAcpRuntime({ |
| 380 | 380 | grokSettings, |
| 381 | 381 | ...(options?.environment ? { environment: options.environment } : {}), |
apps/server/src/provider/Layers/OpenCodeAdapter.ts+2-2| ... | ... | @@ -26,7 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 26 | 26 | |
| 27 | 27 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 28 | 28 | import { ServerConfig } from "../../config.ts"; |
| 29 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | |
| 29 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 30 | 30 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 31 | 31 | import { |
| 32 | 32 | ProviderAdapterProcessError, |
| ... | ... | @@ -1054,7 +1054,7 @@ export function makeOpenCodeAdapter( |
| 1054 | 1054 | directory, |
| 1055 | 1055 | ...(server.external && serverPassword ? { serverPassword } : {}), |
| 1056 | 1056 | }); |
| 1057 | const mcpSession = readMcpProviderSession(input.threadId); | |
| 1057 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); | |
| 1058 | 1058 | if (mcpSession && !server.external) { |
| 1059 | 1059 | yield* runOpenCodeSdk("mcp.add", () => |
| 1060 | 1060 | client.mcp.add({ |
apps/server/src/provider/Layers/ProviderService.ts+10-16| ... | ... | @@ -56,16 +56,8 @@ import { |
| 56 | 56 | import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 57 | 57 | import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 58 | 58 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 59 | import { | |
| 60 | issueActiveMcpCredential, | |
| 61 | revokeActiveMcpThread, | |
| 62 | revokeAllActiveMcpCredentials, | |
| 63 | } from "../../mcp/Layers/McpSessionRegistry.ts"; | |
| 64 | import { | |
| 65 | clearAllMcpProviderSessions, | |
| 66 | clearMcpProviderSession, | |
| 67 | setMcpProviderSession, | |
| 68 | } from "../../mcp/Services/McpProviderSession.ts"; | |
| 59 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; | |
| 60 | import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; | |
| 69 | 61 | const isModelSelection = Schema.is(ModelSelection); |
| 70 | 62 | |
| 71 | 63 | /** |
| ... | ... | @@ -223,14 +215,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 223 | 215 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 224 | 216 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 225 | 217 | const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => |
| 226 | issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( | |
| 218 | McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( | |
| 227 | 219 | Effect.tap((credential) => |
| 228 | credential ? Effect.sync(() => setMcpProviderSession(credential.config)) : Effect.void, | |
| 220 | credential | |
| 221 | ? Effect.sync(() => McpProviderSession.setMcpProviderSession(credential.config)) | |
| 222 | : Effect.void, | |
| 229 | 223 | ), |
| 230 | 224 | ); |
| 231 | 225 | const clearMcpSession = (threadId: ThreadId) => |
| 232 | revokeActiveMcpThread(threadId).pipe( | |
| 233 | Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))), | |
| 226 | McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( | |
| 227 | Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), | |
| 234 | 228 | ); |
| 235 | 229 | |
| 236 | 230 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| ... | ... | @@ -1027,8 +1021,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 1027 | 1021 | ), |
| 1028 | 1022 | ).pipe(Effect.asVoid); |
| 1029 | 1023 | yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); |
| 1030 | yield* revokeAllActiveMcpCredentials(); | |
| 1031 | clearAllMcpProviderSessions(); | |
| 1024 | yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); | |
| 1025 | McpProviderSession.clearAllMcpProviderSessions(); | |
| 1032 | 1026 | const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); |
| 1033 | 1027 | yield* Effect.forEach(bindings, (binding) => |
| 1034 | 1028 | Effect.gen(function* () { |
apps/server/src/server.test.ts+4-4| ... | ... | @@ -99,8 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server |
| 99 | 99 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; |
| 100 | 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| 101 | 101 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; |
| 102 | import { PreviewManager } from "./preview/Services/Manager.ts"; | |
| 103 | import { PortDiscovery } from "./preview/Services/PortScanner.ts"; | |
| 102 | import * as PreviewManager from "./preview/Manager.ts"; | |
| 103 | import * as PortScanner from "./preview/PortScanner.ts"; | |
| 104 | 104 | import { |
| 105 | 105 | BrowserTraceCollector, |
| 106 | 106 | type BrowserTraceCollectorShape, |
| ... | ... | @@ -669,7 +669,7 @@ const buildAppUnderTest = (options?: { |
| 669 | 669 | ), |
| 670 | 670 | Layer.provide( |
| 671 | 671 | Layer.mergeAll( |
| 672 | Layer.mock(PreviewManager)({ | |
| 672 | Layer.mock(PreviewManager.PreviewManager)({ | |
| 673 | 673 | open: () => Effect.die("PreviewManager not stubbed in this test"), |
| 674 | 674 | navigate: () => Effect.die("PreviewManager not stubbed in this test"), |
| 675 | 675 | reportStatus: () => Effect.void, |
| ... | ... | @@ -681,7 +681,7 @@ const buildAppUnderTest = (options?: { |
| 681 | 681 | PubSub.subscribe(pubsub), |
| 682 | 682 | ), |
| 683 | 683 | }), |
| 684 | Layer.mock(PortDiscovery)({ | |
| 684 | Layer.mock(PortScanner.PortDiscovery)({ | |
| 685 | 685 | scan: () => Effect.succeed([]), |
| 686 | 686 | subscribe: () => Effect.succeed(() => {}), |
| 687 | 687 | retain: () => Effect.succeed(() => {}), |
apps/server/src/server.ts+10-7| ... | ... | @@ -35,10 +35,10 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 35 | 35 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 36 | 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 37 | 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; | |
| 39 | import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts"; | |
| 40 | import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; | |
| 41 | import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; | |
| 38 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; | |
| 39 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; | |
| 40 | import * as PreviewManager from "./preview/Manager.ts"; | |
| 41 | import * as PortScanner from "./preview/PortScanner.ts"; | |
| 42 | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| 43 | 43 | import * as GitManager from "./git/GitManager.ts"; |
| 44 | 44 | import { KeybindingsLive } from "./keybindings.ts"; |
| ... | ... | @@ -236,14 +236,17 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 236 | 236 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), |
| 237 | 237 | ); |
| 238 | 238 | |
| 239 | const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer)); | |
| 239 | const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer)); | |
| 240 | 240 | |
| 241 | 241 | const TerminalLayerLive = TerminalManagerLive.pipe( |
| 242 | 242 | Layer.provide(PtyAdapterLive), |
| 243 | 243 | Layer.provide(PortScannerLayerLive), |
| 244 | 244 | ); |
| 245 | 245 | |
| 246 | const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive); | |
| 246 | const PreviewLayerLive = Layer.empty.pipe( | |
| 247 | Layer.provideMerge(PreviewManager.layer), | |
| 248 | Layer.provideMerge(PortScannerLayerLive), | |
| 249 | ); | |
| 247 | 250 | |
| 248 | 251 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( |
| 249 | 252 | Layer.provide(WorkspacePathsLive), |
| ... | ... | @@ -353,7 +356,7 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 353 | 356 | staticAndDevRouteLayer, |
| 354 | 357 | websocketRpcRouteLayer, |
| 355 | 358 | ), |
| 356 | McpHttpServerLive.pipe(Layer.provide(McpSessionRegistryLive)), | |
| 359 | McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), | |
| 357 | 360 | ).pipe(Layer.provide(browserApiCorsLayer)); |
| 358 | 361 | |
| 359 | 362 | export const makeServerLayer = Layer.unwrap( |
apps/server/src/terminal/Layers/Manager.ts+2-2| ... | ... | @@ -33,7 +33,7 @@ import { |
| 33 | 33 | terminalSessionsTotal, |
| 34 | 34 | } from "../../observability/Metrics.ts"; |
| 35 | 35 | import * as ProcessRunner from "../../processRunner.ts"; |
| 36 | import { PortDiscovery } from "../../preview/Services/PortScanner.ts"; | |
| 36 | import * as PortScanner from "../../preview/PortScanner.ts"; | |
| 37 | 37 | import { |
| 38 | 38 | TerminalCwdError, |
| 39 | 39 | TerminalHistoryError, |
| ... | ... | @@ -996,7 +996,7 @@ interface TerminalManagerOptions { |
| 996 | 996 | const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { |
| 997 | 997 | const { terminalLogsDir } = yield* ServerConfig; |
| 998 | 998 | const ptyAdapter = yield* PtyAdapter; |
| 999 | const portDiscovery = yield* PortDiscovery; | |
| 999 | const portDiscovery = yield* PortScanner.PortDiscovery; | |
| 1000 | 1000 | return yield* makeTerminalManagerWithOptions({ |
| 1001 | 1001 | logsDir: terminalLogsDir, |
| 1002 | 1002 | ptyAdapter, |
apps/server/src/ws.ts+7-5| ... | ... | @@ -71,9 +71,9 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 71 | 71 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 72 | 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| 73 | 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; | |
| 75 | import { PortDiscovery } from "./preview/Services/PortScanner.ts"; | |
| 76 | import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; | |
| 74 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | |
| 75 | import * as PreviewManager from "./preview/Manager.ts"; | |
| 76 | import * as PortScanner from "./preview/PortScanner.ts"; | |
| 77 | 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 78 | 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 79 | 79 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| ... | ... | @@ -256,8 +256,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 256 | 256 | const vcsProvisioning = yield* VcsProvisioningService; |
| 257 | 257 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 258 | 258 | const terminalManager = yield* TerminalManager; |
| 259 | const previewManager = yield* PreviewManager; | |
| 260 | const portDiscovery = yield* PortDiscovery; | |
| 259 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; | |
| 260 | const previewManager = yield* PreviewManager.PreviewManager; | |
| 261 | const portDiscovery = yield* PortScanner.PortDiscovery; | |
| 261 | 262 | const providerRegistry = yield* ProviderRegistry; |
| 262 | 263 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 263 | 264 | const config = yield* ServerConfig; |
| ... | ... | @@ -1573,6 +1574,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1573 | 1574 | Effect.provide( |
| 1574 | 1575 | makeWsRpcLayer(session).pipe( |
| 1575 | 1576 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1577 | Layer.provide(PreviewAutomationBroker.layer), | |
| 1576 | 1578 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 1577 | 1579 | Layer.provide( |
| 1578 | 1580 | SourceControlDiscoveryLayer.layer.pipe( |