1import { assert, describe, it } from "@effect/vitest";
2import * as Cause from "effect/Cause";
3import * as Effect from "effect/Effect";
4import * as Fiber from "effect/Fiber";
5import * as NodeServices from "@effect/platform-node/NodeServices";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as TestClock from "effect/testing/TestClock";
9import { beforeEach, vi } from "vite-plus/test";
10
11const { handleMock, netFetchMock, unhandleMock } = vi.hoisted(() => ({
12 handleMock: vi.fn(),
13 netFetchMock: vi.fn(),
14 unhandleMock: vi.fn(),
15}));
16
17vi.mock("electron", () => ({
18 net: { fetch: netFetchMock },
19 protocol: { handle: handleMock, unhandle: unhandleMock },
20}));
21
22import * as ElectronProtocol from "./ElectronProtocol.ts";
23
24const layerProtocol = ElectronProtocol.layer.pipe(Layer.provide(NodeServices.layer));
25
26describe("ElectronProtocol", () => {
27 beforeEach(() => {
28 handleMock.mockReset();
29 netFetchMock.mockReset();
30 unhandleMock.mockReset();
31 });
32
33 it.effect("serves the bundled client from disk without a backend", () =>
34 Effect.gen(function* () {
35 const fileSystem = yield* FileSystem.FileSystem;
36 const directory = yield* fileSystem.makeTempDirectoryScoped();
37 yield* fileSystem.writeFileString(`${directory}/index.html`, "<html>app</html>");
38 yield* fileSystem.writeFileString(`${directory}/app.js`, "export default 1;");
39 let handler: ((request: Request) => Promise<Response>) | undefined;
40 handleMock.mockImplementation((_scheme, nextHandler) => {
41 handler = nextHandler;
42 });
43 const protocol = yield* ElectronProtocol.ElectronProtocol;
44 yield* protocol.registerDesktopProtocol({
45 scheme: "t3code",
46 assetDirectories: Effect.succeed([directory]),
47 clerkFrontendApiHostname: undefined,
48 });
49 const request = (pathname: string, init?: RequestInit) =>
50 Effect.promise(() => handler!(new Request(`t3code://app${pathname}`, init)));
51
52 // SPA routes fall back to index.html, including ones containing dots.
53 const page = yield* request("/settings/connections");
54 assert.equal(yield* Effect.promise(() => page.text()), "<html>app</html>");
55 assert.include(page.headers.get("content-security-policy") ?? "", "default-src 'self'");
56 const dottedRoute = yield* request("/environment/thread.with.dots", {
57 headers: { accept: "text/html" },
58 });
59 assert.equal(yield* Effect.promise(() => dottedRoute.text()), "<html>app</html>");
60
61 const script = yield* request("/app.js?v=1");
62 assert.equal(yield* Effect.promise(() => script.text()), "export default 1;");
63 assert.include(script.headers.get("content-type") ?? "", "javascript");
64
65 assert.equal((yield* request("/missing.js")).status, 404);
66 assert.equal((yield* request("/%2e%2e%2fsecret.txt")).status, 404);
67 assert.equal((yield* request("/%invalid")).status, 400);
68 assert.equal((yield* request("/", { method: "POST" })).status, 405);
69 assert.equal(netFetchMock.mock.calls.length, 0);
70 }).pipe(Effect.provide(Layer.merge(layerProtocol, NodeServices.layer)), Effect.scoped),
71 );
72
73 it.effect("proxies the stable renderer origin to the current app server", () =>
74 Effect.gen(function* () {
75 let handler: ((request: Request) => Promise<Response>) | undefined;
76 handleMock.mockImplementation((_scheme, nextHandler) => {
77 handler = nextHandler;
78 });
79 netFetchMock.mockResolvedValue(new Response("ok"));
80
81 yield* Effect.scoped(
82 Effect.gen(function* () {
83 const protocol = yield* ElectronProtocol.ElectronProtocol;
84 yield* protocol.registerDesktopProtocol({
85 scheme: "t3code-dev",
86 targetOrigin: new URL("http://127.0.0.1:3773/"),
87 clerkFrontendApiHostname: "clerk.t3.codes",
88 });
89 assert.isDefined(handler);
90
91 const response = yield* Effect.promise(() =>
92 handler!(
93 new Request("t3code-dev://app/api/health?verbose=1", {
94 headers: {
95 accept: "application/json",
96 origin: "t3code-dev://app",
97 referer: "t3code-dev://app/",
98 "sec-fetch-site": "same-origin",
99 },
100 }),
101 ),
102 );
103 assert.equal(yield* Effect.promise(() => response.text()), "ok");
104 assert.include(
105 response.headers.get("content-security-policy") ?? "",
106 "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' https://clerk.t3.codes https://challenges.cloudflare.com",
107 );
108 assert.include(
109 response.headers.get("content-security-policy") ?? "",
110 "connect-src 'self' blob: http: https: ws: wss:",
111 );
112 assert.include(
113 response.headers.get("content-security-policy") ?? "",
114 "img-src 'self' t3code-dev: blob: data: http: https:",
115 );
116 assert.include(
117 response.headers.get("content-security-policy") ?? "",
118 "font-src 'self' t3code-dev: data:",
119 );
120 }),
121 );
122
123 assert.deepEqual(
124 handleMock.mock.calls.map((call) => call[0]),
125 ["t3code-dev"],
126 );
127 assert.equal(netFetchMock.mock.calls[0]?.[0], "http://127.0.0.1:3773/api/health?verbose=1");
128 const forwardedHeaders = new Headers(netFetchMock.mock.calls[0]?.[1]?.headers);
129 assert.equal(forwardedHeaders.get("accept"), "application/json");
130 assert.isNull(forwardedHeaders.get("origin"));
131 assert.isNull(forwardedHeaders.get("referer"));
132 assert.isNull(forwardedHeaders.get("sec-fetch-site"));
133 assert.deepEqual(unhandleMock.mock.calls, [["t3code-dev"]]);
134 }).pipe(Effect.provide(layerProtocol)),
135 );
136
137 it.effect("rejects custom protocol requests for another host", () =>
138 Effect.gen(function* () {
139 let handler: ((request: Request) => Promise<Response>) | undefined;
140 handleMock.mockImplementation((_scheme, nextHandler) => {
141 handler = nextHandler;
142 });
143
144 const response = yield* Effect.scoped(
145 Effect.gen(function* () {
146 const protocol = yield* ElectronProtocol.ElectronProtocol;
147 yield* protocol.registerDesktopProtocol({
148 scheme: "t3code",
149 targetOrigin: new URL("http://127.0.0.1:3773/"),
150 clerkFrontendApiHostname: undefined,
151 });
152 return yield* Effect.promise(() => handler!(new Request("t3code://other/")));
153 }),
154 );
155
156 assert.equal(response.status, 404);
157 assert.equal(netFetchMock.mock.calls.length, 0);
158 }).pipe(Effect.provide(layerProtocol)),
159 );
160
161 it.effect("retries transient renderer target failures", () =>
162 Effect.gen(function* () {
163 let handler: ((request: Request) => Promise<Response>) | undefined;
164 handleMock.mockImplementation((_scheme, nextHandler) => {
165 handler = nextHandler;
166 });
167 netFetchMock
168 .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733"))
169 .mockResolvedValueOnce(new Response("ready"));
170
171 const response = yield* Effect.scoped(
172 Effect.gen(function* () {
173 const protocol = yield* ElectronProtocol.ElectronProtocol;
174 yield* protocol.registerDesktopProtocol({
175 scheme: "t3code-dev",
176 targetOrigin: new URL("http://127.0.0.1:5733/"),
177 clerkFrontendApiHostname: undefined,
178 });
179 const fiber = yield* Effect.forkChild(
180 Effect.promise(() => handler!(new Request("t3code-dev://app/"))),
181 );
182 yield* TestClock.adjust("50 millis");
183 return yield* Fiber.join(fiber);
184 }),
185 );
186
187 assert.equal(yield* Effect.promise(() => response.text()), "ready");
188 assert.equal(netFetchMock.mock.calls.length, 2);
189 }).pipe(Effect.provide(layerProtocol)),
190 );
191
192 it.effect("rejects with the last renderer target failure after 50ms and 150ms retries", () =>
193 Effect.gen(function* () {
194 let handler: ((request: Request) => Promise<Response>) | undefined;
195 handleMock.mockImplementation((_scheme, nextHandler) => {
196 handler = nextHandler;
197 });
198 const lastFailure = new Error("connect ECONNREFUSED 127.0.0.1:5733 (3)");
199 netFetchMock
200 .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (1)"))
201 .mockRejectedValueOnce(new Error("connect ECONNREFUSED 127.0.0.1:5733 (2)"))
202 .mockRejectedValueOnce(lastFailure);
203
204 const rejection = yield* Effect.scoped(
205 Effect.gen(function* () {
206 const protocol = yield* ElectronProtocol.ElectronProtocol;
207 yield* protocol.registerDesktopProtocol({
208 scheme: "t3code-dev",
209 targetOrigin: new URL("http://127.0.0.1:5733/"),
210 clerkFrontendApiHostname: undefined,
211 });
212 const fiber = yield* Effect.forkChild(
213 Effect.promise(() =>
214 handler!(new Request("t3code-dev://app/")).then(
215 () => null,
216 (error: unknown) => error,
217 ),
218 ),
219 );
220 yield* TestClock.adjust("49 millis");
221 assert.equal(netFetchMock.mock.calls.length, 1);
222 yield* TestClock.adjust("1 millis");
223 assert.equal(netFetchMock.mock.calls.length, 2);
224 yield* TestClock.adjust("149 millis");
225 assert.equal(netFetchMock.mock.calls.length, 2);
226 yield* TestClock.adjust("1 millis");
227 return yield* Fiber.join(fiber);
228 }),
229 );
230
231 assert.strictEqual(rejection, lastFailure);
232 assert.equal(netFetchMock.mock.calls.length, 3);
233 }).pipe(Effect.provide(layerProtocol)),
234 );
235
236 it.effect("preserves protocol registration failures", () =>
237 Effect.gen(function* () {
238 const cause = new Error("protocol registration failed");
239 handleMock.mockImplementationOnce(() => {
240 throw cause;
241 });
242
243 const protocol = yield* ElectronProtocol.ElectronProtocol;
244 const error = yield* Effect.scoped(
245 protocol.registerDesktopProtocol({
246 scheme: "t3code-dev",
247 targetOrigin: new URL("http://127.0.0.1:3773/"),
248 clerkFrontendApiHostname: undefined,
249 }),
250 ).pipe(Effect.flip);
251
252 assert.instanceOf(error, ElectronProtocol.ElectronProtocolRegistrationError);
253 assert.equal(error.scheme, "t3code-dev");
254 assert.strictEqual(error.cause, cause);
255 assert.equal(error.message, 'Failed to register Electron protocol scheme "t3code-dev".');
256 }).pipe(Effect.provide(layerProtocol)),
257 );
258
259 it.effect("preserves protocol unregistration failures", () =>
260 Effect.gen(function* () {
261 const cause = new Error("protocol unregistration failed");
262 unhandleMock.mockImplementationOnce(() => {
263 throw cause;
264 });
265
266 const protocol = yield* ElectronProtocol.ElectronProtocol;
267 const exit = yield* Effect.exit(
268 Effect.scoped(
269 protocol.registerDesktopProtocol({
270 scheme: "t3code",
271 targetOrigin: new URL("http://127.0.0.1:3773/"),
272 clerkFrontendApiHostname: undefined,
273 }),
274 ),
275 );
276
277 assert.equal(exit._tag, "Failure");
278 if (exit._tag === "Failure") {
279 const error = Cause.squash(exit.cause);
280 assert.instanceOf(error, ElectronProtocol.ElectronProtocolUnregistrationError);
281 assert.equal(error.scheme, "t3code");
282 assert.strictEqual(error.cause, cause);
283 assert.equal(error.message, 'Failed to unregister Electron protocol scheme "t3code".');
284 }
285 }).pipe(Effect.provide(layerProtocol)),
286 );
287
288 it("keeps executable sources host-restricted while allowing runtime network resources", () => {
289 const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({
290 scheme: "t3code",
291 targetOrigin: new URL("http://127.0.0.1:3773/"),
292 clerkFrontendApiHostname: "clerk.t3.codes",
293 });
294 const directives = Object.fromEntries(
295 policy.split("; ").map((directive) => {
296 const [name, ...sources] = directive.split(" ");
297 return [name, sources];
298 }),
299 );
300
301 assert.deepEqual(directives["script-src"], [
302 "'self'",
303 "'unsafe-inline'",
304 "'wasm-unsafe-eval'",
305 "https://clerk.t3.codes",
306 "https://challenges.cloudflare.com",
307 ]);
308 assert.deepEqual(directives["connect-src"], [
309 "'self'",
310 "blob:",
311 "http:",
312 "https:",
313 "ws:",
314 "wss:",
315 ]);
316 assert.deepEqual(directives["img-src"], [
317 "'self'",
318 "t3code:",
319 "blob:",
320 "data:",
321 "http:",
322 "https:",
323 ]);
324 assert.deepEqual(directives["media-src"], ["'self'", "t3code:", "blob:", "http:", "https:"]);
325 assert.deepEqual(directives["frame-src"], ["'self'", "blob:", "http:", "https:"]);
326 assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]);
327 });
328});