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 @@...@@ -1,29 +1,35 @@
1import { assert, describe, it } from "@effect/vitest";1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5import { afterEach } from "vite-plus/test";3import { afterEach } from "vite-plus/test";
64
7import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts";5import { fetchCloudAuth, validateClerkFrontendApiUrl } from "./cloudAuth.ts";
86
9const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY;7const originalClerkPublishableKey = process.env.T3CODE_CLERK_PUBLISHABLE_KEY;
8const originalFetch = globalThis.fetch;
109
11const clerkPublishableKey = (hostname: string): string =>10const clerkPublishableKey = (hostname: string): string =>
12 `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`;11 `pk_test_${Buffer.from(`${hostname}$`).toString("base64")}`;
1312
14function makeHttpClientLayer(13type FetchCall = readonly [input: RequestInfo | URL, init: RequestInit];
15 handler: (14
16 request: HttpClientRequest.HttpClientRequest,15const recordedFetch = (...responses: ReadonlyArray<Response>) => {
17 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>,16 const calls: Array<FetchCall> = [];
18) {17 let responseIndex = 0;
19 return Layer.succeed(18 const fetchFn = ((input, init) => {
20 HttpClient.HttpClient,19 calls.push([input, init ?? {}]);
21 HttpClient.make((request) => handler(request)),20 const response = responses[responseIndex++];
22 );21 if (!response) {
23}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
25describe("Desktop cloud auth IPC", () => {30describe("Desktop cloud auth IPC", () => {
26 afterEach(() => {31 afterEach(() => {
32 globalThis.fetch = originalFetch;
27 if (originalClerkPublishableKey === undefined) {33 if (originalClerkPublishableKey === undefined) {
28 delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY;34 delete process.env.T3CODE_CLERK_PUBLISHABLE_KEY;
29 } else {35 } else {
...@@ -33,16 +39,8 @@ describe("Desktop cloud auth IPC", () => {...@@ -33,16 +39,8 @@ describe("Desktop cloud auth IPC", () => {
3339
34 it.effect("preserves Clerk's URL-encoded OAuth form content type", () => {40 it.effect("preserves Clerk's URL-encoded OAuth form content type", () => {
35 const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback";41 const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback";
36 let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null;42 const fetch = recordedFetch(Response.json({ response: { object: "sign_in_attempt" } }));
37 const layer = makeHttpClientLayer((request) =>43 globalThis.fetch = fetch.fetchFn;
38 Effect.sync(() => {
39 forwardedRequest = request;
40 return HttpClientResponse.fromWeb(
41 request,
42 Response.json({ response: { object: "sign_in_attempt" } }),
43 );
44 }),
45 );
4644
47 return Effect.gen(function* () {45 return Effect.gen(function* () {
48 yield* fetchCloudAuth.handler({46 yield* fetchCloudAuth.handler({
...@@ -55,32 +53,25 @@ describe("Desktop cloud auth IPC", () => {...@@ -55,32 +53,25 @@ describe("Desktop cloud auth IPC", () => {
55 body,53 body,
56 });54 });
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");
59 assert.equal(61 assert.equal(
60 forwardedRequest.headers["content-type"],62 new Headers(init.headers).get("content-type"),
61 "application/x-www-form-urlencoded;charset=UTF-8",63 "application/x-www-form-urlencoded;charset=UTF-8",
62 );64 );
63 assert.equal(forwardedRequest.body._tag, "Uint8Array");65 assert.equal(new TextDecoder().decode(init.body as Uint8Array), body);
64 if (forwardedRequest.body._tag === "Uint8Array") {66 });
65 assert.equal(new TextDecoder().decode(forwardedRequest.body.body), body);
66 }
67 }).pipe(Effect.provide(layer));
68 });67 });
6968
70 it.effect(69 it.effect(
71 "allows the custom Clerk Frontend API host encoded by the configured publishable key",70 "allows the custom Clerk Frontend API host encoded by the configured publishable key",
72 () => {71 () => {
73 process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes");72 process.env.T3CODE_CLERK_PUBLISHABLE_KEY = clerkPublishableKey("clerk.t3.codes");
74 let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null;73 const fetch = recordedFetch(Response.json({ response: { object: "client" } }));
75 const layer = makeHttpClientLayer((request) =>74 globalThis.fetch = fetch.fetchFn;
76 Effect.sync(() => {
77 forwardedRequest = request;
78 return HttpClientResponse.fromWeb(
79 request,
80 Response.json({ response: { object: "client" } }),
81 );
82 }),
83 );
8475
85 return Effect.gen(function* () {76 return Effect.gen(function* () {
86 yield* fetchCloudAuth.handler({77 yield* fetchCloudAuth.handler({
...@@ -89,9 +80,10 @@ describe("Desktop cloud auth IPC", () => {...@@ -89,9 +80,10 @@ describe("Desktop cloud auth IPC", () => {
89 headers: {},80 headers: {},
90 });81 });
9182
92 assert(forwardedRequest !== null);83 const forwardedRequest = fetch.calls[0];
93 assert.equal(forwardedRequest.url.toString(), "https://clerk.t3.codes/v1/client");84 assert(forwardedRequest !== undefined);
94 }).pipe(Effect.provide(layer));85 assert.equal(String(forwardedRequest[0]), "https://clerk.t3.codes/v1/client");
86 });
95 },87 },
96 );88 );
9789
apps/desktop/src/ipc/methods/cloudAuth.ts+66-43
...@@ -8,9 +8,11 @@ import {...@@ -8,9 +8,11 @@ import {
8} from "@t3tools/shared/relayAuth";8} from "@t3tools/shared/relayAuth";
9import * as Data from "effect/Data";9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";10import * as Effect from "effect/Effect";
11import { identity } from "effect/Function";
12import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";13import * as Option from "effect/Option";
12import * as Schema from "effect/Schema";14import * as Schema from "effect/Schema";
13import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http";15import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/unstable/http";
1416
15import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";17import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";
16import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";18import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";
...@@ -52,6 +54,68 @@ export function validateClerkFrontendApiUrl(rawUrl: string): URL {...@@ -52,6 +54,68 @@ export function validateClerkFrontendApiUrl(rawUrl: string): URL {
52 return url;54 return url;
53}55}
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
55export const createCloudAuthRequest = makeIpcMethod({119export const createCloudAuthRequest = makeIpcMethod({
56 channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL,120 channel: IpcChannels.CREATE_CLOUD_AUTH_REQUEST_CHANNEL,
57 payload: Schema.Void,121 payload: Schema.Void,
...@@ -108,47 +172,6 @@ export const fetchCloudAuth = makeIpcMethod({...@@ -108,47 +172,6 @@ export const fetchCloudAuth = makeIpcMethod({
108 }),172 }),
109 });173 });
110174
111 const requestWithoutBody = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(175 return yield* executeCloudAuthFetch(url, input).pipe(Effect.provide(electronNetFetchLayer));
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 };
153 }),176 }),
154});177});