authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-19 21:09:22-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-19 21:09:22-07:00
log5c1ac922574340be4560eacf928237929497d11d
treeabd561916532dc5a36af7fd548811c2e98868d66
parent8bfdacf206608c0e14b1c5471dfa1f482a452acc
signature Signed by PGP key B5690EEEBB952194

[codex] normalize server process and preview Effect services (#3191)

Co-authored-by: codex <codex@users.noreply.github.com>

8 files changed, 297 insertions(+), 272 deletions(-)

apps/server/src/environment/Layers/ServerEnvironmentLabel.test.ts+5-5
...@@ -5,15 +5,15 @@ import * as Layer from "effect/Layer";...@@ -5,15 +5,15 @@ import * as Layer from "effect/Layer";
5import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";5import { HostProcessHostname, HostProcessPlatform } from "@t3tools/shared/hostProcess";
6import { vi } from "vite-plus/test";6import { vi } from "vite-plus/test";
77
8import { ProcessRunner, ProcessSpawnError, type ProcessRunnerShape } from "../../processRunner.ts";8import * as ProcessRunner from "../../processRunner.ts";
9import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";9import { resolveServerEnvironmentLabel } from "./ServerEnvironmentLabel.ts";
10import { ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcessSpawner } from "effect/unstable/process";
1111
12const runMock = vi.fn<ProcessRunnerShape["run"]>();12const runMock = vi.fn<ProcessRunner.ProcessRunner["Service"]["run"]>();
1313
14const ProcessRunnerTest = Layer.succeed(14const ProcessRunnerTest = Layer.succeed(
15 ProcessRunner,15 ProcessRunner.ProcessRunner,
16 ProcessRunner.of({16 ProcessRunner.ProcessRunner.of({
17 run: (input) => runMock(input),17 run: (input) => runMock(input),
18 }),18 }),
19);19);
...@@ -136,7 +136,7 @@ describe("resolveServerEnvironmentLabel", () => {...@@ -136,7 +136,7 @@ describe("resolveServerEnvironmentLabel", () => {
136 Effect.gen(function* () {136 Effect.gen(function* () {
137 runMock.mockReturnValueOnce(137 runMock.mockReturnValueOnce(
138 Effect.fail(138 Effect.fail(
139 new ProcessSpawnError({139 new ProcessRunner.ProcessSpawnError({
140 command: "scutil",140 command: "scutil",
141 args: ["--get", "ComputerName"],141 args: ["--get", "ComputerName"],
142 cause: new Error("spawn scutil ENOENT"),142 cause: new Error("spawn scutil ENOENT"),
apps/server/src/mcp/PreviewAutomationBroker.test.ts+8-8
...@@ -37,7 +37,7 @@ const makeOwner = (overrides: Partial<PreviewAutomationOwner> = {}): PreviewAuto...@@ -37,7 +37,7 @@ const makeOwner = (overrides: Partial<PreviewAutomationOwner> = {}): PreviewAuto
37it.effect("atomically registers a connected owner and correlates its response", () =>37it.effect("atomically registers a connected owner and correlates its response", () =>
38 Effect.scoped(38 Effect.scoped(
39 Effect.gen(function* () {39 Effect.gen(function* () {
40 const broker = yield* PreviewAutomationBroker.__testing.make;40 const broker = yield* PreviewAutomationBroker.make;
41 const requests = yield* broker.connect(makeOwner());41 const requests = yield* broker.connect(makeOwner());
42 yield* Stream.runForEach(requests, (request) =>42 yield* Stream.runForEach(requests, (request) =>
43 broker.respond({43 broker.respond({
...@@ -61,7 +61,7 @@ it.effect("atomically registers a connected owner and correlates its response",...@@ -61,7 +61,7 @@ it.effect("atomically registers a connected owner and correlates its response",
6161
62it.effect("rejects calls when no focused owner exists", () =>62it.effect("rejects calls when no focused owner exists", () =>
63 Effect.gen(function* () {63 Effect.gen(function* () {
64 const broker = yield* PreviewAutomationBroker.__testing.make;64 const broker = yield* PreviewAutomationBroker.make;
65 const error = yield* broker65 const error = yield* broker
66 .invoke<void>({ scope, operation: "status", input: {} })66 .invoke<void>({ scope, operation: "status", input: {} })
67 .pipe(Effect.flip);67 .pipe(Effect.flip);
...@@ -72,7 +72,7 @@ it.effect("rejects calls when no focused owner exists", () =>...@@ -72,7 +72,7 @@ it.effect("rejects calls when no focused owner exists", () =>
72it.effect("routes interactive commands to a hidden durable browser host", () =>72it.effect("routes interactive commands to a hidden durable browser host", () =>
73 Effect.scoped(73 Effect.scoped(
74 Effect.gen(function* () {74 Effect.gen(function* () {
75 const broker = yield* PreviewAutomationBroker.__testing.make;75 const broker = yield* PreviewAutomationBroker.make;
76 const requests = yield* broker.connect(76 const requests = yield* broker.connect(
77 makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }),77 makeOwner({ clientId: "client-hidden", tabId: "tab-hidden" }),
78 );78 );
...@@ -89,7 +89,7 @@ it.effect("routes interactive commands to a hidden durable browser host", () =>...@@ -89,7 +89,7 @@ it.effect("routes interactive commands to a hidden durable browser host", () =>
89it.effect("lets the browser host resolve an active tab that has not been reported yet", () =>89it.effect("lets the browser host resolve an active tab that has not been reported yet", () =>
90 Effect.scoped(90 Effect.scoped(
91 Effect.gen(function* () {91 Effect.gen(function* () {
92 const broker = yield* PreviewAutomationBroker.__testing.make;92 const broker = yield* PreviewAutomationBroker.make;
93 const requests = yield* broker.connect(makeOwner({ tabId: null }));93 const requests = yield* broker.connect(makeOwner({ tabId: null }));
94 let routedTabId: string | undefined;94 let routedTabId: string | undefined;
95 yield* Stream.runForEach(requests, (request) => {95 yield* Stream.runForEach(requests, (request) => {
...@@ -108,7 +108,7 @@ it.effect("lets the browser host resolve an active tab that has not been reporte...@@ -108,7 +108,7 @@ it.effect("lets the browser host resolve an active tab that has not been reporte
108it.effect("preserves current owner metadata when its request stream reconnects", () =>108it.effect("preserves current owner metadata when its request stream reconnects", () =>
109 Effect.scoped(109 Effect.scoped(
110 Effect.gen(function* () {110 Effect.gen(function* () {
111 const broker = yield* PreviewAutomationBroker.__testing.make;111 const broker = yield* PreviewAutomationBroker.make;
112 const firstRequests = yield* broker.connect(makeOwner());112 const firstRequests = yield* broker.connect(makeOwner());
113 yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped);113 yield* Stream.runDrain(firstRequests).pipe(Effect.forkScoped);
114 yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true }));114 yield* broker.reportOwner(makeOwner({ tabId: "tab-current", visible: true }));
...@@ -131,7 +131,7 @@ it.effect("preserves current owner metadata when its request stream reconnects",...@@ -131,7 +131,7 @@ it.effect("preserves current owner metadata when its request stream reconnects",
131it.effect("ignores stale owner cleanup after the client moves to another thread", () =>131it.effect("ignores stale owner cleanup after the client moves to another thread", () =>
132 Effect.scoped(132 Effect.scoped(
133 Effect.gen(function* () {133 Effect.gen(function* () {
134 const broker = yield* PreviewAutomationBroker.__testing.make;134 const broker = yield* PreviewAutomationBroker.make;
135 const requests = yield* broker.connect(makeOwner());135 const requests = yield* broker.connect(makeOwner());
136 yield* Stream.runForEach(requests, (request) =>136 yield* Stream.runForEach(requests, (request) =>
137 broker.respond({ requestId: request.requestId, ok: true }),137 broker.respond({ requestId: request.requestId, ok: true }),
...@@ -152,7 +152,7 @@ it.effect("ignores stale owner cleanup after the client moves to another thread"...@@ -152,7 +152,7 @@ it.effect("ignores stale owner cleanup after the client moves to another thread"
152it.effect("fails requests assigned to a browser stream when that stream reconnects", () =>152it.effect("fails requests assigned to a browser stream when that stream reconnects", () =>
153 Effect.scoped(153 Effect.scoped(
154 Effect.gen(function* () {154 Effect.gen(function* () {
155 const broker = yield* PreviewAutomationBroker.__testing.make;155 const broker = yield* PreviewAutomationBroker.make;
156 const _requests = yield* broker.connect(makeOwner());156 const _requests = yield* broker.connect(makeOwner());
157 const pending = yield* broker157 const pending = yield* broker
158 .invoke<void>({ scope, operation: "status", input: {} })158 .invoke<void>({ scope, operation: "status", input: {} })
...@@ -170,7 +170,7 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec...@@ -170,7 +170,7 @@ it.effect("fails requests assigned to a browser stream when that stream reconnec
170it.effect("falls back to an older connected owner when a newer report is not connected", () =>170it.effect("falls back to an older connected owner when a newer report is not connected", () =>
171 Effect.scoped(171 Effect.scoped(
172 Effect.gen(function* () {172 Effect.gen(function* () {
173 const broker = yield* PreviewAutomationBroker.__testing.make;173 const broker = yield* PreviewAutomationBroker.make;
174 const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" }));174 const requests = yield* broker.connect(makeOwner({ clientId: "client-connected" }));
175 yield* Stream.runForEach(requests, (request) =>175 yield* Stream.runForEach(requests, (request) =>
176 broker.respond({ requestId: request.requestId, ok: true, result: "connected" }),176 broker.respond({ requestId: request.requestId, ok: true, result: "connected" }),
apps/server/src/mcp/PreviewAutomationBroker.ts+22-33
...@@ -35,34 +35,28 @@ export interface PreviewAutomationInvokeInput {...@@ -35,34 +35,28 @@ export interface PreviewAutomationInvokeInput {
35 readonly timeoutMs?: number;35 readonly timeoutMs?: number;
36}36}
3737
38export interface PreviewAutomationBrokerShape {
39 readonly connect: (
40 owner: PreviewAutomationOwner,
41 ) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>;
42 readonly reportOwner: (
43 owner: PreviewAutomationOwner,
44 ) => Effect.Effect<void, PreviewAutomationError>;
45 readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect<void>;
46 readonly respond: (
47 response: PreviewAutomationResponse,
48 ) => Effect.Effect<void, PreviewAutomationError>;
49 readonly invoke: <A = unknown>(
50 request: PreviewAutomationInvokeInput,
51 ) => Effect.Effect<A, PreviewAutomationError>;
52}
53
54export class PreviewAutomationBroker extends Context.Service<38export class PreviewAutomationBroker extends Context.Service<
55 PreviewAutomationBroker,39 PreviewAutomationBroker,
56 PreviewAutomationBrokerShape40 {
41 readonly connect: (
42 owner: PreviewAutomationOwner,
43 ) => Effect.Effect<Stream.Stream<PreviewAutomationRequest>>;
44 readonly reportOwner: (
45 owner: PreviewAutomationOwner,
46 ) => Effect.Effect<void, PreviewAutomationError>;
47 readonly clearOwner: (owner: PreviewAutomationOwnerIdentity) => Effect.Effect<void>;
48 readonly respond: (
49 response: PreviewAutomationResponse,
50 ) => Effect.Effect<void, PreviewAutomationError>;
51 readonly invoke: <A = unknown>(
52 request: PreviewAutomationInvokeInput,
53 ) => Effect.Effect<A, PreviewAutomationError>;
54 }
57>()("t3/mcp/PreviewAutomationBroker") {}55>()("t3/mcp/PreviewAutomationBroker") {}
5856
59interface ClientConnection {57interface ClientConnection {
60 readonly clientId: string;58 readonly clientId: string;
61 readonly queue: Queue.Queue<59 readonly queue: Queue.Queue<PreviewAutomationRequest>;
62 Parameters<PreviewAutomationBrokerShape["respond"]>[0] extends never
63 ? never
64 : import("@t3tools/contracts").PreviewAutomationRequest
65 >;
66}60}
6761
68interface PendingRequest {62interface PendingRequest {
...@@ -123,7 +117,7 @@ const makeResponseError = (...@@ -123,7 +117,7 @@ const makeResponseError = (
123 }117 }
124};118};
125119
126const make = Effect.gen(function* PreviewAutomationBrokerMake() {120export const make = Effect.gen(function* PreviewAutomationBrokerMake() {
127 const state = yield* SynchronizedRef.make<BrokerState>({121 const state = yield* SynchronizedRef.make<BrokerState>({
128 clients: new Map(),122 clients: new Map(),
129 owners: new Map(),123 owners: new Map(),
...@@ -166,7 +160,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -166,7 +160,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
166 yield* Queue.shutdown(queue);160 yield* Queue.shutdown(queue);
167 });161 });
168162
169 const connect: PreviewAutomationBrokerShape["connect"] = Effect.fn(163 const connect: PreviewAutomationBroker["Service"]["connect"] = Effect.fn(
170 "PreviewAutomationBroker.connect",164 "PreviewAutomationBroker.connect",
171 )(function* (owner) {165 )(function* (owner) {
172 const clientId = owner.clientId;166 const clientId = owner.clientId;
...@@ -189,7 +183,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -189,7 +183,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
189 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));183 return Stream.fromQueue(queue).pipe(Stream.ensuring(disconnect(clientId, queue)));
190 });184 });
191185
192 const reportOwner: PreviewAutomationBrokerShape["reportOwner"] = Effect.fn(186 const reportOwner: PreviewAutomationBroker["Service"]["reportOwner"] = Effect.fn(
193 "PreviewAutomationBroker.reportOwner",187 "PreviewAutomationBroker.reportOwner",
194 )(function* (owner) {188 )(function* (owner) {
195 yield* SynchronizedRef.update(state, (current) => {189 yield* SynchronizedRef.update(state, (current) => {
...@@ -199,7 +193,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -199,7 +193,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
199 });193 });
200 });194 });
201195
202 const clearOwner: PreviewAutomationBrokerShape["clearOwner"] = Effect.fn(196 const clearOwner: PreviewAutomationBroker["Service"]["clearOwner"] = Effect.fn(
203 "PreviewAutomationBroker.clearOwner",197 "PreviewAutomationBroker.clearOwner",
204 )(function* (owner) {198 )(function* (owner) {
205 yield* SynchronizedRef.update(state, (current) => {199 yield* SynchronizedRef.update(state, (current) => {
...@@ -217,7 +211,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -217,7 +211,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
217 });211 });
218 });212 });
219213
220 const respond: PreviewAutomationBrokerShape["respond"] = Effect.fn(214 const respond: PreviewAutomationBroker["Service"]["respond"] = Effect.fn(
221 "PreviewAutomationBroker.respond",215 "PreviewAutomationBroker.respond",
222 )(function* (response) {216 )(function* (response) {
223 const pending = yield* SynchronizedRef.modify(state, (current) => {217 const pending = yield* SynchronizedRef.modify(state, (current) => {
...@@ -243,7 +237,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -243,7 +237,7 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
243 });237 });
244238
245 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(239 const invoke = Effect.fn("PreviewAutomationBroker.invoke")(function* <A = unknown>(
246 input: Parameters<PreviewAutomationBrokerShape["invoke"]>[0],240 input: Parameters<PreviewAutomationBroker["Service"]["invoke"]>[0],
247 ): Effect.fn.Return<A, PreviewAutomationError> {241 ): Effect.fn.Return<A, PreviewAutomationError> {
248 const current = yield* SynchronizedRef.get(state);242 const current = yield* SynchronizedRef.get(state);
249 const candidates = Array.from(current.owners.values())243 const candidates = Array.from(current.owners.values())
...@@ -317,8 +311,3 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {...@@ -317,8 +311,3 @@ const make = Effect.gen(function* PreviewAutomationBrokerMake() {
317}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));311}).pipe(Effect.withSpan("PreviewAutomationBroker.make"));
318312
319export const layer = Layer.effect(PreviewAutomationBroker, make);313export const layer = Layer.effect(PreviewAutomationBroker, make);
320
321/** Exposed for tests. */
322export const __testing = {
323 make,
324};
apps/server/src/preview/Manager.ts+98-95
...@@ -28,33 +28,30 @@ import {...@@ -28,33 +28,30 @@ import {
28 normalizePreviewUrl,28 normalizePreviewUrl,
29 PreviewUrlNormalizationError,29 PreviewUrlNormalizationError,
30} from "@t3tools/shared/preview";30} from "@t3tools/shared/preview";
31import {31import * as Context from "effect/Context";
32 Context,32import * as DateTime from "effect/DateTime";
33 DateTime,33import * as Effect from "effect/Effect";
34 Effect,34import * as Layer from "effect/Layer";
35 Layer,35import * as PubSub from "effect/PubSub";
36 PubSub,36import * as Scope from "effect/Scope";
37 type Scope,37import * as Stream from "effect/Stream";
38 Stream,38import * as SynchronizedRef from "effect/SynchronizedRef";
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}
5439
55export class PreviewManager extends Context.Service<PreviewManager, PreviewManagerShape>()(40export class PreviewManager extends Context.Service<
56 "t3/preview/Manager/PreviewManager",41 PreviewManager,
57) {}42 {
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>()("t3/preview/Manager/PreviewManager") {}
5855
59interface PreviewSessionState {56interface PreviewSessionState {
60 readonly threadId: string;57 readonly threadId: string;
...@@ -127,7 +124,7 @@ const buildIdleSnapshot = (input: {...@@ -127,7 +124,7 @@ const buildIdleSnapshot = (input: {
127 updatedAt: input.updatedAt,124 updatedAt: input.updatedAt,
128});125});
129126
130const make = Effect.gen(function* PreviewManagerMake() {127export const make = Effect.gen(function* PreviewManagerMake() {
131 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);128 const stateRef = yield* SynchronizedRef.make<ManagerState>(initialState);
132 // Unbounded PubSub is fine here — events are tiny and we don't want to129 // 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 on130 // block publishers if a subscriber is slow. WS clients backpressure on
...@@ -184,38 +181,40 @@ const make = Effect.gen(function* PreviewManagerMake() {...@@ -184,38 +181,40 @@ const make = Effect.gen(function* PreviewManagerMake() {
184 );181 );
185 };182 };
186183
187 const open: PreviewManagerShape["open"] = Effect.fn("PreviewManager.open")(function* (input) {184 const open: PreviewManager["Service"]["open"] = Effect.fn("PreviewManager.open")(
188 const tabId = newPreviewTabId();185 function* (input) {
189 const updatedAt = yield* currentIsoTimestamp;186 const tabId = newPreviewTabId();
190 const snapshot = input.url187 const updatedAt = yield* currentIsoTimestamp;
191 ? buildLoadingSnapshot({188 const snapshot = input.url
189 ? buildLoadingSnapshot({
190 threadId: input.threadId,
191 tabId,
192 url: yield* normalizeUrl(input.url),
193 title: "",
194 updatedAt,
195 })
196 : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt });
197 yield* SynchronizedRef.update(stateRef, (state) => {
198 const sessions = new Map(state.sessions);
199 sessions.set(compositeKey(input.threadId, tabId), {
192 threadId: input.threadId,200 threadId: input.threadId,
193 tabId,201 tabId,
194 url: yield* normalizeUrl(input.url),202 snapshot,
195 title: "",203 });
196 updatedAt,204 return { sessions };
197 })205 });
198 : buildIdleSnapshot({ threadId: input.threadId, tabId, updatedAt });206 yield* PubSub.publish(eventsPubSub, {
199 yield* SynchronizedRef.update(stateRef, (state) => {207 type: "opened",
200 const sessions = new Map(state.sessions);
201 sessions.set(compositeKey(input.threadId, tabId), {
202 threadId: input.threadId,208 threadId: input.threadId,
203 tabId,209 tabId,
210 createdAt: snapshot.updatedAt,
204 snapshot,211 snapshot,
205 });212 });
206 return { sessions };213 return snapshot;
207 });214 },
208 yield* PubSub.publish(eventsPubSub, {215 );
209 type: "opened",
210 threadId: input.threadId,
211 tabId,
212 createdAt: snapshot.updatedAt,
213 snapshot,
214 });
215 return snapshot;
216 });
217216
218 const navigate: PreviewManagerShape["navigate"] = Effect.fn("PreviewManager.navigate")(217 const navigate: PreviewManager["Service"]["navigate"] = Effect.fn("PreviewManager.navigate")(
219 function* (input) {218 function* (input) {
220 const url = yield* normalizeUrl(input.url);219 const url = yield* normalizeUrl(input.url);
221 return yield* mutateExistingSession(220 return yield* mutateExistingSession(
...@@ -250,7 +249,7 @@ const make = Effect.gen(function* PreviewManagerMake() {...@@ -250,7 +249,7 @@ const make = Effect.gen(function* PreviewManagerMake() {
250 },249 },
251 );250 );
252251
253 const reportStatus: PreviewManagerShape["reportStatus"] = Effect.fn(252 const reportStatus: PreviewManager["Service"]["reportStatus"] = Effect.fn(
254 "PreviewManager.reportStatus",253 "PreviewManager.reportStatus",
255 )(function* (input) {254 )(function* (input) {
256 yield* mutateExistingSession(255 yield* mutateExistingSession(
...@@ -294,7 +293,7 @@ const make = Effect.gen(function* PreviewManagerMake() {...@@ -294,7 +293,7 @@ const make = Effect.gen(function* PreviewManagerMake() {
294 );293 );
295 });294 });
296295
297 const refresh: PreviewManagerShape["refresh"] = Effect.fn("PreviewManager.refresh")(296 const refresh: PreviewManager["Service"]["refresh"] = Effect.fn("PreviewManager.refresh")(
298 function* (input) {297 function* (input) {
299 // Verify the session exists; the desktop bridge handles the actual reload298 // Verify the session exists; the desktop bridge handles the actual reload
300 // and will report progress back via `reportStatus`. No event emitted.299 // and will report progress back via `reportStatus`. No event emitted.
...@@ -304,50 +303,54 @@ const make = Effect.gen(function* PreviewManagerMake() {...@@ -304,50 +303,54 @@ const make = Effect.gen(function* PreviewManagerMake() {
304 },303 },
305 );304 );
306305
307 const close: PreviewManagerShape["close"] = Effect.fn("PreviewManager.close")(function* (input) {306 const close: PreviewManager["Service"]["close"] = Effect.fn("PreviewManager.close")(
308 const createdAt = yield* currentIsoTimestamp;307 function* (input) {
309 const events = yield* SynchronizedRef.modify(stateRef, (state) => {308 const createdAt = yield* currentIsoTimestamp;
310 const eventsToEmit: PreviewEvent[] = [];309 const events = yield* SynchronizedRef.modify(stateRef, (state) => {
311 const sessions = new Map(state.sessions);310 const eventsToEmit: PreviewEvent[] = [];
312 const targets = input.tabId311 const sessions = new Map(state.sessions);
313 ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter(312 const targets = input.tabId
314 (entry): entry is PreviewSessionState => entry !== undefined,313 ? [state.sessions.get(compositeKey(input.threadId, input.tabId))].filter(
315 )314 (entry): entry is PreviewSessionState => entry !== undefined,
316 : sessionsForThread(state, input.threadId);315 )
317 for (const target of targets) {316 : sessionsForThread(state, input.threadId);
318 sessions.delete(compositeKey(target.threadId, target.tabId));317 for (const target of targets) {
319 eventsToEmit.push({318 sessions.delete(compositeKey(target.threadId, target.tabId));
320 type: "closed",319 eventsToEmit.push({
321 threadId: target.threadId,320 type: "closed",
322 tabId: target.tabId,321 threadId: target.threadId,
323 createdAt,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,
324 });334 });
325 }335 }
326 if (eventsToEmit.length === 0) {336 },
327 return [eventsToEmit, state] as const;337 );
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 });
337338
338 const list: PreviewManagerShape["list"] = Effect.fn("PreviewManager.list")(function* (input) {339 const list: PreviewManager["Service"]["list"] = Effect.fn("PreviewManager.list")(
339 return yield* SynchronizedRef.get(stateRef).pipe(340 function* (input) {
340 Effect.map(341 return yield* SynchronizedRef.get(stateRef).pipe(
341 (state): PreviewListResult => ({342 Effect.map(
342 sessions: sessionsForThread(state, input.threadId)343 (state): PreviewListResult => ({
343 .map((s) => s.snapshot)344 sessions: sessionsForThread(state, input.threadId)
344 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)),345 .map((s) => s.snapshot)
345 }),346 .toSorted((a, b) => a.updatedAt.localeCompare(b.updatedAt)),
346 ),347 }),
347 );348 ),
348 });349 );
350 },
351 );
349352
350 return {353 return PreviewManager.of({
351 open,354 open,
352 navigate,355 navigate,
353 reportStatus,356 reportStatus,
...@@ -356,7 +359,7 @@ const make = Effect.gen(function* PreviewManagerMake() {...@@ -356,7 +359,7 @@ const make = Effect.gen(function* PreviewManagerMake() {
356 list,359 list,
357 events,360 events,
358 subscribeEvents: PubSub.subscribe(eventsPubSub),361 subscribeEvents: PubSub.subscribe(eventsPubSub),
359 } satisfies PreviewManagerShape;362 });
360}).pipe(Effect.withSpan("PreviewManager.make"));363}).pipe(Effect.withSpan("PreviewManager.make"));
361364
362export const layer = Layer.effect(PreviewManager, make);365export const layer = Layer.effect(PreviewManager, make);
apps/server/src/preview/PortScanner.ts+54-49
...@@ -15,30 +15,36 @@ import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";...@@ -15,30 +15,36 @@ import { ThreadId, type DiscoveredLocalServer } from "@t3tools/contracts";
15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";15import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
16import * as Net from "@t3tools/shared/Net";16import * as Net from "@t3tools/shared/Net";
17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";17import { LSOF_LOCAL_HOST_TOKENS } from "@t3tools/shared/preview";
18import { Cause, Context, Duration, Effect, Layer, Ref, Schedule, Scope } from "effect";18import * as Cause from "effect/Cause";
19import * as Context from "effect/Context";
20import * as Duration from "effect/Duration";
21import * as Effect from "effect/Effect";
22import * as Layer from "effect/Layer";
23import * as Ref from "effect/Ref";
24import * as Schedule from "effect/Schedule";
25import * as Scope from "effect/Scope";
1926
20import { ProcessRunner } from "../processRunner.ts";27import * as ProcessRunner from "../processRunner.ts";
2128
22export interface PortDiscoveryShape {29export class PortDiscovery extends Context.Service<
23 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;30 PortDiscovery,
24 readonly subscribe: (31 {
25 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,32 readonly scan: () => Effect.Effect<ReadonlyArray<DiscoveredLocalServer>>;
26 ) => Effect.Effect<void, never, Scope.Scope>;33 readonly subscribe: (
27 readonly retain: Effect.Effect<void, never, Scope.Scope>;34 listener: (servers: ReadonlyArray<DiscoveredLocalServer>) => Effect.Effect<void>,
28 readonly registerTerminalProcesses: (input: {35 ) => Effect.Effect<void, never, Scope.Scope>;
29 readonly threadId: string;36 readonly retain: Effect.Effect<void, never, Scope.Scope>;
30 readonly terminalId: string;37 readonly registerTerminalProcesses: (input: {
31 readonly processIds: ReadonlyArray<number>;38 readonly threadId: string;
32 }) => Effect.Effect<void>;39 readonly terminalId: string;
33 readonly unregisterTerminal: (input: {40 readonly processIds: ReadonlyArray<number>;
34 readonly threadId: string;41 }) => Effect.Effect<void>;
35 readonly terminalId: string;42 readonly unregisterTerminal: (input: {
36 }) => Effect.Effect<void>;43 readonly threadId: string;
37}44 readonly terminalId: string;
3845 }) => Effect.Effect<void>;
39export class PortDiscovery extends Context.Service<PortDiscovery, PortDiscoveryShape>()(46 }
40 "t3/preview/PortScanner/PortDiscovery",47>()("t3/preview/PortScanner/PortDiscovery") {}
41) {}
4248
43export const COMMON_DEV_PORTS: ReadonlyArray<number> = Object.freeze([49export 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,50 3000, 3001, 3333, 4173, 4200, 4321, 5000, 5173, 5174, 5175, 5500, 8000, 8080, 8081, 8888, 9000,
...@@ -180,9 +186,9 @@ const serversEqual = (...@@ -180,9 +186,9 @@ const serversEqual = (
180 return true;186 return true;
181};187};
182188
183const make = Effect.gen(function* PortDiscoveryMake() {189export const make = Effect.gen(function* PortDiscoveryMake() {
184 const net = yield* Net.NetService;190 const net = yield* Net.NetService;
185 const processRunner = yield* ProcessRunner;191 const processRunner = yield* ProcessRunner.ProcessRunner;
186 const hostPlatform = yield* HostProcessPlatform;192 const hostPlatform = yield* HostProcessPlatform;
187 const stateRef = yield* Ref.make<ScannerState>({193 const stateRef = yield* Ref.make<ScannerState>({
188 lastSnapshot: [],194 lastSnapshot: [],
...@@ -296,14 +302,14 @@ const make = Effect.gen(function* PortDiscoveryMake() {...@@ -296,14 +302,14 @@ const make = Effect.gen(function* PortDiscoveryMake() {
296 }302 }
297 });303 });
298304
299 const retain: PortDiscoveryShape["retain"] = Effect.acquireRelease(acquireRetention(), () =>305 const retain: PortDiscovery["Service"]["retain"] = Effect.acquireRelease(acquireRetention(), () =>
300 Ref.update(stateRef, (state) => ({306 Ref.update(stateRef, (state) => ({
301 ...state,307 ...state,
302 retainCount: Math.max(0, state.retainCount - 1),308 retainCount: Math.max(0, state.retainCount - 1),
303 })),309 })),
304 );310 );
305311
306 const subscribe: PortDiscoveryShape["subscribe"] = Effect.fn("PortDiscovery.subscribe")(312 const subscribe: PortDiscovery["Service"]["subscribe"] = Effect.fn("PortDiscovery.subscribe")(
307 (listener) =>313 (listener) =>
308 Effect.acquireRelease(314 Effect.acquireRelease(
309 Ref.update(stateRef, (state) => ({315 Ref.update(stateRef, (state) => ({
...@@ -319,29 +325,28 @@ const make = Effect.gen(function* PortDiscoveryMake() {...@@ -319,29 +325,28 @@ const make = Effect.gen(function* PortDiscoveryMake() {
319 ),325 ),
320 );326 );
321327
322 const registerTerminalProcesses: PortDiscoveryShape["registerTerminalProcesses"] = Effect.fn(328 const registerTerminalProcesses: PortDiscovery["Service"]["registerTerminalProcesses"] =
323 "PortDiscovery.registerTerminalProcesses",329 Effect.fn("PortDiscovery.registerTerminalProcesses")(function* (input) {
324 )(function* (input) {330 const owner = {
325 const owner = {331 threadId: ThreadId.make(input.threadId),
326 threadId: ThreadId.make(input.threadId),332 terminalId: input.terminalId,
327 terminalId: input.terminalId,333 };
328 };334 const processIds = new Set(
329 const processIds = new Set(335 input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),
330 input.processIds.filter((processId) => Number.isInteger(processId) && processId > 0),336 );
331 );337 yield* Ref.update(stateRef, (state) => {
332 yield* Ref.update(stateRef, (state) => {338 const terminalProcesses = new Map(state.terminalProcesses);
333 const terminalProcesses = new Map(state.terminalProcesses);339 const key = terminalOwnerKey(owner);
334 const key = terminalOwnerKey(owner);340 if (processIds.size === 0) {
335 if (processIds.size === 0) {341 terminalProcesses.delete(key);
336 terminalProcesses.delete(key);342 } else {
337 } else {343 terminalProcesses.set(key, { owner, processIds });
338 terminalProcesses.set(key, { owner, processIds });344 }
339 }345 return { ...state, terminalProcesses };
340 return { ...state, terminalProcesses };346 });
341 });347 });
342 });
343348
344 const unregisterTerminal: PortDiscoveryShape["unregisterTerminal"] = Effect.fn(349 const unregisterTerminal: PortDiscovery["Service"]["unregisterTerminal"] = Effect.fn(
345 "PortDiscovery.unregisterTerminal",350 "PortDiscovery.unregisterTerminal",
346 )(function* (input) {351 )(function* (input) {
347 yield* Ref.update(stateRef, (state) => {352 yield* Ref.update(stateRef, (state) => {
...@@ -351,13 +356,13 @@ const make = Effect.gen(function* PortDiscoveryMake() {...@@ -351,13 +356,13 @@ const make = Effect.gen(function* PortDiscoveryMake() {
351 });356 });
352 });357 });
353358
354 return {359 return PortDiscovery.of({
355 scan: scanOnce,360 scan: scanOnce,
356 subscribe,361 subscribe,
357 retain,362 retain,
358 registerTerminalProcesses,363 registerTerminalProcesses,
359 unregisterTerminal,364 unregisterTerminal,
360 } satisfies PortDiscoveryShape;365 });
361}).pipe(Effect.withSpan("PortDiscovery.make"));366}).pipe(Effect.withSpan("PortDiscovery.make"));
362367
363export const layer = Layer.effect(PortDiscovery, make);368export const layer = Layer.effect(PortDiscovery, make);
apps/server/src/process/externalLauncher.ts+19-25
...@@ -22,7 +22,8 @@ import * as FileSystem from "effect/FileSystem";...@@ -22,7 +22,8 @@ import * as FileSystem from "effect/FileSystem";
22import * as Layer from "effect/Layer";22import * as Layer from "effect/Layer";
23import * as Option from "effect/Option";23import * as Option from "effect/Option";
24import * as Path from "effect/Path";24import * as Path from "effect/Path";
25import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";25import * as ChildProcess from "effect/unstable/process/ChildProcess";
26import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
2627
27// ==============================28// ==============================
28// Definitions29// Definitions
...@@ -282,30 +283,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit...@@ -282,30 +283,23 @@ const resolveAvailableEditors = Effect.fn("externalLauncher.resolveAvailableEdit
282 return yield* buildAvailableEditors(platform, env);283 return yield* buildAvailableEditors(platform, env);
283});284});
284285
285/**
286 * ExternalLauncherShape - Service API for browser and editor launch actions.
287 */
288export interface ExternalLauncherShape {
289 readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>;
290 /**
291 * Launch a URL target in the default browser.
292 */
293 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
294
295 /**
296 * Launch a workspace path in a selected editor integration.
297 *
298 * Launches the editor as a detached process so server startup is not blocked.
299 */
300 readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect<void, ExternalLauncherError>;
301}
302
303/**286/**
304 * ExternalLauncher - Service tag for browser/editor launch operations.287 * ExternalLauncher - Service tag for browser/editor launch operations.
305 */288 */
306export class ExternalLauncher extends Context.Service<ExternalLauncher, ExternalLauncherShape>()(289export class ExternalLauncher extends Context.Service<
307 "t3/process/externalLauncher",290 ExternalLauncher,
308) {}291 {
292 readonly resolveAvailableEditors: () => Effect.Effect<ReadonlyArray<EditorId>>;
293 /** Launch a URL target in the default browser. */
294 readonly launchBrowser: (target: string) => Effect.Effect<void, ExternalLauncherError>;
295 /**
296 * Launch a workspace path in a selected editor integration.
297 *
298 * Launches the editor as a detached process so server startup is not blocked.
299 */
300 readonly launchEditor: (input: LaunchEditorInput) => Effect.Effect<void, ExternalLauncherError>;
301 }
302>()("t3/process/externalLauncher") {}
309303
310// ==============================304// ==============================
311// Implementations305// Implementations
...@@ -397,7 +391,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu...@@ -397,7 +391,7 @@ const launchEditorProcess = Effect.fn("externalLauncher.launchEditorProcess")(fu
397 );391 );
398});392});
399393
400const make = Effect.gen(function* () {394export const make = Effect.gen(function* () {
401 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;395 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
402 const fileSystem = yield* FileSystem.FileSystem;396 const fileSystem = yield* FileSystem.FileSystem;
403 const path = yield* Path.Path;397 const path = yield* Path.Path;
...@@ -410,7 +404,7 @@ const make = Effect.gen(function* () {...@@ -410,7 +404,7 @@ const make = Effect.gen(function* () {
410 Effect.provideService(Path.Path, path),404 Effect.provideService(Path.Path, path),
411 );405 );
412406
413 return {407 return ExternalLauncher.of({
414 resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),408 resolveAvailableEditors: () => provideCommandResolutionServices(resolveAvailableEditors()),
415 launchBrowser: (target) =>409 launchBrowser: (target) =>
416 launchBrowser(target).pipe(410 launchBrowser(target).pipe(
...@@ -424,7 +418,7 @@ const make = Effect.gen(function* () {...@@ -424,7 +418,7 @@ const make = Effect.gen(function* () {
424 ),418 ),
425 ),419 ),
426 ),420 ),
427 } satisfies ExternalLauncherShape;421 });
428});422});
429423
430export const layer = Layer.effect(ExternalLauncher, make);424export const layer = Layer.effect(ExternalLauncher, make);
apps/server/src/processRunner.test.ts+11-17
...@@ -11,14 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";...@@ -11,14 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process";
11import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";11import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import { SpawnExecutableResolution } from "@t3tools/shared/shell";12import { SpawnExecutableResolution } from "@t3tools/shared/shell";
1313
14import {14import * as ProcessRunner from "./processRunner.ts";
15 isWindowsCommandNotFound,
16 ProcessOutputLimitError,
17 ProcessRunner,
18 ProcessTimeoutError,
19 layer as ProcessRunnerLive,
20 type ProcessRunInput,
21} from "./processRunner.ts";
2215
23type ChildProcessCommand = {16type ChildProcessCommand = {
24 readonly command: string;17 readonly command: string;
...@@ -68,15 +61,16 @@ function makeSpawner(...@@ -68,15 +61,16 @@ function makeSpawner(
68}61}
6962
70const runWith =63const runWith =
71 (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) => (input: ProcessRunInput) =>64 (spawner: ChildProcessSpawner.ChildProcessSpawner["Service"]) =>
72 Effect.service(ProcessRunner).pipe(65 (input: ProcessRunner.ProcessRunInput) =>
66 Effect.service(ProcessRunner.ProcessRunner).pipe(
73 Effect.flatMap((runner) =>67 Effect.flatMap((runner) =>
74 runner.run({68 runner.run({
75 ...input,69 ...input,
76 }),70 }),
77 ),71 ),
78 Effect.provide(72 Effect.provide(
79 ProcessRunnerLive.pipe(73 ProcessRunner.layer.pipe(
80 Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),74 Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
81 ),75 ),
82 ),76 ),
...@@ -112,12 +106,12 @@ describe("runProcess", () => {...@@ -112,12 +106,12 @@ describe("runProcess", () => {
112 return makeHandle({ stdout: "service ok" });106 return makeHandle({ stdout: "service ok" });
113 }),107 }),
114 );108 );
115 const layer = ProcessRunnerLive.pipe(109 const layer = ProcessRunner.layer.pipe(
116 Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),110 Layer.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),
117 );111 );
118112
119 return Effect.gen(function* () {113 return Effect.gen(function* () {
120 const runner = yield* ProcessRunner;114 const runner = yield* ProcessRunner.ProcessRunner;
121 const result = yield* runner.run({115 const result = yield* runner.run({
122 command: "fake",116 command: "fake",
123 args: ["--service"],117 args: ["--service"],
...@@ -175,7 +169,7 @@ describe("runProcess", () => {...@@ -175,7 +169,7 @@ describe("runProcess", () => {
175 maxOutputBytes: 128,169 maxOutputBytes: 128,
176 }).pipe(Effect.flip);170 }).pipe(Effect.flip);
177171
178 expect(error).toBeInstanceOf(ProcessOutputLimitError);172 expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError);
179 }),173 }),
180 );174 );
181175
...@@ -200,7 +194,7 @@ describe("runProcess", () => {...@@ -200,7 +194,7 @@ describe("runProcess", () => {
200 timeout: "2 seconds",194 timeout: "2 seconds",
201 }).pipe(Effect.flip);195 }).pipe(Effect.flip);
202196
203 expect(error).toBeInstanceOf(ProcessOutputLimitError);197 expect(error).toBeInstanceOf(ProcessRunner.ProcessOutputLimitError);
204 }),198 }),
205 );199 );
206200
...@@ -285,7 +279,7 @@ describe("runProcess", () => {...@@ -285,7 +279,7 @@ describe("runProcess", () => {
285 yield* TestClock.adjust(Duration.millis(50));279 yield* TestClock.adjust(Duration.millis(50));
286 const error = yield* Fiber.join(errorFiber);280 const error = yield* Fiber.join(errorFiber);
287281
288 expect(error).toBeInstanceOf(ProcessTimeoutError);282 expect(error).toBeInstanceOf(ProcessRunner.ProcessTimeoutError);
289 }),283 }),
290 );284 );
291285
...@@ -324,7 +318,7 @@ describe("runProcess", () => {...@@ -324,7 +318,7 @@ describe("runProcess", () => {
324describe("isWindowsCommandNotFound", () => {318describe("isWindowsCommandNotFound", () => {
325 it.effect("matches the localized German cmd.exe error text", () =>319 it.effect("matches the localized German cmd.exe error text", () =>
326 Effect.gen(function* () {320 Effect.gen(function* () {
327 const isCommandNotFound = yield* isWindowsCommandNotFound(321 const isCommandNotFound = yield* ProcessRunner.isWindowsCommandNotFound(
328 1,322 1,
329 "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",323 "wird nicht als interner oder externer Befehl, betriebsfahiges Programm oder Batch-Datei erkannt",
330 ).pipe(Effect.provideService(HostProcessPlatform, "win32"));324 ).pipe(Effect.provideService(HostProcessPlatform, "win32"));
apps/server/src/processRunner.ts+80-40
...@@ -1,13 +1,14 @@...@@ -1,13 +1,14 @@
1import * as Data from "effect/Data";
2import * as Context from "effect/Context";1import * as Context from "effect/Context";
3import * as Duration from "effect/Duration";2import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";3import * as Effect from "effect/Effect";
5import * as Layer from "effect/Layer";4import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";5import * as Option from "effect/Option";
7import * as PlatformError from "effect/PlatformError";6import * as PlatformError from "effect/PlatformError";
7import * as Schema from "effect/Schema";
8import * as Scope from "effect/Scope";8import * as Scope from "effect/Scope";
9import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";10import * as ChildProcess from "effect/unstable/process/ChildProcess";
11import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
11import { HostProcessPlatform } from "@t3tools/shared/hostProcess";12import { HostProcessPlatform } from "@t3tools/shared/hostProcess";
12import { resolveSpawnCommand } from "@t3tools/shared/shell";13import { resolveSpawnCommand } from "@t3tools/shared/shell";
13import {14import {
...@@ -42,42 +43,82 @@ export interface ProcessRunOutput {...@@ -42,42 +43,82 @@ export interface ProcessRunOutput {
42 readonly stderrTruncated: boolean;43 readonly stderrTruncated: boolean;
43}44}
4445
45export class ProcessSpawnError extends Data.TaggedError("ProcessSpawnError")<{46const ProcessInvocationFields = {
46 readonly command: string;47 command: Schema.String,
47 readonly args: ReadonlyArray<string>;48 args: Schema.Array(Schema.String),
48 readonly cwd?: string | undefined;49 cwd: Schema.optional(Schema.String),
49 readonly cause: unknown;50};
50}> {}
5151
52export class ProcessStdinError extends Data.TaggedError("ProcessStdinError")<{52const formatProcessInvocation = (input: {
53 readonly command: string;53 readonly command: string;
54 readonly args: ReadonlyArray<string>;54 readonly args: ReadonlyArray<string>;
55 readonly cwd?: string | undefined;55 readonly cwd?: string | undefined;
56 readonly cause: unknown;56}): string => {
57}> {}57 const command = [input.command, ...input.args].join(" ");
58 return input.cwd === undefined ? `'${command}'` : `'${command}' in '${input.cwd}'`;
59};
5860
59export class ProcessOutputLimitError extends Data.TaggedError("ProcessOutputLimitError")<{61export class ProcessSpawnError extends Schema.TaggedErrorClass<ProcessSpawnError>()(
60 readonly command: string;62 "ProcessSpawnError",
61 readonly args: ReadonlyArray<string>;63 {
62 readonly cwd?: string | undefined;64 ...ProcessInvocationFields,
63 readonly stream: "stdout" | "stderr";65 cause: Schema.Defect(),
64 readonly maxBytes: number;66 },
65}> {}67) {
68 override get message(): string {
69 return `Failed to spawn process ${formatProcessInvocation(this)}`;
70 }
71}
6672
67export class ProcessReadError extends Data.TaggedError("ProcessReadError")<{73export class ProcessStdinError extends Schema.TaggedErrorClass<ProcessStdinError>()(
68 readonly command: string;74 "ProcessStdinError",
69 readonly args: ReadonlyArray<string>;75 {
70 readonly cwd?: string | undefined;76 ...ProcessInvocationFields,
71 readonly stream: "stdout" | "stderr" | "exitCode";77 cause: Schema.Defect(),
72 readonly cause: unknown;78 },
73}> {}79) {
80 override get message(): string {
81 return `Failed to write stdin for process ${formatProcessInvocation(this)}`;
82 }
83}
7484
75export class ProcessTimeoutError extends Data.TaggedError("ProcessTimeoutError")<{85export class ProcessOutputLimitError extends Schema.TaggedErrorClass<ProcessOutputLimitError>()(
76 readonly command: string;86 "ProcessOutputLimitError",
77 readonly args: ReadonlyArray<string>;87 {
78 readonly cwd?: string | undefined;88 ...ProcessInvocationFields,
79 readonly timeoutMs: number;89 stream: Schema.Literals(["stdout", "stderr"]),
80}> {}90 maxBytes: Schema.Number,
91 },
92) {
93 override get message(): string {
94 return `Process ${formatProcessInvocation(this)} ${this.stream} exceeded ${this.maxBytes} bytes`;
95 }
96}
97
98export class ProcessReadError extends Schema.TaggedErrorClass<ProcessReadError>()(
99 "ProcessReadError",
100 {
101 ...ProcessInvocationFields,
102 stream: Schema.Literals(["stdout", "stderr", "exitCode"]),
103 cause: Schema.Defect(),
104 },
105) {
106 override get message(): string {
107 return `Failed to read ${this.stream} for process ${formatProcessInvocation(this)}`;
108 }
109}
110
111export class ProcessTimeoutError extends Schema.TaggedErrorClass<ProcessTimeoutError>()(
112 "ProcessTimeoutError",
113 {
114 ...ProcessInvocationFields,
115 timeoutMs: Schema.Number,
116 },
117) {
118 override get message(): string {
119 return `Process ${formatProcessInvocation(this)} timed out after ${this.timeoutMs}ms`;
120 }
121}
81122
82export type ProcessRunError =123export type ProcessRunError =
83 | ProcessSpawnError124 | ProcessSpawnError
...@@ -86,13 +127,12 @@ export type ProcessRunError =...@@ -86,13 +127,12 @@ export type ProcessRunError =
86 | ProcessReadError127 | ProcessReadError
87 | ProcessTimeoutError;128 | ProcessTimeoutError;
88129
89export interface ProcessRunnerShape {130export class ProcessRunner extends Context.Service<
90 readonly run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput, ProcessRunError>;131 ProcessRunner,
91}132 {
92133 readonly run: (input: ProcessRunInput) => Effect.Effect<ProcessRunOutput, ProcessRunError>;
93export class ProcessRunner extends Context.Service<ProcessRunner, ProcessRunnerShape>()(134 }
94 "t3/processRunner",135>()("t3/processRunner") {}
95) {}
96136
97const DEFAULT_TIMEOUT = "60 seconds";137const DEFAULT_TIMEOUT = "60 seconds";
98const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;138const DEFAULT_MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
...@@ -332,10 +372,10 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (...@@ -332,10 +372,10 @@ const runProcessCore = Effect.fn("processRunner.runProcessCore")(function* (
332 } satisfies ProcessRunOutput;372 } satisfies ProcessRunOutput;
333});373});
334374
335export const make = Effect.fn("makeProcessRunner")(function* () {375export const make = Effect.fn("ProcessRunner.make")(function* () {
336 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;376 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
337377
338 const run: ProcessRunnerShape["run"] = (input) =>378 const run: ProcessRunner["Service"]["run"] = (input) =>
339 finalizeRunProcess(runProcessCore(spawner, input), input);379 finalizeRunProcess(runProcessCore(spawner, input), input);
340380
341 return ProcessRunner.of({381 return ProcessRunner.of({