Skip to content

Commit 45c8899

Browse files
Infer missing client environment URLs from one another
- Allow primary environment config to derive HTTP or WS base URLs when only one is set - Preserve saved environment records during registry hydration - Pass VITE_HTTP_URL through dev runner and Vite define
1 parent 184de87 commit 45c8899

7 files changed

Lines changed: 108 additions & 9 deletions

File tree

apps/web/src/environments/primary/bootstrap.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,32 @@ describe("environmentBootstrap", () => {
110110
expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
111111
});
112112

113+
it("derives the websocket url when only VITE_HTTP_URL is configured", async () => {
114+
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
115+
vi.stubGlobal("fetch", fetchMock);
116+
vi.stubEnv("VITE_HTTP_URL", "https://remote.example.com");
117+
118+
await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
119+
expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
120+
expect(getPrimaryKnownEnvironment()?.target).toEqual({
121+
httpBaseUrl: "https://remote.example.com/",
122+
wsBaseUrl: "wss://remote.example.com/",
123+
});
124+
});
125+
126+
it("derives the http url when only VITE_WS_URL is configured", async () => {
127+
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
128+
vi.stubGlobal("fetch", fetchMock);
129+
vi.stubEnv("VITE_WS_URL", "wss://remote.example.com");
130+
131+
await expect(resolveInitialPrimaryEnvironmentDescriptor()).resolves.toEqual(BASE_ENVIRONMENT);
132+
expect(fetchMock).toHaveBeenCalledWith("https://remote.example.com/.well-known/t3/environment");
133+
expect(getPrimaryKnownEnvironment()?.target).toEqual({
134+
httpBaseUrl: "https://remote.example.com/",
135+
wsBaseUrl: "wss://remote.example.com/",
136+
});
137+
});
138+
113139
it("uses the current origin as the descriptor base for local dev environments", async () => {
114140
const fetchMock = vi.fn<typeof fetch>().mockResolvedValue(jsonResponse(BASE_ENVIRONMENT));
115141
vi.stubGlobal("fetch", fetchMock);

apps/web/src/environments/primary/target.ts

Lines changed: 23 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,15 @@ function normalizeBaseUrl(rawValue: string): string {
1616
return new URL(rawValue, window.location.origin).toString();
1717
}
1818

19+
function swapBaseUrlProtocol(
20+
rawValue: string,
21+
nextProtocol: "http:" | "https:" | "ws:" | "wss:",
22+
): string {
23+
const url = new URL(normalizeBaseUrl(rawValue));
24+
url.protocol = nextProtocol;
25+
return url.toString();
26+
}
27+
1928
function normalizeHostname(hostname: string): string {
2029
return hostname
2130
.trim()
@@ -54,22 +63,29 @@ function resolveHttpRequestBaseUrl(httpBaseUrl: string): string {
5463
}
5564

5665
function resolveConfiguredPrimaryTarget(): PrimaryEnvironmentTarget | null {
57-
const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim();
58-
const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim();
66+
const configuredHttpBaseUrl = import.meta.env.VITE_HTTP_URL?.trim() || undefined;
67+
const configuredWsBaseUrl = import.meta.env.VITE_WS_URL?.trim() || undefined;
5968

6069
if (!configuredHttpBaseUrl && !configuredWsBaseUrl) {
6170
return null;
6271
}
6372

64-
if (!configuredHttpBaseUrl || !configuredWsBaseUrl) {
65-
throw new Error("Configured primary environments require both VITE_HTTP_URL and VITE_WS_URL.");
66-
}
73+
const resolvedHttpBaseUrl =
74+
configuredHttpBaseUrl ??
75+
(configuredWsBaseUrl?.startsWith("wss:")
76+
? swapBaseUrlProtocol(configuredWsBaseUrl, "https:")
77+
: swapBaseUrlProtocol(configuredWsBaseUrl!, "http:"));
78+
const resolvedWsBaseUrl =
79+
configuredWsBaseUrl ??
80+
(configuredHttpBaseUrl?.startsWith("https:")
81+
? swapBaseUrlProtocol(configuredHttpBaseUrl, "wss:")
82+
: swapBaseUrlProtocol(configuredHttpBaseUrl!, "ws:"));
6783

6884
return {
6985
source: "configured",
7086
target: {
71-
httpBaseUrl: normalizeBaseUrl(configuredHttpBaseUrl),
72-
wsBaseUrl: normalizeBaseUrl(configuredWsBaseUrl),
87+
httpBaseUrl: normalizeBaseUrl(resolvedHttpBaseUrl),
88+
wsBaseUrl: normalizeBaseUrl(resolvedWsBaseUrl),
7389
},
7490
};
7591
}

apps/web/src/environments/runtime/catalog.test.ts

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
1-
import { EnvironmentId, type LocalApi } from "@t3tools/contracts";
1+
import { EnvironmentId, type LocalApi, type PersistedSavedEnvironmentRecord } from "@t3tools/contracts";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33

44
import {
55
resetSavedEnvironmentRegistryStoreForTests,
66
resetSavedEnvironmentRuntimeStoreForTests,
77
useSavedEnvironmentRegistryStore,
88
useSavedEnvironmentRuntimeStore,
9+
waitForSavedEnvironmentRegistryHydration,
910
} from "./catalog";
1011

1112
describe("environment runtime catalog stores", () => {
@@ -88,4 +89,49 @@ describe("environment runtime catalog stores", () => {
8889

8990
expect(errorSpy).toHaveBeenCalledWith("[SAVED_ENVIRONMENTS] persist failed", expect.any(Error));
9091
});
92+
93+
it("does not let stale hydration overwrite records added while hydration is in flight", async () => {
94+
let resolveRegistryRead: () => void = () => {
95+
throw new Error("Registry read resolver was not initialized.");
96+
};
97+
98+
vi.stubGlobal("window", {
99+
nativeApi: {
100+
persistence: {
101+
getClientSettings: async () => null,
102+
setClientSettings: async () => undefined,
103+
getSavedEnvironmentRegistry: () =>
104+
new Promise<readonly PersistedSavedEnvironmentRecord[]>((resolve) => {
105+
resolveRegistryRead = () => resolve([]);
106+
}),
107+
setSavedEnvironmentRegistry: async () => undefined,
108+
getSavedEnvironmentSecret: async () => null,
109+
setSavedEnvironmentSecret: async () => true,
110+
removeSavedEnvironmentSecret: async () => undefined,
111+
},
112+
} satisfies Pick<LocalApi, "persistence">,
113+
});
114+
115+
const { __resetLocalApiForTests } = await import("../../localApi");
116+
await __resetLocalApiForTests();
117+
118+
const hydrationPromise = waitForSavedEnvironmentRegistryHydration();
119+
120+
const environmentId = EnvironmentId.makeUnsafe("environment-1");
121+
const record = {
122+
environmentId,
123+
label: "Remote environment",
124+
httpBaseUrl: "https://remote.example.com/",
125+
wsBaseUrl: "wss://remote.example.com/",
126+
createdAt: "2026-04-09T00:00:00.000Z",
127+
lastConnectedAt: null,
128+
} as const;
129+
130+
useSavedEnvironmentRegistryStore.getState().upsert(record);
131+
132+
resolveRegistryRead();
133+
await hydrationPromise;
134+
135+
expect(useSavedEnvironmentRegistryStore.getState().byId[environmentId]).toEqual(record);
136+
});
91137
});

apps/web/src/environments/runtime/catalog.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,8 +74,13 @@ function persistSavedEnvironmentRegistryState(
7474
function replaceSavedEnvironmentRegistryState(
7575
records: ReadonlyArray<SavedEnvironmentRecord>,
7676
): void {
77+
const currentById = useSavedEnvironmentRegistryStore.getState().byId;
78+
const hydratedById = Object.fromEntries(records.map((record) => [record.environmentId, record]));
7779
useSavedEnvironmentRegistryStore.setState({
78-
byId: Object.fromEntries(records.map((record) => [record.environmentId, record])),
80+
byId: {
81+
...hydratedById,
82+
...currentById,
83+
},
7984
});
8085
}
8186

apps/web/vite.config.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import pkg from "./package.json" with { type: "json" };
77

88
const port = Number(process.env.PORT ?? 5733);
99
const host = process.env.HOST?.trim() || "localhost";
10+
const configuredHttpUrl = process.env.VITE_HTTP_URL?.trim();
1011
const configuredWsUrl = process.env.VITE_WS_URL?.trim();
1112
const sourcemapEnv = process.env.T3CODE_WEB_SOURCEMAP?.trim().toLowerCase();
1213

@@ -58,6 +59,7 @@ export default defineConfig({
5859
include: ["@pierre/diffs", "@pierre/diffs/react", "@pierre/diffs/worker/worker.js"],
5960
},
6061
define: {
62+
"import.meta.env.VITE_HTTP_URL": JSON.stringify(configuredHttpUrl ?? ""),
6163
// In dev mode, tell the web app where the WebSocket server lives
6264
"import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
6365
"import.meta.env.APP_VERSION": JSON.stringify(pkg.version),

scripts/dev-runner.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
8484

8585
assert.equal(env.T3CODE_HOME, resolve("/tmp/custom-t3"));
8686
assert.equal(env.T3CODE_PORT, "4222");
87+
assert.equal(env.VITE_HTTP_URL, "http://localhost:4222");
8788
assert.equal(env.VITE_WS_URL, "ws://localhost:4222");
8889
assert.equal(env.T3CODE_NO_BROWSER, "1");
8990
assert.equal(env.T3CODE_AUTO_BOOTSTRAP_PROJECT_FROM_CWD, "0");
@@ -183,6 +184,7 @@ it.layer(NodeServices.layer)("dev-runner", (it) => {
183184
assert.equal(env.VITE_DEV_SERVER_URL, "http://127.0.0.1:5733");
184185
assert.equal(env.HOST, "127.0.0.1");
185186
assert.equal(env.T3CODE_PORT, "4222");
187+
assert.equal(env.VITE_HTTP_URL, "http://127.0.0.1:4222");
186188
assert.equal(env.T3CODE_MODE, undefined);
187189
assert.equal(env.T3CODE_NO_BROWSER, undefined);
188190
assert.equal(env.T3CODE_HOST, undefined);

scripts/dev-runner.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,9 +159,11 @@ export function createDevRunnerEnv({
159159

160160
if (!isDesktopMode) {
161161
output.T3CODE_PORT = String(serverPort);
162+
output.VITE_HTTP_URL = `http://localhost:${serverPort}`;
162163
output.VITE_WS_URL = `ws://localhost:${serverPort}`;
163164
} else {
164165
output.T3CODE_PORT = String(serverPort);
166+
output.VITE_HTTP_URL = `http://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
165167
output.VITE_WS_URL = `ws://${DESKTOP_DEV_LOOPBACK_HOST}:${serverPort}`;
166168
delete output.T3CODE_MODE;
167169
delete output.T3CODE_NO_BROWSER;

0 commit comments

Comments
 (0)