| 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,28 +7,28 @@ import type { |
| 7 | PreviewAutomationStatus, | 7 | PreviewAutomationStatus, |
| 8 | } from "@t3tools/contracts"; | 8 | } from "@t3tools/contracts"; |
| 9 | 9 | ||
| 10 | import { requireMcpCapability } from "../../Services/McpInvocationContext.ts"; | 10 | import * as McpInvocationContext from "../../McpInvocationContext.ts"; |
| 11 | import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts"; | 11 | import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts"; |
| 12 | import { PreviewToolkit } from "./tools.ts"; | 12 | import { PreviewToolkit } from "./tools.ts"; |
| 13 | 13 | ||
| 14 | const invoke = <A>( | 14 | const invoke = Effect.fn("PreviewToolkit.invoke")(function* <A>( |
| 15 | operation: PreviewAutomationOperation, | 15 | operation: PreviewAutomationOperation, |
| 16 | input: unknown, | 16 | input: unknown, |
| 17 | timeoutMs?: number, | 17 | timeoutMs?: number, |
| 18 | ): Effect.Effect< | 18 | ): Effect.fn.Return< |
| 19 | A, | 19 | A, |
| 20 | import("@t3tools/contracts").PreviewAutomationError, | 20 | import("@t3tools/contracts").PreviewAutomationError, |
| 21 | import("../../Services/McpInvocationContext.ts").McpInvocationContext | 21 | McpInvocationContext.McpInvocationContext | PreviewAutomationBroker.PreviewAutomationBroker |
| 22 | > => | 22 | > { |
| 23 | Effect.gen(function* () { | 23 | const scope = yield* McpInvocationContext.requireMcpCapability("preview"); |
| 24 | const scope = yield* requireMcpCapability("preview"); | 24 | const broker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 25 | return yield* previewAutomationBroker.invoke<A>({ | 25 | return yield* broker.invoke<A>({ |
| 26 | scope, | 26 | scope, |
| 27 | operation, | 27 | operation, |
| 28 | input, | 28 | input, |
| 29 | ...(timeoutMs === undefined ? {} : { timeoutMs }), | 29 | ...(timeoutMs === undefined ? {} : { timeoutMs }), |
| 30 | }); | ||
| 31 | }); | 30 | }); |
| 31 | }); | ||
| 32 | 32 | ||
| 33 | export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ | 33 | export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({ |
| 34 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), | 34 | preview_status: () => invoke<PreviewAutomationStatus>("status", {}), |
apps/server/src/mcp/toolkits/preview/tools.ts+19-13| ... | @@ -16,7 +16,13 @@ import { | ... | @@ -16,7 +16,13 @@ import { |
| 16 | import * as Schema from "effect/Schema"; | 16 | import * as Schema from "effect/Schema"; |
| 17 | import { Tool, Toolkit } from "effect/unstable/ai"; | 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 | const browserTool = <T extends Tool.Any>(tool: T): T => | 27 | const browserTool = <T extends Tool.Any>(tool: T): T => |
| 22 | tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; | 28 | tool.annotate(Tool.OpenWorld, true).annotate(Tool.Destructive, true) as T; |
| ... | @@ -32,7 +38,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { | ... | @@ -32,7 +38,7 @@ export const PreviewStatusTool = Tool.make("preview_status", { |
| 32 | "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", | 38 | "Report whether the scoped thread has an automation-capable desktop preview, including its active tab, URL, title, visibility, and loading state.", |
| 33 | success: PreviewAutomationStatus, | 39 | success: PreviewAutomationStatus, |
| 34 | failure: PreviewAutomationError, | 40 | failure: PreviewAutomationError, |
| 35 | dependencies: [McpInvocationContext], | 41 | dependencies, |
| 36 | }) | 42 | }) |
| 37 | .annotate(Tool.Title, "Get preview status") | 43 | .annotate(Tool.Title, "Get preview status") |
| 38 | .annotate(Tool.Readonly, true) | 44 | .annotate(Tool.Readonly, true) |
| ... | @@ -46,7 +52,7 @@ export const PreviewOpenTool = browserTool( | ... | @@ -46,7 +52,7 @@ export const PreviewOpenTool = browserTool( |
| 46 | parameters: PreviewAutomationOpenInput, | 52 | parameters: PreviewAutomationOpenInput, |
| 47 | success: PreviewAutomationStatus, | 53 | success: PreviewAutomationStatus, |
| 48 | failure: PreviewAutomationError, | 54 | failure: PreviewAutomationError, |
| 49 | dependencies: [McpInvocationContext], | 55 | dependencies, |
| 50 | }) | 56 | }) |
| 51 | .annotate(Tool.Title, "Open browser preview") | 57 | .annotate(Tool.Title, "Open browser preview") |
| 52 | .annotate(Tool.Destructive, false), | 58 | .annotate(Tool.Destructive, false), |
| ... | @@ -59,7 +65,7 @@ export const PreviewNavigateTool = safeBrowserTool( | ... | @@ -59,7 +65,7 @@ export const PreviewNavigateTool = safeBrowserTool( |
| 59 | parameters: PreviewAutomationNavigateInput, | 65 | parameters: PreviewAutomationNavigateInput, |
| 60 | success: PreviewAutomationStatus, | 66 | success: PreviewAutomationStatus, |
| 61 | failure: PreviewAutomationError, | 67 | failure: PreviewAutomationError, |
| 62 | dependencies: [McpInvocationContext], | 68 | dependencies, |
| 63 | }).annotate(Tool.Title, "Navigate browser preview"), | 69 | }).annotate(Tool.Title, "Navigate browser preview"), |
| 64 | ); | 70 | ); |
| 65 | 71 | ||
| ... | @@ -69,7 +75,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( | ... | @@ -69,7 +75,7 @@ export const PreviewSnapshotTool = readonlyBrowserTool( |
| 69 | "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.", | 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 | success: PreviewAutomationSnapshot, | 76 | success: PreviewAutomationSnapshot, |
| 71 | failure: PreviewAutomationError, | 77 | failure: PreviewAutomationError, |
| 72 | dependencies: [McpInvocationContext], | 78 | dependencies, |
| 73 | }).annotate(Tool.Title, "Inspect browser page"), | 79 | }).annotate(Tool.Title, "Inspect browser page"), |
| 74 | ); | 80 | ); |
| 75 | 81 | ||
| ... | @@ -79,7 +85,7 @@ export const PreviewClickTool = browserTool( | ... | @@ -79,7 +85,7 @@ export const PreviewClickTool = browserTool( |
| 79 | "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.", | 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 | parameters: PreviewAutomationClickInput, | 86 | parameters: PreviewAutomationClickInput, |
| 81 | failure: PreviewAutomationError, | 87 | failure: PreviewAutomationError, |
| 82 | dependencies: [McpInvocationContext], | 88 | dependencies, |
| 83 | }).annotate(Tool.Title, "Click preview page"), | 89 | }).annotate(Tool.Title, "Click preview page"), |
| 84 | ); | 90 | ); |
| 85 | 91 | ||
| ... | @@ -89,7 +95,7 @@ export const PreviewTypeTool = browserTool( | ... | @@ -89,7 +95,7 @@ export const PreviewTypeTool = browserTool( |
| 89 | "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.", | 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 | parameters: PreviewAutomationTypeInput, | 96 | parameters: PreviewAutomationTypeInput, |
| 91 | failure: PreviewAutomationError, | 97 | failure: PreviewAutomationError, |
| 92 | dependencies: [McpInvocationContext], | 98 | dependencies, |
| 93 | }).annotate(Tool.Title, "Type into preview page"), | 99 | }).annotate(Tool.Title, "Type into preview page"), |
| 94 | ); | 100 | ); |
| 95 | 101 | ||
| ... | @@ -99,7 +105,7 @@ export const PreviewPressTool = browserTool( | ... | @@ -99,7 +105,7 @@ export const PreviewPressTool = browserTool( |
| 99 | "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.", | 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 | parameters: PreviewAutomationPressInput, | 106 | parameters: PreviewAutomationPressInput, |
| 101 | failure: PreviewAutomationError, | 107 | failure: PreviewAutomationError, |
| 102 | dependencies: [McpInvocationContext], | 108 | dependencies, |
| 103 | }).annotate(Tool.Title, "Press key in preview page"), | 109 | }).annotate(Tool.Title, "Press key in preview page"), |
| 104 | ); | 110 | ); |
| 105 | 111 | ||
| ... | @@ -109,7 +115,7 @@ export const PreviewScrollTool = safeBrowserTool( | ... | @@ -109,7 +115,7 @@ export const PreviewScrollTool = safeBrowserTool( |
| 109 | "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.", | 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 | parameters: PreviewAutomationScrollInput, | 116 | parameters: PreviewAutomationScrollInput, |
| 111 | failure: PreviewAutomationError, | 117 | failure: PreviewAutomationError, |
| 112 | dependencies: [McpInvocationContext], | 118 | dependencies, |
| 113 | }).annotate(Tool.Title, "Scroll preview page"), | 119 | }).annotate(Tool.Title, "Scroll preview page"), |
| 114 | ); | 120 | ); |
| 115 | 121 | ||
| ... | @@ -120,7 +126,7 @@ export const PreviewEvaluateTool = browserTool( | ... | @@ -120,7 +126,7 @@ export const PreviewEvaluateTool = browserTool( |
| 120 | parameters: PreviewAutomationEvaluateInput, | 126 | parameters: PreviewAutomationEvaluateInput, |
| 121 | success: Schema.Unknown, | 127 | success: Schema.Unknown, |
| 122 | failure: PreviewAutomationError, | 128 | failure: PreviewAutomationError, |
| 123 | dependencies: [McpInvocationContext], | 129 | dependencies, |
| 124 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), | 130 | }).annotate(Tool.Title, "Evaluate JavaScript in preview"), |
| 125 | ); | 131 | ); |
| 126 | 132 | ||
| ... | @@ -130,7 +136,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( | ... | @@ -130,7 +136,7 @@ export const PreviewWaitForTool = readonlyBrowserTool( |
| 130 | "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.", | 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 | parameters: PreviewAutomationWaitForInput, | 137 | parameters: PreviewAutomationWaitForInput, |
| 132 | failure: PreviewAutomationError, | 138 | failure: PreviewAutomationError, |
| 133 | dependencies: [McpInvocationContext], | 139 | dependencies, |
| 134 | }).annotate(Tool.Title, "Wait for preview page condition"), | 140 | }).annotate(Tool.Title, "Wait for preview page condition"), |
| 135 | ); | 141 | ); |
| 136 | 142 | ||
| ... | @@ -140,7 +146,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( | ... | @@ -140,7 +146,7 @@ export const PreviewRecordingStartTool = safeBrowserTool( |
| 140 | "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", | 146 | "Start recording the active collaborative browser tab while keeping it interactive for both agent and human use.", |
| 141 | success: PreviewAutomationRecordingStatus, | 147 | success: PreviewAutomationRecordingStatus, |
| 142 | failure: PreviewAutomationError, | 148 | failure: PreviewAutomationError, |
| 143 | dependencies: [McpInvocationContext], | 149 | dependencies, |
| 144 | }).annotate(Tool.Title, "Start browser recording"), | 150 | }).annotate(Tool.Title, "Start browser recording"), |
| 145 | ); | 151 | ); |
| 146 | 152 | ||
| ... | @@ -149,7 +155,7 @@ export const PreviewRecordingStopTool = safeBrowserTool( | ... | @@ -149,7 +155,7 @@ export const PreviewRecordingStopTool = safeBrowserTool( |
| 149 | description: "Stop the active browser recording and save it as a local evidence artifact.", | 155 | description: "Stop the active browser recording and save it as a local evidence artifact.", |
| 150 | success: PreviewAutomationRecordingArtifact, | 156 | success: PreviewAutomationRecordingArtifact, |
| 151 | failure: PreviewAutomationError, | 157 | failure: PreviewAutomationError, |
| 152 | dependencies: [McpInvocationContext], | 158 | dependencies, |
| 153 | }).annotate(Tool.Title, "Stop browser recording"), | 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,7 +69,7 @@ import * as Stream from "effect/Stream"; |
| 69 | 69 | ||
| 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 70 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 71 | import { ServerConfig } from "../../config.ts"; | 71 | import { ServerConfig } from "../../config.ts"; |
| 72 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | 72 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 73 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; | 73 | import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts"; |
| 74 | import { | 74 | import { |
| 75 | getClaudeModelCapabilities, | 75 | getClaudeModelCapabilities, |
| ... | @@ -3446,7 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( | ... | @@ -3446,7 +3446,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( |
| 3446 | ...(fastMode ? { fastMode: true } : {}), | 3446 | ...(fastMode ? { fastMode: true } : {}), |
| 3447 | ...(ultracode ? { ultracode: true } : {}), | 3447 | ...(ultracode ? { ultracode: true } : {}), |
| 3448 | }; | 3448 | }; |
| 3449 | const mcpSession = readMcpProviderSession(input.threadId); | 3449 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); |
| 3450 | const queryOptions: ClaudeQueryOptions = { | 3450 | const queryOptions: ClaudeQueryOptions = { |
| 3451 | ...(input.cwd ? { cwd: input.cwd } : {}), | 3451 | ...(input.cwd ? { cwd: input.cwd } : {}), |
| 3452 | ...(apiModelId ? { model: apiModelId } : {}), | 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,7 +39,7 @@ import * as EffectCodexSchema from "effect-codex-app-server/schema"; |
| 39 | 39 | ||
| 40 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; | 40 | import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 41 | import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts"; | 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 | import { | 44 | import { |
| 45 | ProviderAdapterRequestError, | 45 | ProviderAdapterRequestError, |
| ... | @@ -1386,7 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( | ... | @@ -1386,7 +1386,7 @@ export const makeCodexAdapter = Effect.fn("makeCodexAdapter")(function* ( |
| 1386 | input.modelSelection?.instanceId === boundInstanceId | 1386 | input.modelSelection?.instanceId === boundInstanceId |
| 1387 | ? getCodexServiceTierOptionValue(input.modelSelection) | 1387 | ? getCodexServiceTierOptionValue(input.modelSelection) |
| 1388 | : undefined; | 1388 | : undefined; |
| 1389 | const mcpSession = readMcpProviderSession(input.threadId); | 1389 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); |
| 1390 | const runtimeInput: CodexSessionRuntimeOptions = { | 1390 | const runtimeInput: CodexSessionRuntimeOptions = { |
| 1391 | threadId: input.threadId, | 1391 | threadId: input.threadId, |
| 1392 | providerInstanceId: boundInstanceId, | 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,7 +42,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 42 | 42 | ||
| 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 43 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 44 | import { ServerConfig } from "../../config.ts"; | 44 | import { ServerConfig } from "../../config.ts"; |
| 45 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | 45 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 46 | import { | 46 | import { |
| 47 | ProviderAdapterProcessError, | 47 | ProviderAdapterProcessError, |
| 48 | ProviderAdapterRequestError, | 48 | ProviderAdapterRequestError, |
| ... | @@ -531,7 +531,7 @@ export function makeCursorAdapter( | ... | @@ -531,7 +531,7 @@ export function makeCursorAdapter( |
| 531 | ? yield* options.resolveSettings | 531 | ? yield* options.resolveSettings |
| 532 | : cursorSettings; | 532 | : cursorSettings; |
| 533 | 533 | ||
| 534 | const mcpSession = readMcpProviderSession(input.threadId); | 534 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); |
| 535 | const acp = yield* makeCursorAcpRuntime({ | 535 | const acp = yield* makeCursorAcpRuntime({ |
| 536 | cursorSettings: effectiveCursorSettings, | 536 | cursorSettings: effectiveCursorSettings, |
| 537 | ...(options?.environment ? { environment: options.environment } : {}), | 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,7 +33,7 @@ import type * as EffectAcpSchema from "effect-acp/schema"; |
| 33 | 33 | ||
| 34 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 34 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 35 | import { ServerConfig } from "../../config.ts"; | 35 | import { ServerConfig } from "../../config.ts"; |
| 36 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | 36 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 37 | import { | 37 | import { |
| 38 | ProviderAdapterProcessError, | 38 | ProviderAdapterProcessError, |
| 39 | ProviderAdapterRequestError, | 39 | ProviderAdapterRequestError, |
| ... | @@ -375,7 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte | ... | @@ -375,7 +375,7 @@ export function makeGrokAdapter(grokSettings: GrokSettings, options?: GrokAdapte |
| 375 | threadId: input.threadId, | 375 | threadId: input.threadId, |
| 376 | }); | 376 | }); |
| 377 | 377 | ||
| 378 | const mcpSession = readMcpProviderSession(input.threadId); | 378 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); |
| 379 | const acp = yield* makeGrokAcpRuntime({ | 379 | const acp = yield* makeGrokAcpRuntime({ |
| 380 | grokSettings, | 380 | grokSettings, |
| 381 | ...(options?.environment ? { environment: options.environment } : {}), | 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,7 +26,7 @@ import { getModelSelectionStringOptionValue } from "@t3tools/shared/model"; |
| 26 | 26 | ||
| 27 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; | 27 | import { resolveAttachmentPath } from "../../attachmentStore.ts"; |
| 28 | import { ServerConfig } from "../../config.ts"; | 28 | import { ServerConfig } from "../../config.ts"; |
| 29 | import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts"; | 29 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 30 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 30 | import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 31 | import { | 31 | import { |
| 32 | ProviderAdapterProcessError, | 32 | ProviderAdapterProcessError, |
| ... | @@ -1054,7 +1054,7 @@ export function makeOpenCodeAdapter( | ... | @@ -1054,7 +1054,7 @@ export function makeOpenCodeAdapter( |
| 1054 | directory, | 1054 | directory, |
| 1055 | ...(server.external && serverPassword ? { serverPassword } : {}), | 1055 | ...(server.external && serverPassword ? { serverPassword } : {}), |
| 1056 | }); | 1056 | }); |
| 1057 | const mcpSession = readMcpProviderSession(input.threadId); | 1057 | const mcpSession = McpProviderSession.readMcpProviderSession(input.threadId); |
| 1058 | if (mcpSession && !server.external) { | 1058 | if (mcpSession && !server.external) { |
| 1059 | yield* runOpenCodeSdk("mcp.add", () => | 1059 | yield* runOpenCodeSdk("mcp.add", () => |
| 1060 | client.mcp.add({ | 1060 | client.mcp.add({ |
apps/server/src/provider/Layers/ProviderService.ts+10-16| ... | @@ -56,16 +56,8 @@ import { | ... | @@ -56,16 +56,8 @@ import { |
| 56 | import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; | 56 | import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts"; |
| 57 | import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; | 57 | import { ProviderEventLoggers } from "./ProviderEventLoggers.ts"; |
| 58 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; | 58 | import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts"; |
| 59 | import { | 59 | import * as McpProviderSession from "../../mcp/McpProviderSession.ts"; |
| 60 | issueActiveMcpCredential, | 60 | import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts"; |
| 61 | revokeActiveMcpThread, | ||
| 62 | revokeAllActiveMcpCredentials, | ||
| 63 | } from "../../mcp/Layers/McpSessionRegistry.ts"; | ||
| 64 | import { | ||
| 65 | clearAllMcpProviderSessions, | ||
| 66 | clearMcpProviderSession, | ||
| 67 | setMcpProviderSession, | ||
| 68 | } from "../../mcp/Services/McpProviderSession.ts"; | ||
| 69 | const isModelSelection = Schema.is(ModelSelection); | 61 | const isModelSelection = Schema.is(ModelSelection); |
| 70 | 62 | ||
| 71 | /** | 63 | /** |
| ... | @@ -223,14 +215,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -223,14 +215,16 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 223 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); | 215 | const runtimeEventPubSub = yield* PubSub.unbounded<ProviderRuntimeEvent>(); |
| 224 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); | 216 | const nowIso = Effect.map(DateTime.now, DateTime.formatIso); |
| 225 | const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => | 217 | const prepareMcpSession = (threadId: ThreadId, providerInstanceId: ProviderInstanceId) => |
| 226 | issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( | 218 | McpSessionRegistry.issueActiveMcpCredential({ threadId, providerInstanceId }).pipe( |
| 227 | Effect.tap((credential) => | 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 | const clearMcpSession = (threadId: ThreadId) => | 225 | const clearMcpSession = (threadId: ThreadId) => |
| 232 | revokeActiveMcpThread(threadId).pipe( | 226 | McpSessionRegistry.revokeActiveMcpThread(threadId).pipe( |
| 233 | Effect.tap(() => Effect.sync(() => clearMcpProviderSession(threadId))), | 227 | Effect.tap(() => Effect.sync(() => McpProviderSession.clearMcpProviderSession(threadId))), |
| 234 | ); | 228 | ); |
| 235 | 229 | ||
| 236 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => | 230 | const publishRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect<void> => |
| ... | @@ -1027,8 +1021,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( | ... | @@ -1027,8 +1021,8 @@ const makeProviderService = Effect.fn("makeProviderService")(function* ( |
| 1027 | ), | 1021 | ), |
| 1028 | ).pipe(Effect.asVoid); | 1022 | ).pipe(Effect.asVoid); |
| 1029 | yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); | 1023 | yield* Effect.forEach(currentAdapters, ([, adapter]) => adapter.stopAll()).pipe(Effect.asVoid); |
| 1030 | yield* revokeAllActiveMcpCredentials(); | 1024 | yield* McpSessionRegistry.revokeAllActiveMcpCredentials(); |
| 1031 | clearAllMcpProviderSessions(); | 1025 | McpProviderSession.clearAllMcpProviderSessions(); |
| 1032 | const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); | 1026 | const bindings = yield* directory.listBindings().pipe(Effect.orElseSucceed(() => [])); |
| 1033 | yield* Effect.forEach(bindings, (binding) => | 1027 | yield* Effect.forEach(bindings, (binding) => |
| 1034 | Effect.gen(function* () { | 1028 | Effect.gen(function* () { |
apps/server/src/server.test.ts+4-4| ... | @@ -99,8 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server | ... | @@ -99,8 +99,8 @@ import { ServerLifecycleEvents, type ServerLifecycleEventsShape } from "./server |
| 99 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; | 99 | import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts"; |
| 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; | 100 | import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts"; |
| 101 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; | 101 | import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts"; |
| 102 | import { PreviewManager } from "./preview/Services/Manager.ts"; | 102 | import * as PreviewManager from "./preview/Manager.ts"; |
| 103 | import { PortDiscovery } from "./preview/Services/PortScanner.ts"; | 103 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 104 | import { | 104 | import { |
| 105 | BrowserTraceCollector, | 105 | BrowserTraceCollector, |
| 106 | type BrowserTraceCollectorShape, | 106 | type BrowserTraceCollectorShape, |
| ... | @@ -669,7 +669,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -669,7 +669,7 @@ const buildAppUnderTest = (options?: { |
| 669 | ), | 669 | ), |
| 670 | Layer.provide( | 670 | Layer.provide( |
| 671 | Layer.mergeAll( | 671 | Layer.mergeAll( |
| 672 | Layer.mock(PreviewManager)({ | 672 | Layer.mock(PreviewManager.PreviewManager)({ |
| 673 | open: () => Effect.die("PreviewManager not stubbed in this test"), | 673 | open: () => Effect.die("PreviewManager not stubbed in this test"), |
| 674 | navigate: () => Effect.die("PreviewManager not stubbed in this test"), | 674 | navigate: () => Effect.die("PreviewManager not stubbed in this test"), |
| 675 | reportStatus: () => Effect.void, | 675 | reportStatus: () => Effect.void, |
| ... | @@ -681,7 +681,7 @@ const buildAppUnderTest = (options?: { | ... | @@ -681,7 +681,7 @@ const buildAppUnderTest = (options?: { |
| 681 | PubSub.subscribe(pubsub), | 681 | PubSub.subscribe(pubsub), |
| 682 | ), | 682 | ), |
| 683 | }), | 683 | }), |
| 684 | Layer.mock(PortDiscovery)({ | 684 | Layer.mock(PortScanner.PortDiscovery)({ |
| 685 | scan: () => Effect.succeed([]), | 685 | scan: () => Effect.succeed([]), |
| 686 | subscribe: () => Effect.succeed(() => {}), | 686 | subscribe: () => Effect.succeed(() => {}), |
| 687 | retain: () => Effect.succeed(() => {}), | 687 | retain: () => Effect.succeed(() => {}), |
apps/server/src/server.ts+10-7| ... | @@ -35,10 +35,10 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; | ... | @@ -35,10 +35,10 @@ import * as GitLabCli from "./sourceControl/GitLabCli.ts"; |
| 35 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; | 35 | import * as TextGeneration from "./textGeneration/TextGeneration.ts"; |
| 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; | 36 | import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts"; |
| 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; | 37 | import { TerminalManagerLive } from "./terminal/Layers/Manager.ts"; |
| 38 | import { PreviewManagerLive } from "./preview/Layers/Manager.ts"; | 38 | import * as McpHttpServer from "./mcp/McpHttpServer.ts"; |
| 39 | import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts"; | 39 | import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts"; |
| 40 | import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts"; | 40 | import * as PreviewManager from "./preview/Manager.ts"; |
| 41 | import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts"; | 41 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 42 | import * as ProcessRunner from "./processRunner.ts"; | 42 | import * as ProcessRunner from "./processRunner.ts"; |
| 43 | import * as GitManager from "./git/GitManager.ts"; | 43 | import * as GitManager from "./git/GitManager.ts"; |
| 44 | import { KeybindingsLive } from "./keybindings.ts"; | 44 | import { KeybindingsLive } from "./keybindings.ts"; |
| ... | @@ -236,14 +236,17 @@ const CheckpointingLayerLive = Layer.empty.pipe( | ... | @@ -236,14 +236,17 @@ const CheckpointingLayerLive = Layer.empty.pipe( |
| 236 | Layer.provideMerge(CheckpointStoreLive.pipe(Layer.provide(VcsDriverRegistryLayerLive))), | 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 | const TerminalLayerLive = TerminalManagerLive.pipe( | 241 | const TerminalLayerLive = TerminalManagerLive.pipe( |
| 242 | Layer.provide(PtyAdapterLive), | 242 | Layer.provide(PtyAdapterLive), |
| 243 | Layer.provide(PortScannerLayerLive), | 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 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( | 251 | const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe( |
| 249 | Layer.provide(WorkspacePathsLive), | 252 | Layer.provide(WorkspacePathsLive), |
| ... | @@ -353,7 +356,7 @@ export const makeRoutesLayer = Layer.mergeAll( | ... | @@ -353,7 +356,7 @@ export const makeRoutesLayer = Layer.mergeAll( |
| 353 | staticAndDevRouteLayer, | 356 | staticAndDevRouteLayer, |
| 354 | websocketRpcRouteLayer, | 357 | websocketRpcRouteLayer, |
| 355 | ), | 358 | ), |
| 356 | McpHttpServerLive.pipe(Layer.provide(McpSessionRegistryLive)), | 359 | McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), |
| 357 | ).pipe(Layer.provide(browserApiCorsLayer)); | 360 | ).pipe(Layer.provide(browserApiCorsLayer)); |
| 358 | 361 | ||
| 359 | export const makeServerLayer = Layer.unwrap( | 362 | export const makeServerLayer = Layer.unwrap( |
apps/server/src/terminal/Layers/Manager.ts+2-2| ... | @@ -33,7 +33,7 @@ import { | ... | @@ -33,7 +33,7 @@ import { |
| 33 | terminalSessionsTotal, | 33 | terminalSessionsTotal, |
| 34 | } from "../../observability/Metrics.ts"; | 34 | } from "../../observability/Metrics.ts"; |
| 35 | import * as ProcessRunner from "../../processRunner.ts"; | 35 | import * as ProcessRunner from "../../processRunner.ts"; |
| 36 | import { PortDiscovery } from "../../preview/Services/PortScanner.ts"; | 36 | import * as PortScanner from "../../preview/PortScanner.ts"; |
| 37 | import { | 37 | import { |
| 38 | TerminalCwdError, | 38 | TerminalCwdError, |
| 39 | TerminalHistoryError, | 39 | TerminalHistoryError, |
| ... | @@ -996,7 +996,7 @@ interface TerminalManagerOptions { | ... | @@ -996,7 +996,7 @@ interface TerminalManagerOptions { |
| 996 | const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { | 996 | const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () { |
| 997 | const { terminalLogsDir } = yield* ServerConfig; | 997 | const { terminalLogsDir } = yield* ServerConfig; |
| 998 | const ptyAdapter = yield* PtyAdapter; | 998 | const ptyAdapter = yield* PtyAdapter; |
| 999 | const portDiscovery = yield* PortDiscovery; | 999 | const portDiscovery = yield* PortScanner.PortDiscovery; |
| 1000 | return yield* makeTerminalManagerWithOptions({ | 1000 | return yield* makeTerminalManagerWithOptions({ |
| 1001 | logsDir: terminalLogsDir, | 1001 | logsDir: terminalLogsDir, |
| 1002 | ptyAdapter, | 1002 | ptyAdapter, |
apps/server/src/ws.ts+7-5| ... | @@ -71,9 +71,9 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; | ... | @@ -71,9 +71,9 @@ import { ServerLifecycleEvents } from "./serverLifecycleEvents.ts"; |
| 71 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; | 71 | import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts"; |
| 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; | 72 | import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts"; |
| 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; | 73 | import { TerminalManager } from "./terminal/Services/Manager.ts"; |
| 74 | import { PreviewManager } from "./preview/Services/Manager.ts"; | 74 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| 75 | import { PortDiscovery } from "./preview/Services/PortScanner.ts"; | 75 | import * as PreviewManager from "./preview/Manager.ts"; |
| 76 | import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts"; | 76 | import * as PortScanner from "./preview/PortScanner.ts"; |
| 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; | 77 | import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts"; |
| 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; | 78 | import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts"; |
| 79 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; | 79 | import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts"; |
| ... | @@ -256,8 +256,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => | ... | @@ -256,8 +256,9 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => |
| 256 | const vcsProvisioning = yield* VcsProvisioningService; | 256 | const vcsProvisioning = yield* VcsProvisioningService; |
| 257 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; | 257 | const vcsStatusBroadcaster = yield* VcsStatusBroadcaster; |
| 258 | const terminalManager = yield* TerminalManager; | 258 | const terminalManager = yield* TerminalManager; |
| 259 | const previewManager = yield* PreviewManager; | 259 | const previewAutomationBroker = yield* PreviewAutomationBroker.PreviewAutomationBroker; |
| 260 | const portDiscovery = yield* PortDiscovery; | 260 | const previewManager = yield* PreviewManager.PreviewManager; |
| 261 | const portDiscovery = yield* PortScanner.PortDiscovery; | ||
| 261 | const providerRegistry = yield* ProviderRegistry; | 262 | const providerRegistry = yield* ProviderRegistry; |
| 262 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; | 263 | const providerMaintenanceRunner = yield* ProviderMaintenanceRunner.ProviderMaintenanceRunner; |
| 263 | const config = yield* ServerConfig; | 264 | const config = yield* ServerConfig; |
| ... | @@ -1573,6 +1574,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( | ... | @@ -1573,6 +1574,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( |
| 1573 | Effect.provide( | 1574 | Effect.provide( |
| 1574 | makeWsRpcLayer(session).pipe( | 1575 | makeWsRpcLayer(session).pipe( |
| 1575 | Layer.provideMerge(RpcSerialization.layerJson), | 1576 | Layer.provideMerge(RpcSerialization.layerJson), |
| 1577 | Layer.provide(PreviewAutomationBroker.layer), | ||
| 1576 | Layer.provide(ProviderMaintenanceRunner.layer), | 1578 | Layer.provide(ProviderMaintenanceRunner.layer), |
| 1577 | Layer.provide( | 1579 | Layer.provide( |
| 1578 | SourceControlDiscoveryLayer.layer.pipe( | 1580 | SourceControlDiscoveryLayer.layer.pipe( |