authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-06-05 11:08:34-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-06-05 11:08:34-07:00
log602148f8fe4b5fe369c253022af6bedc27cd553a
treeae108437a8dc2b3753562ea4ccd7d1f24d93a0f2
parent92be60e7c1bae7bdf74482116e09be4cefed1e56
signature Signed by PGP key B5690EEEBB952194

fix(cloud): use Electron fetch for proxying Clerk IPC requests (#2973)


2 files changed, 100 insertions(+), 85 deletions(-)

apps/desktop/src/ipc/methods/cloudAuth.test.ts+34-42
......@@ -1,29 +1,35 @@
11import { assert, describe, it } from "@effect/vitest";
22import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
53import { afterEach } from "vite-plus/test";
64
75import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts";
86
97const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY;
8const originalFetch = globalThis.fetch;
109
1110const clerkPublishableKey = (hostname: string): string =>
1211 `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`;
1312
14function makeHttpClientLayer(
15 handler: (
16 request: HttpClientRequest.HttpClientRequest,
17 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>,
18) {
19 return Layer.succeed(
20 HttpClient.HttpClient,
21 HttpClient.make((request) => handler(request)),
22 );
23}
13type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];
14
15const recordedFetch = (...responses: ReadonlyArray<Response>) => {
16 const calls: Array<FetchCall> = [];
17 let responseIndex = 0;
18 const fetchFn = ((input, init) => {
19 calls.push([input, init ?? {}]);
20 const response = responses[responseIndex++];
21 if (!response) {
22 return Promise.reject(new Error("Unexpected fetch call"));
23 }
24 return Promise.resolve(response);
25 }) satisfies typeof fetch;
26
27 return { fetchFn, calls };
28};
2429
2530describe("Desktop cloud auth IPC", () => {
2631 afterEach(() => {
32 globalThis.fetch = originalFetch;
2733 if (originalClerkPublishableKey === undefined) {
2834 delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY;
2935 } else {
......@@ -33,16 +39,8 @@ describe("Desktop cloud auth IPC", () => {
3339
3440 it.effect("preserves Clerk's URL-encoded OAuth form content type", () => {
3541 const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback";
36 let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null;
37 const layer = makeHttpClientLayer((request) =>
38 Effect.sync(() => {
39 forwardedRequest = request;
40 return HttpClientResponse.fromWeb(
41 request,
42 Response.json({ response: { object: "sign_in_attempt" } }),
43 );
44 }),
45 );
42 const fetch = recordedFetch(Response.json({ response: { object: "sign_in_attempt" } }));
43 globalThis.fetch = fetch.fetchFn;
4644
4745 return Effect.gen(function* () {
4846 yield* fetchCloudAuth.handler({
......@@ -55,32 +53,25 @@ describe("Desktop cloud auth IPC", () => {
5553 body,
5654 });
5755
58 assert(forwardedRequest !== null);
56 const forwardedRequest = fetch.calls[0];
57 assert(forwardedRequest !== undefined);
58 const [url, init] = forwardedRequest;
59 assert.equal(String(url), "https://example.clerk.accounts.dev/v1/client/sign_ins");
60 assert.equal(init.method, "POST");
5961 assert.equal(
60 forwardedRequest.headers["content-type"],
62 new Headers(init.headers).get("content-type"),
6163 "application/x-www-form-urlencoded;charset=UTF-8",
6264 );
63 assert.equal(forwardedRequest.body._tag, "Uint8Array");
64 if (forwardedRequest.body._tag === "Uint8Array") {
65 assert.equal(new TextDecoder().decode(forwardedRequest.body.body), body);
66 }
67 }).pipe(Effect.provide(layer));
65 assert.equal(new TextDecoder().decode(init.body as Uint8Array), body);
66 });
6867 });
6968
7069 it.effect(
7170 "allows the custom Clerk Frontend API host encoded by the configured publishable key",
7271 () => {
7372 process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes");
74 let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null;
75 const layer = makeHttpClientLayer((request) =>
76 Effect.sync(() => {
77 forwardedRequest = request;
78 return HttpClientResponse.fromWeb(
79 request,
80 Response.json({ response: { object: "client" } }),
81 );
82 }),
83 );
73 const fetch = recordedFetch(Response.json({ response: { object: "client" } }));
74 globalThis.fetch = fetch.fetchFn;
8475
8576 return Effect.gen(function* () {
8677 yield* fetchCloudAuth.handler({
......@@ -89,9 +80,10 @@ describe("Desktop cloud auth IPC", () => {
8980 headers: {},
9081 });
9182
92 assert(forwardedRequest !== null);
93 assert.equal(forwardedRequest.url.toString(), "https://clerk.t3.codes/v1/client");
94 }).pipe(Effect.provide(layer));
83 const forwardedRequest = fetch.calls[0];
84 assert(forwardedRequest !== undefined);
85 assert.equal(String(forwardedRequest[0]), "https://clerk.t3.codes/v1/client");
86 });
9587 },
9688 );
9789
apps/desktop/src/ipc/methods/cloudAuth.ts+66-43
......@@ -8,9 +8,11 @@ import {
88} from "@t3tools/shared/relayAuth";
99import * as Data from "effect/Data";
1010import * as Effect from "effect/Effect";
11import { identity } from "effect/Function";
12import * as Layer from "effect/Layer";
1113import * as Option from "effect/Option";
1214import * as Schema from "effect/Schema";
13import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http";
15import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
1416
1517import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";
1618import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";
......@@ -52,6 +54,68 @@ export function validateClerkFrontendApiUrl(rawUrl: string): URL {
5254 return url;
5355}
5456
57function executeCloudAuthFetch(url: URL, input: typeof DesktopCloudAuthFetchInputSchema.Type) {
58 return Effect.gen(function* () {
59 const method = (input.method ?? "GET") as "GET" | "POST";
60 const headers = new Headers(input.headers);
61 const response = yield* HttpClientRequest.make(method)(url).pipe(
62 HttpClientRequest.setHeaders(headers),
63 input.body === undefined
64 ? identity
65 : HttpClientRequest.bodyText(input.body, headers.get("content-type") ?? undefined),
66 HttpClient.execute,
67 Effect.mapError(
68 (cause) =>
69 new DesktopCloudAuthFetchError({
70 reason: "Desktop cloud auth fetch failed to execute.",
71 cause,
72 }),
73 ),
74 );
75
76 const body = yield* response.text.pipe(
77 Effect.mapError(
78 (cause) =>
79 new DesktopCloudAuthFetchError({
80 reason: "Desktop cloud auth fetch response could not be read.",
81 cause,
82 }),
83 ),
84 );
85
86 return {
87 ok: response.status >= 200 && response.status < 300,
88 status: response.status,
89 statusText: "",
90 headers: response.headers,
91 body,
92 };
93 });
94}
95
96const electronNetFetchLayer = Layer.unwrap(
97 Effect.gen(function* () {
98 const electronFetch = yield* Effect.promise(async () => {
99 const electron = (await import("electron")) as {
100 readonly net?: { readonly fetch?: typeof globalThis.fetch };
101 };
102 return typeof electron.net?.fetch === "function"
103 ? electron.net.fetch.bind(electron.net)
104 : null;
105 }).pipe(Effect.catchCause(() => Effect.succeed(null)));
106
107 if (!electronFetch) {
108 yield* Effect.logWarning(
109 "electron.net.fetch is not available, falling back to global fetch. This may cause unexpected errors.",
110 );
111 }
112
113 return FetchHttpClient.layer.pipe(
114 Layer.provide(Layer.succeed(FetchHttpClient.Fetch, electronFetch ?? globalThis.fetch)),
115 );
116 }),
117);
118
55119export const createCloudAuthRequest = makeIpcMethod({
56120 channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL,
57121 payload: Schema.Void,
......@@ -108,47 +172,6 @@ export const fetchCloudAuth = makeIpcMethod({
108172 }),
109173 });
110174
111 const requestWithoutBody = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(
112 url,
113 {
114 headers: input.headers,
115 },
116 );
117 const request =
118 input.body === undefined
119 ? requestWithoutBody
120 : HttpClientRequest.bodyText(
121 requestWithoutBody,
122 input.body,
123 Option.getOrUndefined(Headers.get(requestWithoutBody.headers, "content-type")),
124 );
125
126 const response = yield* HttpClient.execute(request).pipe(
127 Effect.mapError(
128 (cause) =>
129 new DesktopCloudAuthFetchError({
130 reason: "Desktop cloud auth fetch failed.",
131 cause,
132 }),
133 ),
134 );
135
136 const body = yield* response.text.pipe(
137 Effect.mapError(
138 (cause) =>
139 new DesktopCloudAuthFetchError({
140 reason: "Desktop cloud auth fetch response could not be read.",
141 cause,
142 }),
143 ),
144 );
145
146 return {
147 ok: response.status >= 200 && response.status < 300,
148 status: response.status,
149 statusText: "",
150 headers: response.headers,
151 body,
152 };
175 return yield* executeCloudAuthFetch(url, input).pipe(Effect.provide(electronNetFetchLayer));
153176 }),
154177});