authorgravatar for 51714798+juliusmarminge@users.noreply.github.comJulius Marminge <51714798+juliusmarminge@users.noreply.github.com> 2026-06-12 16:55:49-07:00
committergravatar for me@theo.liTheo Browne <me@theo.li> 2026-06-13 22:18:37-07:00
logf7c422d5815e7be03415a87b30558c7bcf2cf8e9
tree9653f2b0f620d46530f781a5ba08d86727cc61f3
parentdd739c880d6c912130cac005ea773ae386560460

Refactor MCP services into top-level modules

- Move MCP session registry and preview broker out of `Layers/` and `Services/` - Update imports, tests, and server wiring to use the new module layout

40 files changed, 2353 insertions(+), 2429 deletions(-)

apps/server/src/mcp/Layers/McpHttpServer.test.ts deleted-163
...@@ -1,163 +0,0 @@
1import { expect, it } from "@effect/vitest";
2import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { McpSchema, McpServer } from "effect/unstable/ai";
7import { HttpServerResponse } from "effect/unstable/http";
8
9import { McpInvocationContext } from "../Services/McpInvocationContext.ts";
10import { normalizeMcpHttpResponse, PreviewToolkitRegistrationLive } from "./McpHttpServer.ts";
11import { previewAutomationBroker } from "./PreviewAutomationBroker.ts";
12
13const environmentId = EnvironmentId.make("environment-mcp-test");
14const threadId = ThreadId.make("thread-mcp-test");
15const tabId = PreviewTabId.make("tab-mcp-test");
16const 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};
25const 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});
34const TestLayer = PreviewToolkitRegistrationLive.pipe(
35 Layer.provideMerge(McpServer.McpServer.layer),
36);
37
38it("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
50it.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 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import { McpSchema, McpServer, Tool } from "effect/unstable/ai";
9import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
10
11import packageJson from "../../../package.json" with { type: "json" };
12import { McpInvocationContext } from "../Services/McpInvocationContext.ts";
13import { McpSessionRegistry } from "../Services/McpSessionRegistry.ts";
14import { PreviewToolkitHandlersLive } from "../toolkits/preview/handlers.ts";
15import { PreviewToolkit } from "../toolkits/preview/tools.ts";
16
17const 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
31export 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
43const 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
66const McpTransportLive = McpServer.layerHttp({
67 name: "T3 Code",
68 version: packageJson.version,
69 path: "/mcp",
70}).pipe(Layer.provide(McpAuthMiddlewareLive));
71
72export 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
163export 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 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import { HttpServer } from "effect/unstable/http";
6
7import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts";
8import { makeMcpSessionRegistry } from "./McpSessionRegistry.ts";
9
10const environmentId = EnvironmentId.make("environment-1");
11const 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});
15const fakeEnvironment = ServerEnvironment.of({
16 getEnvironmentId: Effect.succeed(environmentId),
17 getDescriptor: Effect.die("unused"),
18});
19
20const 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
31it.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
54it.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 @@
1import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Crypto from "effect/Crypto";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Ref from "effect/Ref";
6import { HttpServer } from "effect/unstable/http";
7
8import { ServerEnvironment } from "../../environment/Services/ServerEnvironment.ts";
9import type { McpInvocationScope } from "../Services/McpInvocationContext.ts";
10import {
11 McpSessionRegistry,
12 type McpCredentialRequest,
13 type McpIssuedCredential,
14 type McpSessionRegistryShape,
15} from "../Services/McpSessionRegistry.ts";
16
17interface CredentialRecord {
18 readonly tokenHash: string;
19 readonly scope: McpInvocationScope;
20 readonly lastUsedAt: number;
21}
22
23interface RegistryState {
24 readonly records: ReadonlyMap<string, CredentialRecord>;
25}
26
27export interface McpSessionRegistryOptions {
28 readonly idleTimeoutMs?: number;
29 readonly maximumLifetimeMs?: number;
30 readonly now?: () => number;
31}
32
33const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000;
34const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000;
35
36const bytesToHex = (bytes: Uint8Array): string =>
37 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
38
39const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
40
41export 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
143let activeMcpSessionRegistry: McpSessionRegistryShape | undefined;
144
145export 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
160export 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
169export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
170 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;
171
172export 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 @@
1import { expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 PreviewAutomationNoFocusedOwnerError,
5 ProviderInstanceId,
6 ThreadId,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Stream from "effect/Stream";
10
11import { makePreviewAutomationBroker } from "./PreviewAutomationBroker.ts";
12
13const 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
23it.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
57it.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
67it.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 @@
1import {
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";
15import * as Deferred from "effect/Deferred";
16import * as Effect from "effect/Effect";
17import * as Layer from "effect/Layer";
18import * as Option from "effect/Option";
19import * as Queue from "effect/Queue";
20import * as Ref from "effect/Ref";
21import * as Stream from "effect/Stream";
22
23import {
24 PreviewAutomationBroker,
25 type PreviewAutomationBrokerShape,
26} from "../Services/PreviewAutomationBroker.ts";
27
28interface 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
37interface PendingRequest {
38 readonly clientId: string;
39 readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>;
40}
41
42interface BrokerState {
43 readonly clients: ReadonlyMap<string, ClientConnection>;
44 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;
45 readonly pending: ReadonlyMap<string, PendingRequest>;
46}
47
48const 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
94export 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
265export const previewAutomationBroker = makePreviewAutomationBroker();
266
267export 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 @@
1import { expect, it } from "@effect/vitest";
2import { EnvironmentId, PreviewTabId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Stream from "effect/Stream";
6import { McpSchema, McpServer } from "effect/unstable/ai";
7import { HttpServerResponse } from "effect/unstable/http";
8
9import * as McpHttpServer from "./McpHttpServer.ts";
10import * as McpInvocationContext from "./McpInvocationContext.ts";
11import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
12
13const environmentId = EnvironmentId.make("environment-mcp-test");
14const threadId = ThreadId.make("thread-mcp-test");
15const tabId = PreviewTabId.make("tab-mcp-test");
16const 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};
25const 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});
34const TestLayer = McpHttpServer.PreviewToolkitRegistrationLive.pipe(
35 Layer.provideMerge(McpServer.McpServer.layer),
36 Layer.provideMerge(PreviewAutomationBroker.layer),
37);
38
39it("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
51it.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 @@
1import * as Cause from "effect/Cause";
2import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";
8import { McpSchema, McpServer, Tool } from "effect/unstable/ai";
9import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http";
10
11import packageJson from "../../package.json" with { type: "json" };
12import * as McpInvocationContext from "./McpInvocationContext.ts";
13import * as McpSessionRegistry from "./McpSessionRegistry.ts";
14import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
15import { PreviewToolkitHandlersLive } from "./toolkits/preview/handlers.ts";
16import { PreviewToolkit } from "./toolkits/preview/tools.ts";
17
18const 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
32export 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
44const 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
66const McpTransportLive = McpServer.layerHttp({
67 name: "T3 Code",
68 version: packageJson.version,
69 path: "/mcp",
70}).pipe(Layer.provide(McpAuthMiddlewareLive));
71
72export 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
163export 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 @@
1import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import { PreviewAutomationUnavailableError } from "@t3tools/contracts";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5
6export type McpCapability = "preview";
7
8export 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
18export class McpInvocationContext extends Context.Service<
19 McpInvocationContext,
20 McpInvocationScope
21>()("t3/mcp/McpInvocationContext") {}
22
23export 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 @@
1import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2
3export 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
12const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();
13
14export function setMcpProviderSession(config: McpProviderSessionConfig): void {
15 sessionsByThread.set(config.threadId, config);
16}
17
18export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined {
19 return sessionsByThread.get(threadId);
20}
21
22export function clearMcpProviderSession(threadId: ThreadId): void {
23 sessionsByThread.delete(threadId);
24}
25
26export function clearAllMcpProviderSessions(): void {
27 sessionsByThread.clear();
28}
apps/server/src/mcp/McpSessionRegistry.test.ts created+66
...@@ -0,0 +1,66 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { expect, it } from "@effect/vitest";
3import { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
4import * as Effect from "effect/Effect";
5import { HttpServer } from "effect/unstable/http";
6
7import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
8import * as McpSessionRegistry from "./McpSessionRegistry.ts";
9
10const environmentId = EnvironmentId.make("environment-1");
11const 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});
15const fakeEnvironment = ServerEnvironment.of({
16 getEnvironmentId: Effect.succeed(environmentId),
17 getDescriptor: Effect.die("unused"),
18});
19
20const 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
31it.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
54it.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 @@
1import { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import * as Crypto from "effect/Crypto";
4import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";
6import * as Ref from "effect/Ref";
7import { HttpServer } from "effect/unstable/http";
8
9import { ServerEnvironment } from "../environment/Services/ServerEnvironment.ts";
10import * as McpInvocationContext from "./McpInvocationContext.ts";
11import * as McpProviderSession from "./McpProviderSession.ts";
12
13export interface McpCredentialRequest {
14 readonly threadId: ThreadId;
15 readonly providerInstanceId: ProviderInstanceId;
16}
17
18export interface McpIssuedCredential {
19 readonly config: McpProviderSession.McpProviderSessionConfig;
20 readonly expiresAt: number;
21}
22
23export 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
33export class McpSessionRegistry extends Context.Service<
34 McpSessionRegistry,
35 McpSessionRegistryShape
36>()("t3/mcp/McpSessionRegistry") {}
37
38interface CredentialRecord {
39 readonly tokenHash: string;
40 readonly scope: McpInvocationContext.McpInvocationScope;
41 readonly lastUsedAt: number;
42}
43
44interface RegistryState {
45 readonly records: ReadonlyMap<string, CredentialRecord>;
46}
47
48export interface McpSessionRegistryOptions {
49 readonly idleTimeoutMs?: number;
50 readonly maximumLifetimeMs?: number;
51 readonly now?: () => number;
52}
53
54const DEFAULT_IDLE_TIMEOUT_MS = 30 * 60 * 1_000;
55const DEFAULT_MAXIMUM_LIFETIME_MS = 8 * 60 * 60 * 1_000;
56
57const bytesToHex = (bytes: Uint8Array): string =>
58 Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0")).join("");
59
60const tokenFromBytes = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64url");
61
62const 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
170let activeMcpSessionRegistry: McpSessionRegistryShape | undefined;
171
172export 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
187export const makeForTest = make;
188
189export 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
198export const revokeActiveMcpThread = (threadId: ThreadId): Effect.Effect<void> =>
199 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeThread(threadId) : Effect.void;
200
201export const revokeAllActiveMcpCredentials = (): Effect.Effect<void> =>
202 activeMcpSessionRegistry ? activeMcpSessionRegistry.revokeAll : Effect.void;
apps/server/src/mcp/PreviewAutomationBroker.test.ts created+89
...@@ -0,0 +1,89 @@
1import { expect, it } from "@effect/vitest";
2import {
3 EnvironmentId,
4 PreviewAutomationNoFocusedOwnerError,
5 ProviderInstanceId,
6 ThreadId,
7} from "@t3tools/contracts";
8import * as Effect from "effect/Effect";
9import * as Stream from "effect/Stream";
10
11import * as PreviewAutomationBroker from "./PreviewAutomationBroker.ts";
12
13const 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
23it.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
57it.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
67it.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 @@
1import {
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";
18import * as Context from "effect/Context";
19import * as Deferred from "effect/Deferred";
20import * as Effect from "effect/Effect";
21import * as Layer from "effect/Layer";
22import * as Option from "effect/Option";
23import * as Queue from "effect/Queue";
24import * as Ref from "effect/Ref";
25import * as Stream from "effect/Stream";
26
27import * as McpInvocationContext from "./McpInvocationContext.ts";
28
29export 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
37export 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
51export class PreviewAutomationBroker extends Context.Service<
52 PreviewAutomationBroker,
53 PreviewAutomationBrokerShape
54>()("t3/mcp/PreviewAutomationBroker") {}
55
56interface 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
65interface PendingRequest {
66 readonly clientId: string;
67 readonly deferred: Deferred.Deferred<unknown, PreviewAutomationError>;
68}
69
70interface BrokerState {
71 readonly clients: ReadonlyMap<string, ClientConnection>;
72 readonly owners: ReadonlyMap<string, PreviewAutomationOwner>;
73 readonly pending: ReadonlyMap<string, PendingRequest>;
74}
75
76const 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
122const 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
302const service = makeService();
303const make = Effect.succeed(service);
304export const layer = Layer.effect(PreviewAutomationBroker, make);
305
306export const makeForTest = Effect.sync(makeService);
apps/server/src/mcp/Services/McpInvocationContext.ts deleted-33
...@@ -1,33 +0,0 @@
1import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import { PreviewAutomationUnavailableError } from "@t3tools/contracts";
3import * as Context from "effect/Context";
4import * as Effect from "effect/Effect";
5
6export type McpCapability = "preview";
7
8export 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
18export class McpInvocationContext extends Context.Service<
19 McpInvocationContext,
20 McpInvocationScope
21>()("t3/mcp/Services/McpInvocationContext") {}
22
23export 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 @@
1import type { EnvironmentId, ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3
4export 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
13export class McpProviderSession extends Context.Service<
14 McpProviderSession,
15 McpProviderSessionConfig
16>()("t3/mcp/Services/McpProviderSession") {}
17
18const sessionsByThread = new Map<ThreadId, McpProviderSessionConfig>();
19
20export function setMcpProviderSession(config: McpProviderSessionConfig): void {
21 sessionsByThread.set(config.threadId, config);
22}
23
24export function readMcpProviderSession(threadId: ThreadId): McpProviderSessionConfig | undefined {
25 return sessionsByThread.get(threadId);
26}
27
28export function clearMcpProviderSession(threadId: ThreadId): void {
29 sessionsByThread.delete(threadId);
30}
31
32export function clearAllMcpProviderSessions(): void {
33 sessionsByThread.clear();
34}
apps/server/src/mcp/Services/McpSessionRegistry.ts deleted-29
...@@ -1,29 +0,0 @@
1import type { ProviderInstanceId, ThreadId } from "@t3tools/contracts";
2import * as Context from "effect/Context";
3import type * as Effect from "effect/Effect";
4
5import type { McpInvocationScope } from "./McpInvocationContext.ts";
6import type { McpProviderSessionConfig } from "./McpProviderSession.ts";
7
8export interface McpCredentialRequest {
9 readonly threadId: ThreadId;
10 readonly providerInstanceId: ProviderInstanceId;
11}
12
13export interface McpIssuedCredential {
14 readonly config: McpProviderSessionConfig;
15 readonly expiresAt: number;
16}
17
18export 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
26export 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 @@
1import type {
2 PreviewAutomationError,
3 PreviewAutomationOperation,
4 PreviewAutomationOwner,
5 PreviewAutomationRequest,
6 PreviewAutomationResponse,
7 PreviewTabId,
8} from "@t3tools/contracts";
9import * as Context from "effect/Context";
10import type * as Effect from "effect/Effect";
11import type * as Stream from "effect/Stream";
12
13import type { McpInvocationScope } from "./McpInvocationContext.ts";
14
15export interface PreviewAutomationInvokeInput {
16 readonly scope: McpInvocationScope;
17 readonly operation: PreviewAutomationOperation;
18 readonly input: unknown;
19 readonly tabId?: PreviewTabId;
20 readonly timeoutMs?: number;
21}
22
23export 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
37export 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";
99
10import { requireMcpCapability } from "../../Services/McpInvocationContext.ts";10import * as McpInvocationContext from "../../McpInvocationContext.ts";
11import { previewAutomationBroker } from "../../Layers/PreviewAutomationBroker.ts";11import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
12import { PreviewToolkit } from "./tools.ts";12import { PreviewToolkit } from "./tools.ts";
1313
14const invoke = <A>(14const 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").McpInvocationContext21 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});
3232
33export const PreviewToolkitHandlersLive = PreviewToolkit.toLayer({33export 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 {
16import * as Schema from "effect/Schema";16import * as Schema from "effect/Schema";
17import { Tool, Toolkit } from "effect/unstable/ai";17import { Tool, Toolkit } from "effect/unstable/ai";
1818
19import { McpInvocationContext } from "../../Services/McpInvocationContext.ts";19import * as McpInvocationContext from "../../McpInvocationContext.ts";
20import * as PreviewAutomationBroker from "../../PreviewAutomationBroker.ts";
21
22const dependencies = [
23 McpInvocationContext.McpInvocationContext,
24 PreviewAutomationBroker.PreviewAutomationBroker,
25];
2026
21const browserTool = <T extends Tool.Any>(tool: T): T =>27const 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);
6571
...@@ -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);
7581
...@@ -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);
8591
...@@ -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);
95101
...@@ -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);
105111
...@@ -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);
115121
...@@ -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);
126132
...@@ -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);
136142
...@@ -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);
146152
...@@ -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);
155161
apps/server/src/preview/Layers/Manager.test.ts deleted-261
...@@ -1,261 +0,0 @@
1import { it } from "@effect/vitest";
2import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
3import { Effect, PubSub } from "effect";
4import { expect } from "vite-plus/test";
5
6import { PreviewManager } from "../Services/Manager.ts";
7import { PreviewManagerLive } from "./Manager.ts";
8
9const DRAIN_LIMIT = 100;
10
11interface 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 */
21let nextThreadId = 0;
22const 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 */
29const 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
38it.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 */
12import {
13 type PreviewEvent,
14 PreviewInvalidUrlError,
15 type PreviewListResult,
16 PreviewSessionLookupError,
17 type PreviewSessionSnapshot,
18} from "@t3tools/contracts";
19import {
20 newPreviewTabId,
21 normalizePreviewUrl,
22 PreviewUrlNormalizationError,
23} from "@t3tools/shared/preview";
24import { DateTime, Effect, Layer, PubSub, Stream, SynchronizedRef } from "effect";
25
26import { PreviewManager, type PreviewManagerShape } from "../Services/Manager.ts";
27
28interface PreviewSessionState {
29 readonly threadId: string;
30 readonly tabId: string;
31 readonly snapshot: PreviewSessionSnapshot;
32}
33
34interface ManagerState {
35 /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */
36 readonly sessions: ReadonlyMap<string, PreviewSessionState>;
37}
38
39const initialState: ManagerState = { sessions: new Map() };
40
41const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`;
42
43const 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
54const 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
69const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
70
71const 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
86const 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
99export 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
321export const PreviewManagerLive = Layer.effect(PreviewManager, makePreviewManager);
apps/server/src/preview/Layers/PortScanner.test.ts deleted-241
...@@ -1,241 +0,0 @@
1import * as net from "node:net";
2
3import { it as effectIt } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";
5import { Effect, Layer } from "effect";
6import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
7
8import { COMMON_DEV_PORTS, PortDiscovery } from "../Services/PortScanner.ts";
9import { ProcessRunner } from "../../processRunner.ts";
10import { __testing, PortDiscoveryLive } from "./PortScanner.ts";
11
12const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } =
13 __testing;
14const TestProcessRunner = Layer.succeed(ProcessRunner, {
15 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
16});
17const TestPortDiscoveryLive = PortDiscoveryLive.pipe(Layer.provide(TestProcessRunner));
18
19describe("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
52describe("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
128describe("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
148describe("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 */
184describe("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 */
14import * as net from "node:net";
15
16import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
18import { Cause, Duration, Effect, Layer, Ref, Schedule } from "effect";
19
20import { ProcessRunner } from "../../processRunner.ts";
21import {
22 COMMON_DEV_PORTS,
23 PortDiscovery,
24 type PortDiscoveryShape,
25} from "../Services/PortScanner.ts";
26
27const POLL_INTERVAL = Duration.seconds(3);
28const TCP_PROBE_TIMEOUT_MS = 200;
29const LSOF_TIMEOUT_MS = 5_000;
30const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
31
32type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;
33
34interface ScannerState {
35 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
36}
37
38interface TerminalProcessOwner {
39 readonly threadId: ThreadId;
40 readonly terminalId: string;
41}
42
43const terminalOwnerKey = (owner: {
44 readonly threadId: string;
45 readonly terminalId: string;
46}): string => `${owner.threadId}\u0000${owner.terminalId}`;
47
48const 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
90const 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
105const 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
131const 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
148const 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
165const 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
189export 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
325export const PortDiscoveryLive = Layer.effect(PortDiscovery, makePortDiscovery);
326
327/** Exposed for tests. */
328export const __testing = {
329 parseLsofOutput,
330 parsePortFromLsofName,
331 parseWindowsListenerOutput,
332 serversEqual,
333};
apps/server/src/preview/Manager.test.ts created+260
...@@ -0,0 +1,260 @@
1import { it } from "@effect/vitest";
2import { type PreviewEvent, ThreadId } from "@t3tools/contracts";
3import { Effect, PubSub } from "effect";
4import { expect } from "vite-plus/test";
5
6import * as PreviewManager from "./Manager.ts";
7
8const DRAIN_LIMIT = 100;
9
10interface 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 */
20let nextThreadId = 0;
21const 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 */
28const 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
37it.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 */
12import {
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";
26import {
27 newPreviewTabId,
28 normalizePreviewUrl,
29 PreviewUrlNormalizationError,
30} from "@t3tools/shared/preview";
31import {
32 Context,
33 DateTime,
34 Effect,
35 Layer,
36 PubSub,
37 type Scope,
38 Stream,
39 SynchronizedRef,
40} from "effect";
41
42export 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
55export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()(
56 "t3/preview/Manager/PreviewManager",
57) {}
58
59interface PreviewSessionState {
60 readonly threadId: string;
61 readonly tabId: string;
62 readonly snapshot: PreviewSessionSnapshot;
63}
64
65interface ManagerState {
66 /** All sessions across every thread, keyed by `${threadId}\u0000${tabId}`. */
67 readonly sessions: ReadonlyMap<string, PreviewSessionState>;
68}
69
70const initialState: ManagerState = { sessions: new Map() };
71
72const compositeKey = (threadId: string, tabId: string): string => `${threadId}\u0000${tabId}`;
73
74const 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
85const 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
100const currentIsoTimestamp = DateTime.now.pipe(Effect.map(DateTime.formatIso));
101
102const 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
117const 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
130const 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
362export const layer = Layer.effect(PreviewManager, make);
apps/server/src/preview/PortScanner.test.ts created+240
...@@ -0,0 +1,240 @@
1import * as net from "node:net";
2
3import { it as effectIt } from "@effect/vitest";
4import { ThreadId } from "@t3tools/contracts";
5import { Effect, Layer } from "effect";
6import { afterEach, beforeEach, describe, expect, it } from "vite-plus/test";
7
8import { ProcessRunner } from "../processRunner.ts";
9import * as PortScanner from "./PortScanner.ts";
10
11const { parseLsofOutput, parsePortFromLsofName, parseWindowsListenerOutput, serversEqual } =
12 PortScanner.__testing;
13const TestProcessRunner = Layer.succeed(ProcessRunner, {
14 run: () => Effect.die("ProcessRunner should not be used by Windows TCP probe tests"),
15});
16const TestPortDiscoveryLive = PortScanner.layer.pipe(Layer.provide(TestProcessRunner));
17
18describe("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
51describe("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
127describe("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
147describe("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 */
183describe("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 */
14import * as net from "node:net";
15
16import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
18import { Cause, Context, Duration, Effect, Layer, Ref, Schedule } from "effect";
19
20import { ProcessRunner } from "../processRunner.ts";
21
22export 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
39export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()(
40 "t3/preview/PortScanner/PortDiscovery",
41) {}
42
43export 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
47const POLL_INTERVAL = Duration.seconds(3);
48const TCP_PROBE_TIMEOUT_MS = 200;
49const LSOF_TIMEOUT_MS = 5_000;
50const WINDOWS_LISTENER_TIMEOUT_MS = 5_000;
51
52type Listener = (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>;
53
54interface ScannerState {
55 readonly lastSnapshot: ReadonlyArray<DiscoveredLocalServer>;
56}
57
58interface TerminalProcessOwner {
59 readonly threadId: ThreadId;
60 readonly terminalId: string;
61}
62
63const terminalOwnerKey = (owner: {
64 readonly threadId: string;
65 readonly terminalId: string;
66}): string => `${owner.threadId}\u0000${owner.terminalId}`;
67
68const 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
110const 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
125const 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
151const 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
168const 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
185const 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
209const 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
352export const layer = Layer.effect(PortDiscovery, make);
353
354/** Exposed for tests. */
355export 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 */
17import {
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";
29import { Context, type Effect, type PubSub, type Scope, type Stream } from "effect";
30
31export 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
92export 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 */
11import type { DiscoveredLocalServer } from "@t3tools/contracts";
12import { Context, type Effect } from "effect";
13
14export 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
39export 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. */
44export 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";
6969
70import { resolveAttachmentPath } from "../../attachmentStore.ts";70import { resolveAttachmentPath } from "../../attachmentStore.ts";
71import { ServerConfig } from "../../config.ts";71import { ServerConfig } from "../../config.ts";
72import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";72import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
73import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";73import { makeClaudeEnvironment } from "../Drivers/ClaudeHome.ts";
74import {74import {
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";
3939
40import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";40import { getModelSelectionStringOptionValue } from "@t3tools/shared/model";
41import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";41import { getCodexServiceTierOptionValue } from "../../codexModelOptions.ts";
42import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";42import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
4343
44import {44import {
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 === boundInstanceId1386 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";
4242
43import { resolveAttachmentPath } from "../../attachmentStore.ts";43import { resolveAttachmentPath } from "../../attachmentStore.ts";
44import { ServerConfig } from "../../config.ts";44import { ServerConfig } from "../../config.ts";
45import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";45import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
46import {46import {
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.resolveSettings531 ? yield* options.resolveSettings
532 : cursorSettings;532 : cursorSettings;
533533
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";
3333
34import { resolveAttachmentPath } from "../../attachmentStore.ts";34import { resolveAttachmentPath } from "../../attachmentStore.ts";
35import { ServerConfig } from "../../config.ts";35import { ServerConfig } from "../../config.ts";
36import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";36import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
37import {37import {
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 });
377377
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";
2626
27import { resolveAttachmentPath } from "../../attachmentStore.ts";27import { resolveAttachmentPath } from "../../attachmentStore.ts";
28import { ServerConfig } from "../../config.ts";28import { ServerConfig } from "../../config.ts";
29import { readMcpProviderSession } from "../../mcp/Services/McpProviderSession.ts";29import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
30import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";30import { type EventNdjsonLogger, makeEventNdjsonLogger } from "./EventNdjsonLogger.ts";
31import {31import {
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 {
56import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";56import { type EventNdjsonLogger } from "./EventNdjsonLogger.ts";
57import { ProviderEventLoggers } from "./ProviderEventLoggers.ts";57import { ProviderEventLoggers } from "./ProviderEventLoggers.ts";
58import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";58import { AnalyticsService } from "../../telemetry/Services/AnalyticsService.ts";
59import {59import * as McpProviderSession from "../../mcp/McpProviderSession.ts";
60 issueActiveMcpCredential,60import * as McpSessionRegistry from "../../mcp/McpSessionRegistry.ts";
61 revokeActiveMcpThread,
62 revokeAllActiveMcpCredentials,
63} from "../../mcp/Layers/McpSessionRegistry.ts";
64import {
65 clearAllMcpProviderSessions,
66 clearMcpProviderSession,
67 setMcpProviderSession,
68} from "../../mcp/Services/McpProviderSession.ts";
69const isModelSelection = Schema.is(ModelSelection);61const isModelSelection = Schema.is(ModelSelection);
7062
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 );
235229
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
99import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";99import { ServerRuntimeStartup, type ServerRuntimeStartupShape } from "./serverRuntimeStartup.ts";
100import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";100import { ServerSettingsService, type ServerSettingsShape } from "./serverSettings.ts";
101import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";101import { TerminalManager, type TerminalManagerShape } from "./terminal/Services/Manager.ts";
102import { PreviewManager } from "./preview/Services/Manager.ts";102import * as PreviewManager from "./preview/Manager.ts";
103import { PortDiscovery } from "./preview/Services/PortScanner.ts";103import * as PortScanner from "./preview/PortScanner.ts";
104import {104import {
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";
35import * as TextGeneration from "./textGeneration/TextGeneration.ts";35import * as TextGeneration from "./textGeneration/TextGeneration.ts";
36import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";36import { ProviderInstanceRegistryHydrationLive } from "./provider/Layers/ProviderInstanceRegistryHydration.ts";
37import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";37import { TerminalManagerLive } from "./terminal/Layers/Manager.ts";
38import { PreviewManagerLive } from "./preview/Layers/Manager.ts";38import * as McpHttpServer from "./mcp/McpHttpServer.ts";
39import { PortDiscoveryLive } from "./preview/Layers/PortScanner.ts";39import * as McpSessionRegistry from "./mcp/McpSessionRegistry.ts";
40import { McpHttpServerLive } from "./mcp/Layers/McpHttpServer.ts";40import * as PreviewManager from "./preview/Manager.ts";
41import { McpSessionRegistryLive } from "./mcp/Layers/McpSessionRegistry.ts";41import * as PortScanner from "./preview/PortScanner.ts";
42import * as ProcessRunner from "./processRunner.ts";42import * as ProcessRunner from "./processRunner.ts";
43import * as GitManager from "./git/GitManager.ts";43import * as GitManager from "./git/GitManager.ts";
44import { KeybindingsLive } from "./keybindings.ts";44import { 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);
238238
239const PortScannerLayerLive = PortDiscoveryLive.pipe(Layer.provide(ProcessRunner.layer));239const PortScannerLayerLive = PortScanner.layer.pipe(Layer.provide(ProcessRunner.layer));
240240
241const TerminalLayerLive = TerminalManagerLive.pipe(241const TerminalLayerLive = TerminalManagerLive.pipe(
242 Layer.provide(PtyAdapterLive),242 Layer.provide(PtyAdapterLive),
243 Layer.provide(PortScannerLayerLive),243 Layer.provide(PortScannerLayerLive),
244);244);
245245
246const PreviewLayerLive = Layer.mergeAll(PreviewManagerLive, PortScannerLayerLive);246const PreviewLayerLive = Layer.empty.pipe(
247 Layer.provideMerge(PreviewManager.layer),
248 Layer.provideMerge(PortScannerLayerLive),
249);
247250
248const WorkspaceEntriesLayerLive = WorkspaceEntriesLive.pipe(251const 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));
358361
359export const makeServerLayer = Layer.unwrap(362export 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";
35import * as ProcessRunner from "../../processRunner.ts";35import * as ProcessRunner from "../../processRunner.ts";
36import { PortDiscovery } from "../../preview/Services/PortScanner.ts";36import * as PortScanner from "../../preview/PortScanner.ts";
37import {37import {
38 TerminalCwdError,38 TerminalCwdError,
39 TerminalHistoryError,39 TerminalHistoryError,
...@@ -996,7 +996,7 @@ interface TerminalManagerOptions {...@@ -996,7 +996,7 @@ interface TerminalManagerOptions {
996const makeTerminalManager = Effect.fn("makeTerminalManager")(function* () {996const 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";
71import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";71import { ServerRuntimeStartup } from "./serverRuntimeStartup.ts";
72import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";72import { redactServerSettingsForClient, ServerSettingsService } from "./serverSettings.ts";
73import { TerminalManager } from "./terminal/Services/Manager.ts";73import { TerminalManager } from "./terminal/Services/Manager.ts";
74import { PreviewManager } from "./preview/Services/Manager.ts";74import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
75import { PortDiscovery } from "./preview/Services/PortScanner.ts";75import * as PreviewManager from "./preview/Manager.ts";
76import { previewAutomationBroker } from "./mcp/Layers/PreviewAutomationBroker.ts";76import * as PortScanner from "./preview/PortScanner.ts";
77import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";77import { WorkspaceEntries } from "./workspace/Services/WorkspaceEntries.ts";
78import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";78import { WorkspaceFileSystem } from "./workspace/Services/WorkspaceFileSystem.ts";
79import { WorkspacePathOutsideRootError } from "./workspace/Services/WorkspacePaths.ts";79import { 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(