| author | |
| committer | |
| log | 4ae9de311b4627f6cf0ce00a67172cb9a3fb7c1d |
| tree | 107ea51960b81cb914f99baeab581657730910ee |
| parent | 5fa09fa2d9886a535b1629ce58a9ae88fc912f0c |
| signature | Signed by PGP key B5690EEEBB952194 |
11 files changed, 212 insertions(+), 22 deletions(-)
apps/desktop/src/backendPort.test.ts+54-1| ... | @@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => { | ... | @@ -36,6 +36,57 @@ describe("resolveDesktopBackendPort", () => { |
| 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 | it("fails when the scan range is exhausted", async () => { | 90 | it("fails when the scan range is exhausted", async () => { |
| 40 | const canListenOnHost = vi.fn(async () => false); | 91 | const canListenOnHost = vi.fn(async () => false); |
| 41 | 92 | ||
| ... | @@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => { | ... | @@ -46,7 +97,9 @@ describe("resolveDesktopBackendPort", () => { |
| 46 | maxPort: 65535, | 97 | maxPort: 65535, |
| 47 | canListenOnHost, | 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 | expect(canListenOnHost.mock.calls).toEqual([ | 104 | expect(canListenOnHost.mock.calls).toEqual([ |
| 52 | [65534, "127.0.0.1"], | 105 | [65534, "127.0.0.1"], |
apps/desktop/src/backendPort.ts+32-2| ... | @@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions { | ... | @@ -8,6 +8,7 @@ export interface ResolveDesktopBackendPortOptions { |
| 8 | readonly host: string; | 8 | readonly host: string; |
| 9 | readonly startPort?: number; | 9 | readonly startPort?: number; |
| 10 | readonly maxPort?: number; | 10 | readonly maxPort?: number; |
| 11 | readonly requiredHosts?: ReadonlyArray<string>; | ||
| 11 | readonly canListenOnHost?: (port: number, host: string) => Promise<boolean>; | 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 | ... | @@ -21,10 +22,37 @@ const defaultCanListenOnHost = async (port: number, host: string): Promise<boole |
| 21 | const isValidPort = (port: number): boolean => | 22 | const isValidPort = (port: number): boolean => |
| 22 | Number.isInteger(port) && port >= 1 && port <= MAX_TCP_PORT; | 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 | export async function resolveDesktopBackendPort({ | 51 | export async function resolveDesktopBackendPort({ |
| 25 | host, | 52 | host, |
| 26 | startPort = DEFAULT_DESKTOP_BACKEND_PORT, | 53 | startPort = DEFAULT_DESKTOP_BACKEND_PORT, |
| 27 | maxPort = MAX_TCP_PORT, | 54 | maxPort = MAX_TCP_PORT, |
| 55 | requiredHosts = [], | ||
| 28 | canListenOnHost = defaultCanListenOnHost, | 56 | canListenOnHost = defaultCanListenOnHost, |
| 29 | }: ResolveDesktopBackendPortOptions): Promise<number> { | 57 | }: ResolveDesktopBackendPortOptions): Promise<number> { |
| 30 | if (!isValidPort(startPort)) { | 58 | if (!isValidPort(startPort)) { |
| ... | @@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({ | ... | @@ -39,15 +67,17 @@ export async function resolveDesktopBackendPort({ |
| 39 | throw new Error(`Desktop backend max port ${maxPort} is below start port ${startPort}`); | 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 | // Keep desktop startup predictable across app restarts by probing upward from | 72 | // Keep desktop startup predictable across app restarts by probing upward from |
| 43 | // the same preferred port instead of picking a fresh ephemeral port. | 73 | // the same preferred port instead of picking a fresh ephemeral port. |
| 44 | for (let port = startPort; port <= maxPort; port += 1) { | 74 | for (let port = startPort; port <= maxPort; port += 1) { |
| 45 | if (await canListenOnHost(port, host)) { | 75 | if (await canListenOnAllHosts(port, hostsToCheck, canListenOnHost)) { |
| 46 | return port; | 76 | return port; |
| 47 | } | 77 | } |
| 48 | } | 78 | } |
| 49 | 79 | ||
| 50 | throw new Error( | 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 | } |
apps/desktop/src/main.ts+2| ... | @@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; | ... | @@ -117,6 +117,7 @@ const AUTO_UPDATE_POLL_INTERVAL_MS = 4 * 60 * 60 * 1000; |
| 117 | const DESKTOP_UPDATE_CHANNEL = "latest"; | 117 | const DESKTOP_UPDATE_CHANNEL = "latest"; |
| 118 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; | 118 | const DESKTOP_UPDATE_ALLOW_PRERELEASE = false; |
| 119 | const DESKTOP_LOOPBACK_HOST = "127.0.0.1"; | 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 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; | 122 | type DesktopUpdateErrorContext = DesktopUpdateState["errorContext"]; |
| 122 | type LinuxDesktopNamedApp = Electron.App & { | 123 | type LinuxDesktopNamedApp = Electron.App & { |
| ... | @@ -1781,6 +1782,7 @@ async function bootstrap(): Promise<void> { | ... | @@ -1781,6 +1782,7 @@ async function bootstrap(): Promise<void> { |
| 1781 | (await resolveDesktopBackendPort({ | 1782 | (await resolveDesktopBackendPort({ |
| 1782 | host: DESKTOP_LOOPBACK_HOST, | 1783 | host: DESKTOP_LOOPBACK_HOST, |
| 1783 | startPort: DEFAULT_DESKTOP_BACKEND_PORT, | 1784 | startPort: DEFAULT_DESKTOP_BACKEND_PORT, |
| 1785 | requiredHosts: DESKTOP_REQUIRED_PORT_PROBE_HOSTS, | ||
| 1784 | })); | 1786 | })); |
| 1785 | writeDesktopLogHeader( | 1787 | writeDesktopLogHeader( |
| 1786 | configuredBackendPort === undefined | 1788 | configuredBackendPort === undefined |
apps/server/src/auth/Layers/ServerAuth.ts+11-1| ... | @@ -20,7 +20,10 @@ import { | ... | @@ -20,7 +20,10 @@ import { |
| 20 | AuthError, | 20 | AuthError, |
| 21 | type ServerAuthShape, | 21 | type ServerAuthShape, |
| 22 | } from "../Services/ServerAuth.ts"; | 22 | } from "../Services/ServerAuth.ts"; |
| 23 | import { SessionCredentialService } from "../Services/SessionCredentialService.ts"; | 23 | import { |
| 24 | SessionCredentialError, | ||
| 25 | SessionCredentialService, | ||
| 26 | } from "../Services/SessionCredentialService.ts"; | ||
| 24 | import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; | 27 | import { AuthControlPlaneLive, AuthCoreLive } from "./AuthControlPlane.ts"; |
| 25 | 28 | ||
| 26 | type BootstrapExchangeResult = { | 29 | type BootstrapExchangeResult = { |
| ... | @@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () { | ... | @@ -65,6 +68,13 @@ export const makeServerAuth = Effect.gen(function* () { |
| 65 | 68 | ||
| 66 | const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> => | 69 | const authenticateToken = (token: string): Effect.Effect<AuthenticatedSession, AuthError> => |
| 67 | sessions.verify(token).pipe( | 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 | Effect.map((session) => ({ | 78 | Effect.map((session) => ({ |
| 69 | sessionId: session.sessionId, | 79 | sessionId: session.sessionId, |
| 70 | subject: session.subject, | 80 | subject: session.subject, |
apps/server/src/auth/Layers/ServerAuthPolicy.test.ts+3| ... | @@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { | ... | @@ -33,10 +33,12 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { |
| 33 | 33 | ||
| 34 | expect(descriptor.policy).toBe("desktop-managed-local"); | 34 | expect(descriptor.policy).toBe("desktop-managed-local"); |
| 35 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); | 35 | expect(descriptor.bootstrapMethods).toEqual(["desktop-bootstrap"]); |
| 36 | expect(descriptor.sessionCookieName).toBe("t3_session_3773"); | ||
| 36 | }).pipe( | 37 | }).pipe( |
| 37 | Effect.provide( | 38 | Effect.provide( |
| 38 | makeServerAuthPolicyLayer({ | 39 | makeServerAuthPolicyLayer({ |
| 39 | mode: "desktop", | 40 | mode: "desktop", |
| 41 | port: 3773, | ||
| 40 | }), | 42 | }), |
| 41 | ), | 43 | ), |
| 42 | ), | 44 | ), |
| ... | @@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { | ... | @@ -66,6 +68,7 @@ it.layer(NodeServices.layer)("ServerAuthPolicyLive", (it) => { |
| 66 | 68 | ||
| 67 | expect(descriptor.policy).toBe("loopback-browser"); | 69 | expect(descriptor.policy).toBe("loopback-browser"); |
| 68 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); | 70 | expect(descriptor.bootstrapMethods).toEqual(["one-time-token"]); |
| 71 | expect(descriptor.sessionCookieName).toBe("t3_session"); | ||
| 69 | }).pipe( | 72 | }).pipe( |
| 70 | Effect.provide( | 73 | Effect.provide( |
| 71 | makeServerAuthPolicyLayer({ | 74 | makeServerAuthPolicyLayer({ |
apps/server/src/auth/Layers/ServerAuthPolicy.ts+5-2| ... | @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"; | ... | @@ -3,7 +3,7 @@ import { Effect, Layer } from "effect"; |
| 3 | 3 | ||
| 4 | import { ServerConfig } from "../../config.ts"; | 4 | import { ServerConfig } from "../../config.ts"; |
| 5 | import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; | 5 | import { ServerAuthPolicy, type ServerAuthPolicyShape } from "../Services/ServerAuthPolicy.ts"; |
| 6 | import { SESSION_COOKIE_NAME } from "../utils.ts"; | 6 | import { resolveSessionCookieName } from "../utils.ts"; |
| 7 | import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; | 7 | import { isLoopbackHost, isWildcardHost } from "../../startupAccess.ts"; |
| 8 | 8 | ||
| 9 | export const makeServerAuthPolicy = Effect.gen(function* () { | 9 | export const makeServerAuthPolicy = Effect.gen(function* () { |
| ... | @@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () { | ... | @@ -30,7 +30,10 @@ export const makeServerAuthPolicy = Effect.gen(function* () { |
| 30 | policy, | 30 | policy, |
| 31 | bootstrapMethods, | 31 | bootstrapMethods, |
| 32 | sessionMethods: ["browser-session-cookie", "bearer-session-token"], | 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 | return { | 39 | return { |
apps/server/src/auth/Layers/SessionCredentialService.ts+8-2| ... | @@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from " | ... | @@ -2,10 +2,10 @@ import { AuthSessionId, type AuthClientMetadata, type AuthClientSession } from " |
| 2 | import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; | 2 | import { Clock, DateTime, Duration, Effect, Layer, PubSub, Ref, Schema, Stream } from "effect"; |
| 3 | import { Option } from "effect"; | 3 | import { Option } from "effect"; |
| 4 | 4 | ||
| 5 | import { ServerConfig } from "../../config.ts"; | ||
| 5 | import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; | 6 | import { AuthSessionRepositoryLive } from "../../persistence/Layers/AuthSessions.ts"; |
| 6 | import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; | 7 | import { AuthSessionRepository } from "../../persistence/Services/AuthSessions.ts"; |
| 7 | import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; | 8 | import { ServerSecretStore } from "../Services/ServerSecretStore.ts"; |
| 8 | import { SESSION_COOKIE_NAME } from "../utils.ts"; | ||
| 9 | import { | 9 | import { |
| 10 | SessionCredentialError, | 10 | SessionCredentialError, |
| 11 | SessionCredentialService, | 11 | SessionCredentialService, |
| ... | @@ -17,6 +17,7 @@ import { | ... | @@ -17,6 +17,7 @@ import { |
| 17 | import { | 17 | import { |
| 18 | base64UrlDecodeUtf8, | 18 | base64UrlDecodeUtf8, |
| 19 | base64UrlEncode, | 19 | base64UrlEncode, |
| 20 | resolveSessionCookieName, | ||
| 20 | signPayload, | 21 | signPayload, |
| 21 | timingSafeEqualBase64Url, | 22 | timingSafeEqualBase64Url, |
| 22 | } from "../utils.ts"; | 23 | } from "../utils.ts"; |
| ... | @@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli | ... | @@ -81,11 +82,16 @@ function toAuthClientSession(input: Omit<AuthClientSession, "current">): AuthCli |
| 81 | } | 82 | } |
| 82 | 83 | ||
| 83 | export const makeSessionCredentialService = Effect.gen(function* () { | 84 | export const makeSessionCredentialService = Effect.gen(function* () { |
| 85 | const serverConfig = yield* ServerConfig; | ||
| 84 | const secretStore = yield* ServerSecretStore; | 86 | const secretStore = yield* ServerSecretStore; |
| 85 | const authSessions = yield* AuthSessionRepository; | 87 | const authSessions = yield* AuthSessionRepository; |
| 86 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); | 88 | const signingSecret = yield* secretStore.getOrCreateRandom(SIGNING_SECRET_NAME, 32); |
| 87 | const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); | 89 | const connectedSessionsRef = yield* Ref.make(new Map<string, number>()); |
| 88 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); | 90 | const changesPubSub = yield* PubSub.unbounded<SessionCredentialChange>(); |
| 91 | const cookieName = resolveSessionCookieName({ | ||
| 92 | mode: serverConfig.mode, | ||
| 93 | port: serverConfig.port, | ||
| 94 | }); | ||
| 89 | 95 | ||
| 90 | const toSessionCredentialError = (message: string) => (cause: unknown) => | 96 | const toSessionCredentialError = (message: string) => (cause: unknown) => |
| 91 | new SessionCredentialError({ | 97 | new SessionCredentialError({ |
| ... | @@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { | ... | @@ -472,7 +478,7 @@ export const makeSessionCredentialService = Effect.gen(function* () { |
| 472 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); | 478 | }).pipe(Effect.mapError(toSessionCredentialError("Failed to revoke other sessions."))); |
| 473 | 479 | ||
| 474 | return { | 480 | return { |
| 475 | cookieName: SESSION_COOKIE_NAME, | 481 | cookieName, |
| 476 | issue, | 482 | issue, |
| 477 | verify, | 483 | verify, |
| 478 | issueWebSocketToken, | 484 | issueWebSocketToken, |
apps/server/src/auth/utils.ts+12-1| ... | @@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/ | ... | @@ -2,7 +2,18 @@ import type { AuthClientMetadata, AuthClientMetadataDeviceType } from "@t3tools/ |
| 2 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; | 2 | import type * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; |
| 3 | import * as Crypto from "node:crypto"; | 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 | export function base64UrlEncode(input: string | Uint8Array): string { | 18 | export function base64UrlEncode(input: string | Uint8Array): string { |
| 8 | const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); | 19 | const buffer = typeof input === "string" ? Buffer.from(input, "utf8") : Buffer.from(input); |
apps/server/src/server.test.ts+1-1| ... | @@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { | ... | @@ -789,7 +789,7 @@ it.layer(NodeServices.layer)("server router seam", (it) => { |
| 789 | "browser-session-cookie", | 789 | "browser-session-cookie", |
| 790 | "bearer-session-token", | 790 | "bearer-session-token", |
| 791 | ]); | 791 | ]); |
| 792 | assert.equal(body.auth.sessionCookieName, "t3_session"); | 792 | assert.isTrue(body.auth.sessionCookieName.startsWith("t3_session_")); |
| 793 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), | 793 | }).pipe(Effect.provide(NodeHttpServer.layerTest)), |
| 794 | ); | 794 | ); |
| 795 | 795 |
scripts/dev-runner.test.ts+63-10| ... | @@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; | ... | @@ -5,6 +5,7 @@ import { assert, describe, it } from "@effect/vitest"; |
| 5 | import { Effect } from "effect"; | 5 | import { Effect } from "effect"; |
| 6 | 6 | ||
| 7 | import { | 7 | import { |
| 8 | checkPortAvailabilityOnHosts, | ||
| 8 | createDevRunnerEnv, | 9 | createDevRunnerEnv, |
| 9 | findFirstAvailableOffset, | 10 | findFirstAvailableOffset, |
| 10 | resolveModePortOffsets, | 11 | resolveModePortOffsets, |
| ... | @@ -164,11 +165,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -164,11 +165,11 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 164 | const env = yield* createDevRunnerEnv({ | 165 | const env = yield* createDevRunnerEnv({ |
| 165 | mode: "dev:desktop", | 166 | mode: "dev:desktop", |
| 166 | baseEnv: { | 167 | baseEnv: { |
| 167 | T3CODE_PORT: "3773", | 168 | T3CODE_PORT: "13773", |
| 168 | T3CODE_MODE: "web", | 169 | T3CODE_MODE: "web", |
| 169 | T3CODE_NO_BROWSER: "0", | 170 | T3CODE_NO_BROWSER: "0", |
| 170 | T3CODE_HOST: "0.0.0.0", | 171 | T3CODE_HOST: "0.0.0.0", |
| 171 | VITE_WS_URL: "ws://localhost:3773", | 172 | VITE_WS_URL: "ws://localhost:13773", |
| 172 | }, | 173 | }, |
| 173 | serverOffset: 0, | 174 | serverOffset: 0, |
| 174 | webOffset: 0, | 175 | webOffset: 0, |
| ... | @@ -193,6 +194,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -193,6 +194,28 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 193 | assert.equal(env.VITE_WS_URL, "ws://127.0.0.1:4222"); | 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 | describe("findFirstAvailableOffset", () => { | 221 | describe("findFirstAvailableOffset", () => { |
| ... | @@ -211,7 +234,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -211,7 +234,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 211 | 234 | ||
| 212 | it.effect("advances until all required ports are available", () => | 235 | it.effect("advances until all required ports are available", () => |
| 213 | Effect.gen(function* () { | 236 | Effect.gen(function* () { |
| 214 | const taken = new Set([3773, 5733, 3774, 5734]); | 237 | const taken = new Set([13773, 5733, 13774, 5734]); |
| 215 | const offset = yield* findFirstAvailableOffset({ | 238 | const offset = yield* findFirstAvailableOffset({ |
| 216 | startOffset: 0, | 239 | startOffset: 0, |
| 217 | requireServerPort: true, | 240 | requireServerPort: true, |
| ... | @@ -223,16 +246,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -223,16 +246,46 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 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 | Effect.gen(function* () { | 250 | Effect.gen(function* () { |
| 228 | const offset = yield* findFirstAvailableOffset({ | 251 | const offset = yield* findFirstAvailableOffset({ |
| 229 | startOffset: 59_803, | 252 | startOffset: 59_802, |
| 230 | requireServerPort: true, | 253 | requireServerPort: false, |
| 231 | requireWebPort: false, | 254 | requireWebPort: true, |
| 232 | checkPortAvailability: () => Effect.succeed(true), | 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) => { | ... | @@ -240,7 +293,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 240 | describe("resolveModePortOffsets", () => { | 293 | describe("resolveModePortOffsets", () => { |
| 241 | it.effect("uses a shared fallback offset for dev mode", () => | 294 | it.effect("uses a shared fallback offset for dev mode", () => |
| 242 | Effect.gen(function* () { | 295 | Effect.gen(function* () { |
| 243 | const taken = new Set([3773, 5733]); | 296 | const taken = new Set([13773, 5733]); |
| 244 | const offsets = yield* resolveModePortOffsets({ | 297 | const offsets = yield* resolveModePortOffsets({ |
| 245 | mode: "dev", | 298 | mode: "dev", |
| 246 | startOffset: 0, | 299 | startOffset: 0, |
| ... | @@ -270,7 +323,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { | ... | @@ -270,7 +323,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => { |
| 270 | 323 | ||
| 271 | it.effect("shifts only server offset for dev:server", () => | 324 | it.effect("shifts only server offset for dev:server", () => |
| 272 | Effect.gen(function* () { | 325 | Effect.gen(function* () { |
| 273 | const taken = new Set([3773]); | 326 | const taken = new Set([13773]); |
| 274 | const offsets = yield* resolveModePortOffsets({ | 327 | const offsets = yield* resolveModePortOffsets({ |
| 275 | mode: "dev:server", | 328 | mode: "dev:server", |
| 276 | startOffset: 0, | 329 | startOffset: 0, |
scripts/dev-runner.ts+21-2| ... | @@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from | ... | @@ -9,11 +9,12 @@ import { Config, Data, Effect, Hash, Layer, Logger, Option, Path, Schema } from |
| 9 | import { Argument, Command, Flag } from "effect/unstable/cli"; | 9 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 10 | import { ChildProcess } from "effect/unstable/process"; | 10 | import { ChildProcess } from "effect/unstable/process"; |
| 11 | 11 | ||
| 12 | const BASE_SERVER_PORT = 3773; | 12 | const BASE_SERVER_PORT = 13773; |
| 13 | const BASE_WEB_PORT = 5733; | 13 | const BASE_WEB_PORT = 5733; |
| 14 | const MAX_HASH_OFFSET = 3000; | 14 | const MAX_HASH_OFFSET = 3000; |
| 15 | const MAX_PORT = 65535; | 15 | const MAX_PORT = 65535; |
| 16 | const DESKTOP_DEV_LOOPBACK_HOST = "127.0.0.1"; | 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 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => | 19 | export const DEFAULT_T3_HOME = Effect.map(Effect.service(Path.Path), (path) => |
| 19 | path.join(homedir(), ".t3"), | 20 | path.join(homedir(), ".t3"), |
| ... | @@ -221,10 +222,28 @@ function portPairForOffset(offset: number): { | ... | @@ -221,10 +222,28 @@ function portPairForOffset(offset: number): { |
| 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 | const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) => | 241 | const defaultCheckPortAvailability: PortAvailabilityCheck<NetService> = (port) => |
| 225 | Effect.gen(function* () { | 242 | Effect.gen(function* () { |
| 226 | const net = yield* NetService; | 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 | interface FindFirstAvailableOffsetInput<R = NetService> { | 249 | interface FindFirstAvailableOffsetInput<R = NetService> { |