Unified · Split
| @@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => { · 35 unmodified lines | ||
| 36 | 36 | ]); |
| 37 | 37 | }); |
| 38 | 38 | |
| 39 | + it("treats wildcard-bound ports as unavailable even when loopback probing succeeds", async () => { | |
| 40 | + const canListenOnHost = vi.fn(async (port: number, host: string) => { | |
| 41 | + if (port === 3773 && host === "127.0.0.1") return true; | |
| 42 | + if (port === 3773 && host === "0.0.0.0") return false; | |
| 43 | + return port === 3774; | |
| 44 | + }); | |
| 45 | + | |
| 46 | + await expect( | |
| 47 | + resolveDesktopBackendPort({ | |
| 48 | + host: "127.0.0.1", | |
| 49 | + requiredHosts: ["0.0.0.0"], | |
| 50 | + startPort: 3773, | |
| 51 | + canListenOnHost, | |
| 52 | + }), | |
| 53 | + ).resolves.toBe(3774); | |
| 54 | + | |
| 55 | + expect(canListenOnHost.mock.calls).toEqual([ | |
| 56 | + [3773, "127.0.0.1"], | |
| 57 | + [3773, "0.0.0.0"], | |
| 58 | + [3774, "127.0.0.1"], | |
| 59 | + [3774, "0.0.0.0"], | |
| 60 | + ]); | |
| 61 | + }); | |
| 62 | + | |
| 63 | + it("checks overlapping hosts sequentially to avoid self-interference", async () => { | |
| 64 | + let inFlightCount = 0; | |
| 65 | + const canListenOnHost = vi.fn(async (_port: number, _host: string) => { | |
| 66 | + inFlightCount += 1; | |
| 67 | + const overlapped = inFlightCount > 1; | |
| 68 | + await Promise.resolve(); | |
| 69 | + inFlightCount -= 1; | |
| 70 | + return !overlapped; | |
| 71 | + }); | |
| 72 | + | |
| 73 | + await expect( | |
| 74 | + resolveDesktopBackendPort({ | |
| 75 | + host: "127.0.0.1", | |
| 76 | + requiredHosts: ["0.0.0.0", "::"], | |
| 77 | + startPort: 3773, | |
| 78 | + maxPort: 3773, | |
| 79 | + canListenOnHost, | |
| 80 | + }), | |
| 81 | + ).resolves.toBe(3773); | |
| 82 | + | |
| 83 | + expect(canListenOnHost.mock.calls).toEqual([ | |
| 84 | + [3773, "127.0.0.1"], | |
| 85 | + [3773, "0.0.0.0"], | |
| 86 | + [3773, "::"], | |
| 87 | + ]); | |
| 88 | + }); | |
| 89 | + | |
| 39 | 90 | it("fails when the scan range is exhausted", async () => { |
| 40 | 91 | const canListenOnHost = vi.fn(async () => false); |
| 41 | 92 | |
| @@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => { · 4 unmodified lines | ||
| 46 | 97 | maxPort: 65535, |
| 47 | 98 | canListenOnHost, |
| 48 | 99 | }), |
| 49 | − ).rejects.toThrow("No desktop backend port is available on 127.0.0.1 between 65534 and 65535"); | |
| 100 | + ).rejects.toThrow( | |
| 101 | + "No desktop backend port is available on hosts 127.0.0.1 between 65534 and 65535", | |
| 102 | + ); | |
| 50 | 103 | |
| 51 | 104 | expect(canListenOnHost.mock.calls).toEqual([ |
| 52 | 105 | [65534, "127.0.0.1"], |
| @@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions { · 7 unmodified lines | ||
| 8 | 8 | readonly host: string; |
| 9 | 9 | readonly startPort?: number; |
| 10 | 10 | readonly maxPort?: number; |
| 11 | + readonly requiredHosts?: ReadonlyArray<string>; | |
| 11 | 12 | readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>; |
| 12 | 13 | } |
| 13 | 14 | |
| @@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise<boole · 7 unmodified lines | ||
| 21 | 22 | const isValidPort = (port: number): boolean => |
| 22 | 23 | Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; |
| 23 | 24 | |
| 25 | +const normalizeHosts = ( | |
| 26 | + host: string, | |
| 27 | + requiredHosts: ReadonlyArray<string>, | |
| 28 | +): ReadonlyArray<string> => | |
| 29 | + Array.from( | |
| 30 | + new Set( | |
| 31 | + [host, ...requiredHosts] | |
| 32 | + .map((candidate) => candidate.trim()) | |
| 33 | + .filter((candidate) => candidate.length > 0), | |
| 34 | + ), | |
| 35 | + ); | |
| 36 | + | |
| 37 | +async function canListenOnAllHosts( | |
| 38 | + port: number, | |
| 39 | + hosts: ReadonlyArray<string>, | |
| 40 | + canListenOnHost: (port: number, host: string) => Promise<boolean>, | |
| 41 | +): Promise<boolean> { | |
| 42 | + for (const candidateHost of hosts) { | |
| 43 | + if (!(await canListenOnHost(port, candidateHost))) { | |
| 44 | + return false; | |
| 45 | + } | |
| 46 | + } | |
| 47 | + | |
| 48 | + return true; | |
| 49 | +} | |
| 50 | + | |
| 24 | 51 | export async function resolveDesktopBackendPort({ |
| 25 | 52 | host, |
| 26 | 53 | startPort = DEFAULT_DESKTOP_BACKEND_PORT, |
| 27 | 54 | maxPort = MAX_TCP_PORT, |
| 55 | + requiredHosts = [], | |
| 28 | 56 | canListenOnHost = defaultCanListenOnHost, |
| 29 | 57 | }: ResolveDesktopBackendPortOptions): Promise<number> { |
| 30 | 58 | if (!isValidPort(startPort)) { |
| @@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({ · 8 unmodified lines | ||
| 39 | 67 | throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); |
| 40 | 68 | } |
| 41 | 69 | |
| 70 | + const hostsToCheck = normalizeHosts(host, requiredHosts); | |
| 71 | + | |
| 42 | 72 | // Keep desktop startup predictable across app restarts by probing upward from |
| 43 | 73 | // the same preferred port instead of picking a fresh ephemeral port. |
| 44 | 74 | for (let port = startPort; port <= maxPort; port += 1) { |
| 45 | − if (await canListenOnHost(port, host)) { | |
| 75 | + if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { | |
| 46 | 76 | return port; |
| 47 | 77 | } |
| 48 | 78 | } |
| 49 | 79 | |
| 50 | 80 | throw new Error( |
| 51 | − `No desktop backend port is available on ${host} between ${startPort} and ${maxPort}`, | |
| 81 | + `No desktop backend port is available on hosts ${hostsToCheck.join(", ")} between ${startPort} and ${maxPort}`, | |
| 52 | 82 | ); |
| 53 | 83 | } |
| @@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; · 116 unmodified lines | ||
| 117 | 117 | const DESKTOP_UPDATE_CHANNEL = "latest"; |
| 118 | 118 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; |
| 119 | 119 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; |
| 120 | +const DESKTOP_REQUIRED_PORT_PROBE_HOSTS = ["0.0.0.0", "::"] as const; | |
| 120 | 121 | |
| 121 | 122 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; |
| 122 | 123 | type LinuxDesktopNamedApp = Electron.App & { |
| @@ -1781,6 +1782,7 @@ async function bootstrap(): Promise<void> { · 1658 unmodified lines | ||
| 1781 | 1782 | (await resolveDesktopBackendPort({ |
| 1782 | 1783 | host: DESKTOP_LOOPBACK_HOST, |
| 1783 | 1784 | startPort: DEFAULT_DESKTOP_BACKEND_PORT, |
| 1785 | + requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, | |
| 1784 | 1786 | })); |
| 1785 | 1787 | writeDesktopLogHeader( |
| 1786 | 1788 | configuredBackendPort === undefined |
| @@ -20,7 +20,10 @@ import { · 19 unmodified lines | ||
| 20 | 20 | AuthError, |
| 21 | 21 | type ServerAuthShape, |
| 22 | 22 | } from "../Services/ServerAuth.ts"; |
| 23 | −import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | |
| 23 | +import { | |
| 24 | + SessionCredentialError, | |
| 25 | + SessionCredentialService, | |
| 26 | +} from "../Services/SessionCredentialService.ts"; | |
| 24 | 27 | import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; |
| 25 | 28 | |
| 26 | 29 | type BootstrapExchangeResult = { |
| @@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () { · 38 unmodified lines | ||
| 65 | 68 | |
| 66 | 69 | const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> => |
| 67 | 70 | sessions.verify(token).pipe( |
| 71 | + Effect.tapError((cause: SessionCredentialError) => | |
| 72 | + Effect.logWarning("Rejected authenticated session credential.").pipe( | |
| 73 | + Effect.annotateLogs({ | |
| 74 | + reason: cause.message, | |
| 75 | + }), | |
| 76 | + ), | |
| 77 | + ), | |
| 68 | 78 | Effect.map((session) => ({ |
| 69 | 79 | sessionId: session.sessionId, |
| 70 | 80 | subject: session.subject, |
| @@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { · 32 unmodified lines | ||
| 33 | 33 | |
| 34 | 34 | expect(descriptor.policy).toBe("desktop-managed-local"); |
| 35 | 35 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); |
| 36 | + expect(descriptor.sessionCookieName).toBe("t3_session_3773"); | |
| 36 | 37 | }).pipe( |
| 37 | 38 | Effect.provide( |
| 38 | 39 | makeServerAuthPolicyLayer({ |
| 39 | 40 | mode: "desktop", |
| 41 | + port: 3773, | |
| 40 | 42 | }), |
| 41 | 43 | ), |
| 42 | 44 | ), |
| @@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { · 23 unmodified lines | ||
| 66 | 68 | |
| 67 | 69 | expect(descriptor.policy).toBe("loopback-browser"); |
| 68 | 70 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); |
| 71 | + expect(descriptor.sessionCookieName).toBe("t3_session"); | |
| 69 | 72 | }).pipe( |
| 70 | 73 | Effect.provide( |
| 71 | 74 | makeServerAuthPolicyLayer({ |
| @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { ServerConfig } from "../../config.ts"; |
| 5 | 5 | import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; |
| 6 | −import { SESSION_COOKIE_NAME } from "../utils.ts"; | |
| 6 | +import { resolveSessionCookieName } from "../utils.ts"; | |
| 7 | 7 | import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; |
| 8 | 8 | |
| 9 | 9 | export const makeServerAuthPolicy = Effect.gen(function* () { |
| @@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () { · 20 unmodified lines | ||
| 30 | 30 | policy, |
| 31 | 31 | bootstrapMethods, |
| 32 | 32 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], |
| 33 | − sessionCookieName: SESSION_COOKIE_NAME, | |
| 33 | + sessionCookieName: resolveSessionCookieName({ | |
| 34 | + mode: config.mode, | |
| 35 | + port: config.port, | |
| 36 | + }), | |
| 34 | 37 | }; |
| 35 | 38 | |
| 36 | 39 | return { |
| @@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from " · 1 unmodified line | ||
| 2 | 2 | import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; |
| 3 | 3 | import { Option } from "effect"; |
| 4 | 4 | |
| 5 | +import { ServerConfig } from "../../config.ts"; | |
| 5 | 6 | import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; |
| 6 | 7 | import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; |
| 7 | 8 | import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; |
| 8 | −import { SESSION_COOKIE_NAME } from "../utils.ts"; | |
| 9 | 9 | import { |
| 10 | 10 | SessionCredentialError, |
| 11 | 11 | SessionCredentialService, |
| @@ -17,6 +17,7 @@ import { · 5 unmodified lines | ||
| 17 | 17 | import { |
| 18 | 18 | base64UrlDecodeUtf8, |
| 19 | 19 | base64UrlEncode, |
| 20 | + resolveSessionCookieName, | |
| 20 | 21 | signPayload, |
| 21 | 22 | timingSafeEqualBase64Url, |
| 22 | 23 | } from "../utils.ts"; |
| @@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli · 58 unmodified lines | ||
| 81 | 82 | } |
| 82 | 83 | |
| 83 | 84 | export const makeSessionCredentialService = Effect.gen(function* () { |
| 85 | + const serverConfig = yield* ServerConfig; | |
| 84 | 86 | const secretStore = yield* ServerSecretStore; |
| 85 | 87 | const authSessions = yield* AuthSessionRepository; |
| 86 | 88 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); |
| 87 | 89 | const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); |
| 88 | 90 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); |
| 91 | + const cookieName = resolveSessionCookieName({ | |
| 92 | + mode: serverConfig.mode, | |
| 93 | + port: serverConfig.port, | |
| 94 | + }); | |
| 89 | 95 | |
| 90 | 96 | const toSessionCredentialError = (message: string) => (cause: unknown) => |
| 91 | 97 | new SessionCredentialError({ |
| @@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { · 380 unmodified lines | ||
| 472 | 478 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); |
| 473 | 479 | |
| 474 | 480 | return { |
| 475 | − cookieName: SESSION_COOKIE_NAME, | |
| 481 | + cookieName, | |
| 476 | 482 | issue, |
| 477 | 483 | verify, |
| 478 | 484 | issueWebSocketToken, |
| @@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/ · 1 unmodified line | ||
| 2 | 2 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 3 | 3 | import * as Crypto from "node:crypto"; |
| 4 | 4 | |
| 5 | −export const SESSION_COOKIE_NAME = "t3_session"; | |
| 5 | +const SESSION_COOKIE_NAME = "t3_session"; | |
| 6 | + | |
| 7 | +export function resolveSessionCookieName(input: { | |
| 8 | + readonly mode: "web" | "desktop"; | |
| 9 | + readonly port: number; | |
| 10 | +}): string { | |
| 11 | + if (input.mode !== "desktop") { | |
| 12 | + return SESSION_COOKIE_NAME; | |
| 13 | + } | |
| 14 | + | |
| 15 | + return `${SESSION_COOKIE_NAME}_${input.port}`; | |
| 16 | +} | |
| 6 | 17 | |
| 7 | 18 | export function base64UrlEncode(input: string | Uint8Array): string { |
| 8 | 19 | const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); |
| @@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { · 788 unmodified lines | ||
| 789 | 789 | "browser-session-cookie", |
| 790 | 790 | "bearer-session-token", |
| 791 | 791 | ]); |
| 792 | − assert.equal(body.auth.sessionCookieName, "t3_session"); | |
| 792 | + assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); | |
| 793 | 793 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 794 | 794 | ); |
| 795 | 795 | |
| @@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; · 4 unmodified lines | ||
| 5 | 5 | import { Effect } from "effect"; |
| 6 | 6 | |
| 7 | 7 | import { |
| 8 | + checkPortAvailabilityOnHosts, | |
| 8 | 9 | createDevRunnerEnv, |
| 9 | 10 | findFirstAvailableOffset, |
| 10 | 11 | resolveModePortOffsets, |
| @@ -164,11 +165,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 153 unmodified lines | ||
| 164 | 165 | const env = yield* createDevRunnerEnv({ |
| 165 | 166 | mode: "dev:desktop", |
| 166 | 167 | baseEnv: { |
| 167 | − T3CODE_PORT: "3773", | |
| 168 | + T3CODE_PORT: "13773", | |
| 168 | 169 | T3CODE_MODE: "web", |
| 169 | 170 | T3CODE_NO_BROWSER: "0", |
| 170 | 171 | T3CODE_HOST: "0.0.0.0", |
| 171 | − VITE_WS_URL: "ws://localhost:3773", | |
| 172 | + VITE_WS_URL: "ws://localhost:13773", | |
| 172 | 173 | }, |
| 173 | 174 | serverOffset: 0, |
| 174 | 175 | webOffset: 0, |
| @@ -193,6 +194,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 18 unmodified lines | ||
| 193 | 194 | assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); |
| 194 | 195 | }), |
| 195 | 196 | ); |
| 197 | + | |
| 198 | + it.effect("defaults dev server mode to the higher backend port range", () => | |
| 199 | + Effect.gen(function* () { | |
| 200 | + const env = yield* createDevRunnerEnv({ | |
| 201 | + mode: "dev", | |
| 202 | + baseEnv: {}, | |
| 203 | + serverOffset: 0, | |
| 204 | + webOffset: 0, | |
| 205 | + t3Home: undefined, | |
| 206 | + noBrowser: undefined, | |
| 207 | + autoBootstrapProjectFromCwd: undefined, | |
| 208 | + logWebSocketEvents: undefined, | |
| 209 | + host: undefined, | |
| 210 | + port: undefined, | |
| 211 | + devUrl: undefined, | |
| 212 | + }); | |
| 213 | + | |
| 214 | + assert.equal(env.T3CODE_PORT, "13773"); | |
| 215 | + assert.equal(env.VITE_HTTP_URL, "http://localhost:13773"); | |
| 216 | + assert.equal(env.VITE_WS_URL, "ws://localhost:13773"); | |
| 217 | + }), | |
| 218 | + ); | |
| 196 | 219 | }); |
| 197 | 220 | |
| 198 | 221 | describe("findFirstAvailableOffset", () => { |
| @@ -211,7 +234,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 12 unmodified lines | ||
| 211 | 234 | |
| 212 | 235 | it.effect("advances until all required ports are available", () => |
| 213 | 236 | Effect.gen(function* () { |
| 214 | − const taken = new Set([3773, 5733, 3774, 5734]); | |
| 237 | + const taken = new Set([13773, 5733, 13774, 5734]); | |
| 215 | 238 | const offset = yield* findFirstAvailableOffset({ |
| 216 | 239 | startOffset: 0, |
| 217 | 240 | requireServerPort: true, |
| @@ -223,16 +246,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 5 unmodified lines | ||
| 223 | 246 | }), |
| 224 | 247 | ); |
| 225 | 248 | |
| 226 | − it.effect("allows offsets where only non-required ports exceed max", () => | |
| 249 | + it.effect("allows offsets where the non-required server port exceeds max", () => | |
| 227 | 250 | Effect.gen(function* () { |
| 228 | 251 | const offset = yield* findFirstAvailableOffset({ |
| 229 | − startOffset: 59_803, | |
| 230 | − requireServerPort: true, | |
| 231 | − requireWebPort: false, | |
| 252 | + startOffset: 59_802, | |
| 253 | + requireServerPort: false, | |
| 254 | + requireWebPort: true, | |
| 232 | 255 | checkPortAvailability: () => Effect.succeed(true), |
| 233 | 256 | }); |
| 234 | 257 | |
| 235 | − assert.equal(offset, 59_803); | |
| 258 | + assert.equal(offset, 59_802); | |
| 259 | + }), | |
| 260 | + ); | |
| 261 | + }); | |
| 262 | + | |
| 263 | + describe("checkPortAvailabilityOnHosts", () => { | |
| 264 | + it.effect("checks overlapping hosts sequentially to avoid self-interference", () => | |
| 265 | + Effect.gen(function* () { | |
| 266 | + let inFlightCount = 0; | |
| 267 | + const calls: Array<[number, string]> = []; | |
| 268 | + | |
| 269 | + const available = yield* checkPortAvailabilityOnHosts( | |
| 270 | + 13_773, | |
| 271 | + ["127.0.0.1", "0.0.0.0", "::"], | |
| 272 | + (port, host) => | |
| 273 | + Effect.promise(async () => { | |
| 274 | + calls.push([port, host]); | |
| 275 | + inFlightCount += 1; | |
| 276 | + const overlapped = inFlightCount > 1; | |
| 277 | + await Promise.resolve(); | |
| 278 | + inFlightCount -= 1; | |
| 279 | + return !overlapped; | |
| 280 | + }), | |
| 281 | + ); | |
| 282 | + | |
| 283 | + assert.equal(available, true); | |
| 284 | + assert.deepStrictEqual(calls, [ | |
| 285 | + [13_773, "127.0.0.1"], | |
| 286 | + [13_773, "0.0.0.0"], | |
| 287 | + [13_773, "::"], | |
| 288 | + ]); | |
| 236 | 289 | }), |
| 237 | 290 | ); |
| 238 | 291 | }); |
| @@ -240,7 +293,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 1 unmodified line | ||
| 240 | 293 | describe("resolveModePortOffsets", () => { |
| 241 | 294 | it.effect("uses a shared fallback offset for dev mode", () => |
| 242 | 295 | Effect.gen(function* () { |
| 243 | − const taken = new Set([3773, 5733]); | |
| 296 | + const taken = new Set([13773, 5733]); | |
| 244 | 297 | const offsets = yield* resolveModePortOffsets({ |
| 245 | 298 | mode: "dev", |
| 246 | 299 | startOffset: 0, |
| @@ -270,7 +323,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { · 23 unmodified lines | ||
| 270 | 323 | |
| 271 | 324 | it.effect("shifts only server offset for dev:server", () => |
| 272 | 325 | Effect.gen(function* () { |
| 273 | − const taken = new Set([3773]); | |
| 326 | + const taken = new Set([13773]); | |
| 274 | 327 | const offsets = yield* resolveModePortOffsets({ |
| 275 | 328 | mode: "dev:server", |
| 276 | 329 | startOffset: 0, |
| @@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from · 8 unmodified lines | ||
| 9 | 9 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 10 | 10 | import { ChildProcess } from "effect/unstable/process"; |
| 11 | 11 | |
| 12 | −const BASE_SERVER_PORT = 3773; | |
| 12 | +const BASE_SERVER_PORT = 13773; | |
| 13 | 13 | const BASE_WEB_PORT = 5733; |
| 14 | 14 | const MAX_HASH_OFFSET = 3000; |
| 15 | 15 | const MAX_PORT = 65535; |
| 16 | 16 | const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; |
| 17 | +const DEV_PORT_PROBE_HOSTS = ["127.0.0.1", "0.0.0.0", "::1", "::"] as const; | |
| 17 | 18 | |
| 18 | 19 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => |
| 19 | 20 | path.join(homedir(), ".t3"), |
| @@ -221,10 +222,28 @@ function portPairForOffset(offset: number): { · 201 unmodified lines | ||
| 221 | 222 | }; |
| 222 | 223 | } |
| 223 | 224 | |
| 225 | +export function checkPortAvailabilityOnHosts<R>( | |
| 226 | + port: number, | |
| 227 | + hosts: ReadonlyArray<string>, | |
| 228 | + canListenOnHost: (port: number, host: string) => Effect.Effect<boolean, never, R>, | |
| 229 | +): Effect.Effect<boolean, never, R> { | |
| 230 | + return Effect.gen(function* () { | |
| 231 | + for (const host of hosts) { | |
| 232 | + if (!(yield* canListenOnHost(port, host))) { | |
| 233 | + return false; | |
| 234 | + } | |
| 235 | + } | |
| 236 | + | |
| 237 | + return true; | |
| 238 | + }); | |
| 239 | +} | |
| 240 | + | |
| 224 | 241 | const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) => |
| 225 | 242 | Effect.gen(function* () { |
| 226 | 243 | const net = yield* NetService; |
| 227 | − return yield* net.isPortAvailableOnLoopback(port); | |
| 244 | + return yield* checkPortAvailabilityOnHosts(port, DEV_PORT_PROBE_HOSTS, (candidatePort, host) => | |
| 245 | + net.canListenOnHost(candidatePort, host), | |
| 246 | + ); | |
| 228 | 247 | }); |
| 229 | 248 | |
| 230 | 249 | interface FindFirstAvailableOffsetInput<R = NetService> { |