Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 43 additions & 1 deletion apps/desktop/src/electron/ElectronProtocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,16 @@ describe("ElectronProtocol", () => {
assert.isDefined(handler);

const response = yield* Effect.promise(() =>
handler!(new Request("t3code-dev://app/api/health?verbose=1")),
handler!(
new Request("t3code-dev://app/api/health?verbose=1", {
headers: {
accept: "application/json",
origin: "t3code-dev://app",
referer: "t3code-dev://app/",
"sec-fetch-site": "same-origin",
},
}),
),
);
assert.equal(yield* Effect.promise(() => response.text()), "ok");
assert.include(
Expand All @@ -70,6 +79,11 @@ describe("ElectronProtocol", () => {
["t3code-dev"],
);
assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1");
const forwardedHeaders = new Headers(netFetchMock.mock.calls[0]?.[1]?.headers);
assert.equal(forwardedHeaders.get("accept"), "application/json");
assert.isNull(forwardedHeaders.get("origin"));
assert.isNull(forwardedHeaders.get("referer"));
assert.isNull(forwardedHeaders.get("sec-fetch-site"));
assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]);
}).pipe(Effect.provide(ElectronProtocol.layer)),
);
Expand Down Expand Up @@ -99,6 +113,34 @@ describe("ElectronProtocol", () => {
}).pipe(Effect.provide(ElectronProtocol.layer)),
);

it.effect("retries transient renderer target failures", () =>
Effect.gen(function* () {
let handler: ((request: Request) => Promise<Response>) | undefined;
handleMock.mockImplementation((_scheme, nextHandler) => {
handler = nextHandler;
});
netFetchMock
.mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733"))
.mockResolvedValueOnce(new Response("ready"));

const response = yield* Effect.scoped(
Effect.gen(function* () {
const protocol = yield* ElectronProtocol.ElectronProtocol;
yield* protocol.registerDesktopProtocol({
scheme: "t3code-dev",
targetOrigin: new URL("http://127.0.0.1:5733/"),
backendOrigin: new URL("http://127.0.0.1:3773/"),
clerkFrontendApiHostname: undefined,
});
return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/")));
}),
);

assert.equal(yield* Effect.promise(() => response.text()), "ready");
assert.equal(netFetchMock.mock.calls.length, 2);
}).pipe(Effect.provide(ElectronProtocol.layer)),
);

it.effect("preserves protocol registration failures", () =>
Effect.gen(function* () {
const cause = new Error("protocol registration failed");
Expand Down
47 changes: 45 additions & 2 deletions apps/desktop/src/electron/ElectronProtocol.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";
import * as Layer from "effect/Layer";
import * as NodeTimersPromises from "node:timers/promises";
import * as Ref from "effect/Ref";
import * as Schema from "effect/Schema";
import * as Scope from "effect/Scope";
Expand Down Expand Up @@ -114,18 +115,60 @@ async function proxyRequest(
}

const targetUrl = new URL(`${requestUrl.pathname}${requestUrl.search}`, targetOrigin);
const headers = new Headers(request.headers);
const headersToRemove: string[] = [];
for (const name of headers.keys()) {
if (
name === "host" ||
name === "origin" ||
name === "referer" ||
name === "connection" ||
name === "content-length" ||
name === "accept-encoding" ||
name === "upgrade-insecure-requests" ||
name.startsWith("sec-fetch-")
) {
headersToRemove.push(name);
}
}
for (const name of headersToRemove) {
headers.delete(name);
}
const init: RequestInit = {
method: request.method,
headers: request.headers,
headers,
};
if (request.method !== "GET" && request.method !== "HEAD") {
init.body = request.body;
(init as RequestInit & { duplex: "half" }).duplex = "half";
}
const response = await Electron.net.fetch(targetUrl.toString(), init);
const response =
request.method === "GET" || request.method === "HEAD"
? await fetchWithTransientRetry(targetUrl.toString(), init)
: await Electron.net.fetch(targetUrl.toString(), init);
return withContentSecurityPolicy(response, contentSecurityPolicy);
}

const TRANSIENT_FETCH_RETRY_DELAYS_MS = [0, 50, 150] as const;

async function fetchWithTransientRetry(url: string, init: RequestInit): Promise<Response> {
let lastError: unknown;

for (const delayMs of TRANSIENT_FETCH_RETRY_DELAYS_MS) {
if (delayMs > 0) {
await NodeTimersPromises.setTimeout(delayMs);
}

try {
return await Electron.net.fetch(url, init);
} catch (error) {
lastError = error;
}
}

throw lastError;
}

export const make = Effect.gen(function* () {
const registered = yield* Ref.make(false);

Expand Down
2 changes: 1 addition & 1 deletion apps/marketing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"typecheck": "astro check"
},
"dependencies": {
"astro": "^6.0.4"
"astro": "^7.0.3"
},
"devDependencies": {
"@astrojs/check": "^0.9.7",
Expand Down
7 changes: 6 additions & 1 deletion apps/server/src/http.ts
Comment thread
juliusmarminge marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,18 @@ import { browserApiCorsAllowedHeaders, browserApiCorsAllowedMethods } from "./ht

const OTLP_TRACES_PROXY_PATH = "/api/observability/v1/traces";
const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]);
const DESKTOP_RENDERER_ORIGINS = ["t3code://app", "t3code-dev://app"];

export const browserApiCorsLayer = Layer.unwrap(
Effect.gen(function* () {
const config = yield* ServerConfig.ServerConfig;
const devOrigin = config.devUrl?.origin;
// Dev uses credentialed requests from Vite or the Electron custom origin, so both must be
// explicit. Packaged desktop omits credentials and uses Effect's default wildcard origin.
return HttpRouter.cors({
...(devOrigin ? { allowedOrigins: [devOrigin], credentials: true } : {}),
...(devOrigin
? { allowedOrigins: [devOrigin, ...DESKTOP_RENDERER_ORIGINS], credentials: true }
: {}),
allowedMethods: browserApiCorsAllowedMethods,
allowedHeaders: browserApiCorsAllowedHeaders,
maxAge: 600,
Expand Down
26 changes: 26 additions & 0 deletions apps/server/src/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3233,6 +3233,32 @@ it.layer(NodeServices.layer)("server router seam", (it) => {
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);

for (const desktopOrigin of ["t3code://app", "t3code-dev://app"]) {
it.effect(`allows credentialed preflights from ${desktopOrigin} in development`, () =>
Effect.gen(function* () {
yield* buildAppUnderTest({
config: { devUrl: new URL(crossOriginClientOrigin) },
});

const sessionUrl = yield* getHttpServerUrl("/api/auth/session");
const response = yield* fetchEffect(sessionUrl, {
method: "OPTIONS",
headers: {
origin: desktopOrigin,
"access-control-request-method": "GET",
"access-control-request-headers": "content-type",
},
});

assert.equal(response.status, 204);
assertBrowserApiCorsPreflightHeaders(response.headers, {
origin: desktopOrigin,
credentials: true,
});
}).pipe(Effect.provide(NodeHttpServer.layerTest)),
);
}

it.effect("includes CORS headers on remote websocket-ticket auth failures", () =>
Effect.gen(function* () {
yield* buildAppUnderTest();
Expand Down
1 change: 1 addition & 0 deletions apps/web/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ export default defineConfig(() => {
// connection logs — enable "Verbose" in DevTools to see them.
protocol: "ws",
host,
clientPort: port,
},
},
build: {
Expand Down
Loading
Loading