authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-24 14:13:26-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-26 13:20:36-08:00
log915083fae41e9dc8f68624fc4a1067d18c09c2f9
treeb0c47921d386add02a7bb8c19cb5d01d6320358e
parent9e6ef16fb389e5dc6d0602308249fcd5057a4a32

kewl


3 files changed, 98 insertions(+), 116 deletions(-)

apps/server/src/index.ts+5-5
...@@ -9,9 +9,9 @@ import { Command } from "effect/unstable/cli";...@@ -9,9 +9,9 @@ import { Command } from "effect/unstable/cli";
9import { version } from "../package.json" with { type: "json" };9import { version } from "../package.json" with { type: "json" };
10import { ServerLive } from "./wsServer";10import { ServerLive } from "./wsServer";
1111
12const RuntimeLayer = Layer.mergeAll(CliConfigLive, ServerLive, OpenLive, NodeServices.layer);12const RuntimeLayer = Layer.provideMerge(
1313 Layer.mergeAll(CliConfigLive, ServerLive, OpenLive),
14Command.run(makeCliCommand(), { version }).pipe(14 NodeServices.layer,
15 Effect.provide(RuntimeLayer),
16 NodeRuntime.runMain,
17);15);
16
17Command.run(makeCliCommand(), { version }).pipe(Effect.provide(RuntimeLayer), NodeRuntime.runMain);
apps/server/src/main.test.ts+1-1
...@@ -24,7 +24,7 @@ const testLayer = Layer.mergeAll(...@@ -24,7 +24,7 @@ const testLayer = Layer.mergeAll(
24 cwd: "/tmp/t3-test-workspace",24 cwd: "/tmp/t3-test-workspace",
25 fixPath: Effect.void,25 fixPath: Effect.void,
26 findAvailablePort,26 findAvailablePort,
27 resolveStaticDir: () => undefined,27 resolveStaticDir: () => Effect.undefined,
28 } satisfies CliConfigShape),28 } satisfies CliConfigShape),
29 Layer.succeed(Server, {29 Layer.succeed(Server, {
30 createServer,30 createServer,
apps/server/src/main.ts+92-110
...@@ -1,17 +1,19 @@...@@ -1,17 +1,19 @@
1import fs from "node:fs";
2import net from "node:net";1import net from "node:net";
3import os from "node:os";2import os from "node:os";
4import path from "node:path";
5import { fileURLToPath } from "node:url";
63
7import * as Data from "effect/Data";4import {
8import * as Effect from "effect/Effect";5 ServiceMap,
9import * as Layer from "effect/Layer";6 Data,
10import * as Option from "effect/Option";7 Effect,
11import * as Config from "effect/Config";8 Layer,
12import { ServiceMap } from "effect";9 Option,
13import * as Command from "effect/unstable/cli/Command";10 Config,
14import * as Flag from "effect/unstable/cli/Flag";11 Path,
12 Schema,
13 FileSystem,
14 PlatformError,
15} from "effect";
16import { Command, Flag } from "effect/unstable/cli";
1517
16import { fixPath } from "./fixPath";18import { fixPath } from "./fixPath";
17import { Open } from "./open";19import { Open } from "./open";
...@@ -31,22 +33,11 @@ interface CliInput {...@@ -31,22 +33,11 @@ interface CliInput {
31 readonly token: Option.Option<string>;33 readonly token: Option.Option<string>;
32}34}
3335
34interface ResolvedCliConfig {
35 readonly port: number;
36 readonly mode: RuntimeMode;
37 readonly cwd: string;
38 readonly stateDir: string;
39 readonly devUrl: URL | undefined;
40 readonly noBrowser: boolean;
41 readonly authToken: string | undefined;
42 readonly staticDir: string | undefined;
43}
44
45export interface CliConfigShape {36export interface CliConfigShape {
46 readonly cwd: string;37 readonly cwd: string;
47 readonly fixPath: Effect.Effect<unknown, never>;38 readonly fixPath: Effect.Effect<unknown, never>;
48 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>;39 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>;
49 readonly resolveStaticDir: () => string | undefined;40 readonly resolveStaticDir: () => Effect.Effect<string | undefined, PlatformError.PlatformError>;
50}41}
5142
52export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(43export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(
...@@ -79,31 +70,22 @@ const CliEnvConfig = Config.all({...@@ -79,31 +70,22 @@ const CliEnvConfig = Config.all({
79 ),70 ),
80});71});
8172
82function assertValidPort(value: number, source: string): Effect.Effect<number, StartupError> {73const expandHomePath = Effect.fn(function* (input: string) {
83 if (Number.isInteger(value) && value >= 1 && value <= 65535) {74 const { join } = yield* Path.Path;
84 return Effect.succeed(value);
85 }
86 return Effect.fail(
87 new StartupError({
88 message: `Invalid ${source}: ${value}. Expected an integer between 1 and 65535.`,
89 }),
90 );
91}
92
93function expandHomePath(input: string): string {
94 if (input === "~") return os.homedir();75 if (input === "~") return os.homedir();
95 if (input.startsWith("~/")) {76 if (input.startsWith("~/")) {
96 return path.join(os.homedir(), input.slice(2));77 return join(os.homedir(), input.slice(2));
97 }78 }
98 return input;79 return input;
99}80});
10081
101function resolveStateDir(raw: string | undefined): string {82const resolveStateDir = Effect.fn(function* (raw: string | undefined) {
83 const { join, resolve } = yield* Path.Path;
102 if (!raw || raw.trim().length === 0) {84 if (!raw || raw.trim().length === 0) {
103 return path.join(os.homedir(), ".t3", "userdata");85 return join(os.homedir(), ".t3", "userdata");
104 }86 }
105 return path.resolve(expandHomePath(raw.trim()));87 return resolve(yield* expandHomePath(raw.trim()));
106}88});
10789
108async function findAvailablePort(preferred: number): Promise<number> {90async function findAvailablePort(preferred: number): Promise<number> {
109 return new Promise((resolve, reject) => {91 return new Promise((resolve, reject) => {
...@@ -126,69 +108,57 @@ async function findAvailablePort(preferred: number): Promise<number> {...@@ -126,69 +108,57 @@ async function findAvailablePort(preferred: number): Promise<number> {
126 });108 });
127}109}
128110
129function resolveStaticDir(): string | undefined {111const resolveStaticDir = Effect.fn(function* () {
130 const __dirname = path.dirname(fileURLToPath(import.meta.url));112 const { join, resolve } = yield* Path.Path;
131113 const { stat } = yield* FileSystem.FileSystem;
132 const bundledClient = path.resolve(__dirname, "client");114 const bundledClient = resolve(join(import.meta.dirname, "client"));
133 try {115 const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe(
134 const stat = fs.statSync(path.join(bundledClient, "index.html"));116 Effect.orElseSucceed(() => undefined),
135 if (stat.isFile()) return bundledClient;117 );
136 } catch {118 if (bundledStat) return bundledClient;
137 // Not bundled — check monorepo layout.
138 }
139
140 const monorepoClient = path.resolve(__dirname, "../../web/dist");
141 try {
142 const stat = fs.statSync(path.join(monorepoClient, "index.html"));
143 if (stat.isFile()) return monorepoClient;
144 } catch {
145 // Not found — probably dev mode.
146 }
147119
120 const monorepoClient = resolve(join(import.meta.dirname, "../../web/dist"));
121 const monorepoStat = yield* stat(join(monorepoClient, "index.html")).pipe(
122 Effect.orElseSucceed(() => undefined),
123 );
124 if (monorepoStat) return monorepoClient;
148 return undefined;125 return undefined;
149}126});
150
151function resolveCliConfig(
152 input: CliInput,
153): Effect.Effect<ResolvedCliConfig, StartupError, CliConfig> {
154 return Effect.gen(function* () {
155 const cliConfig = yield* CliConfig;
156 const env = yield* CliEnvConfig.asEffect().pipe(
157 Effect.mapError(
158 (cause) =>
159 new StartupError({
160 message: "Failed to read environment configuration",
161 cause,
162 }),
163 ),
164 );
165 const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web";
166127
167 const cliPort = Option.getOrUndefined(input.port);128const resolveCliConfig = Effect.fn(function* (input: CliInput) {
168 const requestedPort =129 const cliConfig = yield* CliConfig;
169 cliPort === undefined ? env.port : yield* assertValidPort(cliPort, "--port");130 const env = yield* CliEnvConfig.asEffect().pipe(
131 Effect.mapError(
132 (cause) =>
133 new StartupError({
134 message: "Failed to read environment configuration",
135 cause,
136 }),
137 ),
138 );
139 const mode: RuntimeMode = env.mode === "desktop" ? "desktop" : "web";
170140
171 const port =141 const cliPort = Option.getOrUndefined(input.port);
172 requestedPort ??142 const port =
173 (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT));143 (cliPort === undefined ? env.port : cliPort) ??
174 const stateDir = resolveStateDir(env.stateDir);144 (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT));
175 const devUrl = env.devUrl;145 const stateDir = yield* resolveStateDir(env.stateDir);
176 const noBrowser = env.noBrowser ?? mode === "desktop";146 const devUrl = env.devUrl;
177 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;147 const noBrowser = env.noBrowser ?? mode === "desktop";
178 const staticDir = devUrl ? undefined : cliConfig.resolveStaticDir();148 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
149 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir();
179150
180 return {151 return {
181 mode,152 mode,
182 port,153 port,
183 stateDir,154 stateDir,
184 devUrl,155 devUrl,
185 noBrowser,156 noBrowser,
186 authToken,157 authToken,
187 staticDir,158 staticDir,
188 cwd: cliConfig.cwd,159 cwd: cliConfig.cwd,
189 };160 };
190 });161});
191}
192162
193const makeServerProgram = Effect.fn(function* (input: CliInput) {163const makeServerProgram = Effect.fn(function* (input: CliInput) {
194 const cliConfig = yield* CliConfig;164 const cliConfig = yield* CliConfig;
...@@ -253,6 +223,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) {...@@ -253,6 +223,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) {
253223
254export function makeCliCommand() {224export function makeCliCommand() {
255 const portFlag = Flag.integer("port").pipe(225 const portFlag = Flag.integer("port").pipe(
226 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
256 Flag.withDescription("Port for the HTTP/WebSocket server."),227 Flag.withDescription("Port for the HTTP/WebSocket server."),
257 Flag.optional,228 Flag.optional,
258 );229 );
...@@ -270,14 +241,25 @@ export function makeCliCommand() {...@@ -270,14 +241,25 @@ export function makeCliCommand() {
270 );241 );
271}242}
272243
273export const CliConfigLive = Layer.succeed(CliConfig, {244export const CliConfigLive = Layer.effect(
274 cwd: process.cwd(),245 CliConfig,
275 fixPath: Effect.sync(fixPath),246 Effect.gen(function* () {
276 findAvailablePort: (preferred) =>247 const fileSystem = yield* FileSystem.FileSystem;
277 Effect.tryPromise({248 const path = yield* Path.Path;
278 try: () => findAvailablePort(preferred),249 return {
279 catch: (cause) =>250 cwd: process.cwd(),
280 new StartupError({ message: "Failed to discover an available port", cause }),251 fixPath: Effect.sync(fixPath),
281 }),252 findAvailablePort: (preferred) =>
282 resolveStaticDir,253 Effect.tryPromise({
283} satisfies CliConfigShape);254 try: () => findAvailablePort(preferred),
255 catch: (cause) =>
256 new StartupError({ message: "Failed to discover an available port", cause }),
257 }),
258 resolveStaticDir: () =>
259 resolveStaticDir().pipe(
260 Effect.provideService(FileSystem.FileSystem, fileSystem),
261 Effect.provideService(Path.Path, path),
262 ),
263 } satisfies CliConfigShape;
264 }),
265);