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";
99import { version } from "../package.json" with { type: "json" };
1010import { ServerLive } from "./wsServer";
1111
12const RuntimeLayer = Layer.mergeAll(CliConfigLive, ServerLive, OpenLive, NodeServices.layer);
13
14Command.run(makeCliCommand(), { version }).pipe(
15 Effect.provide(RuntimeLayer),
16 NodeRuntime.runMain,
12const RuntimeLayer = Layer.provideMerge(
13 Layer.mergeAll(CliConfigLive, ServerLive, OpenLive),
14 NodeServices.layer,
1715);
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(
2424 cwd: "/tmp/t3-test-workspace",
2525 fixPath: Effect.void,
2626 findAvailablePort,
27 resolveStaticDir: () => undefined,
27 resolveStaticDir: () => Effect.undefined,
2828 } satisfies CliConfigShape),
2929 Layer.succeed(Server, {
3030 createServer,
apps/server/src/main.ts+92-110
......@@ -1,17 +1,19 @@
1import fs from "node:fs";
21import net from "node:net";
32import os from "node:os";
4import path from "node:path";
5import { fileURLToPath } from "node:url";
63
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as Config from "effect/Config";
12import { ServiceMap } from "effect";
13import * as Command from "effect/unstable/cli/Command";
14import * as Flag from "effect/unstable/cli/Flag";
4import {
5 ServiceMap,
6 Data,
7 Effect,
8 Layer,
9 Option,
10 Config,
11 Path,
12 Schema,
13 FileSystem,
14 PlatformError,
15} from "effect";
16import { Command, Flag } from "effect/unstable/cli";
1517
1618import { fixPath } from "./fixPath";
1719import { Open } from "./open";
......@@ -31,22 +33,11 @@ interface CliInput {
3133 readonly token: Option.Option<string>;
3234}
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
4536export interface CliConfigShape {
4637 readonly cwd: string;
4738 readonly fixPath: Effect.Effect<unknown, never>;
4839 readonly findAvailablePort: (preferred: number) => Effect.Effect<number, StartupError>;
49 readonly resolveStaticDir: () => string | undefined;
40 readonly resolveStaticDir: () => Effect.Effect<string | undefined, PlatformError.PlatformError>;
5041}
5142
5243export class CliConfig extends ServiceMap.Service<CliConfig, CliConfigShape>()(
......@@ -79,31 +70,22 @@ const CliEnvConfig = Config.all({
7970 ),
8071});
8172
82function assertValidPort(value: number, source: string): Effect.Effect<number, StartupError> {
83 if (Number.isInteger(value) && value >= 1 && value <= 65535) {
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 {
73const expandHomePath = Effect.fn(function* (input: string) {
74 const { join } = yield* Path.Path;
9475 if (input === "~") return os.homedir();
9576 if (input.startsWith("~/")) {
96 return path.join(os.homedir(), input.slice(2));
77 return join(os.homedir(), input.slice(2));
9778 }
9879 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;
10284 if (!raw || raw.trim().length === 0) {
103 return path.join(os.homedir(), ".t3", "userdata");
85 return join(os.homedir(), ".t3", "userdata");
10486 }
105 return path.resolve(expandHomePath(raw.trim()));
106}
87 return resolve(yield* expandHomePath(raw.trim()));
88});
10789
10890async function findAvailablePort(preferred: number): Promise<number> {
10991 return new Promise((resolve, reject) => {
......@@ -126,69 +108,57 @@ async function findAvailablePort(preferred: number): Promise<number> {
126108 });
127109}
128110
129function resolveStaticDir(): string | undefined {
130 const __dirname = path.dirname(fileURLToPath(import.meta.url));
131
132 const bundledClient = path.resolve(__dirname, "client");
133 try {
134 const stat = fs.statSync(path.join(bundledClient, "index.html"));
135 if (stat.isFile()) return bundledClient;
136 } catch {
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 }
111const resolveStaticDir = Effect.fn(function* () {
112 const { join, resolve } = yield* Path.Path;
113 const { stat } = yield* FileSystem.FileSystem;
114 const bundledClient = resolve(join(import.meta.dirname, "client"));
115 const bundledStat = yield* stat(join(bundledClient, "index.html")).pipe(
116 Effect.orElseSucceed(() => undefined),
117 );
118 if (bundledStat) return bundledClient;
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;
148125 return undefined;
149}
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";
126});
166127
167 const cliPort = Option.getOrUndefined(input.port);
168 const requestedPort =
169 cliPort === undefined ? env.port : yield* assertValidPort(cliPort, "--port");
128const resolveCliConfig = Effect.fn(function* (input: CliInput) {
129 const cliConfig = yield* CliConfig;
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 =
172 requestedPort ??
173 (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT));
174 const stateDir = resolveStateDir(env.stateDir);
175 const devUrl = env.devUrl;
176 const noBrowser = env.noBrowser ?? mode === "desktop";
177 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
178 const staticDir = devUrl ? undefined : cliConfig.resolveStaticDir();
141 const cliPort = Option.getOrUndefined(input.port);
142 const port =
143 (cliPort === undefined ? env.port : cliPort) ??
144 (mode === "desktop" ? DEFAULT_PORT : yield* cliConfig.findAvailablePort(DEFAULT_PORT));
145 const stateDir = yield* resolveStateDir(env.stateDir);
146 const devUrl = env.devUrl;
147 const noBrowser = env.noBrowser ?? mode === "desktop";
148 const authToken = Option.getOrUndefined(input.token) ?? env.authToken;
149 const staticDir = devUrl ? undefined : yield* cliConfig.resolveStaticDir();
179150
180 return {
181 mode,
182 port,
183 stateDir,
184 devUrl,
185 noBrowser,
186 authToken,
187 staticDir,
188 cwd: cliConfig.cwd,
189 };
190 });
191}
151 return {
152 mode,
153 port,
154 stateDir,
155 devUrl,
156 noBrowser,
157 authToken,
158 staticDir,
159 cwd: cliConfig.cwd,
160 };
161});
192162
193163const makeServerProgram = Effect.fn(function* (input: CliInput) {
194164 const cliConfig = yield* CliConfig;
......@@ -253,6 +223,7 @@ const makeServerProgram = Effect.fn(function* (input: CliInput) {
253223
254224export function makeCliCommand() {
255225 const portFlag = Flag.integer("port").pipe(
226 Flag.withSchema(Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 65535 }))),
256227 Flag.withDescription("Port for the HTTP/WebSocket server."),
257228 Flag.optional,
258229 );
......@@ -270,14 +241,25 @@ export function makeCliCommand() {
270241 );
271242}
272243
273export const CliConfigLive = Layer.succeed(CliConfig, {
274 cwd: process.cwd(),
275 fixPath: Effect.sync(fixPath),
276 findAvailablePort: (preferred) =>
277 Effect.tryPromise({
278 try: () => findAvailablePort(preferred),
279 catch: (cause) =>
280 new StartupError({ message: "Failed to discover an available port", cause }),
281 }),
282 resolveStaticDir,
283} satisfies CliConfigShape);
244export const CliConfigLive = Layer.effect(
245 CliConfig,
246 Effect.gen(function* () {
247 const fileSystem = yield* FileSystem.FileSystem;
248 const path = yield* Path.Path;
249 return {
250 cwd: process.cwd(),
251 fixPath: Effect.sync(fixPath),
252 findAvailablePort: (preferred) =>
253 Effect.tryPromise({
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);