History

feat(cloud): manage cloudflared installation and relay defaults

Julius Marminge committed 5 months ago

Commit 91e8bff1 parent b9c613a38 files · +1557 −74Browse files

Unified · Split

38 files
  1. .env.example +5 −0
  2. .github/workflows/release.yml +6 −0
  3. README.md +5 −0
  4. apps/desktop/src/ipc/DesktopIpcHandlers.ts +3 −0
  5. apps/desktop/src/ipc/channels.ts +2 −0
  6. apps/desktop/src/ipc/methods/cloudAuth.test.ts +55 −0
  7. apps/desktop/src/ipc/methods/cloudAuth.ts +14 −5
  8. apps/desktop/src/ipc/methods/cloudflared.test.ts +43 −0
  9. apps/desktop/src/ipc/methods/cloudflared.ts +27 −0
  10. apps/desktop/src/main.ts +17 −1
  11. apps/desktop/src/preload.ts +2 −0
  12. apps/mobile/README.md +7 −0
  13. apps/mobile/app.config.ts +8 −3
  14. apps/mobile/src/app/settings/waitlist.tsx +1 −1
  15. apps/server/src/cloud/ManagedEndpointRuntime.test.ts +117 −6
  16. apps/server/src/cloud/ManagedEndpointRuntime.ts +73 −21
  17. apps/server/src/server.ts +12 −1
  18. apps/web/src/cloud/linkEnvironment.test.ts +52 −1
  19. apps/web/src/cloud/linkEnvironment.ts +59 −5
  20. apps/web/src/components/settings/CloudSettings.tsx +1 −1
  21. apps/web/src/components/settings/SettingsPanels.browser.tsx +12 −0
  22. apps/web/src/localApi.test.ts +12 −0
  23. apps/web/tsconfig.json +1 −1
  24. apps/web/vite.config.ts +9 −0
  25. docs/t3-cloud-clerk.md +36 −9
  26. infra/relay/.env.example +12 −0
  27. infra/relay/README.md +4 −3
  28. infra/relay/alchemy.run.ts +1 −1
  29. infra/relay/src/worker.ts +14 −10
  30. infra/relay/src/zone.ts +22 −5
  31. packages/contracts/src/ipc.ts +22 −0
  32. packages/shared/package.json +4 −0
  33. packages/shared/src/cloudflared.test.ts +257 −0
  34. packages/shared/src/cloudflared.ts +477 −0
  35. packages/shared/src/relayAuth.ts +9 −0
  36. scripts/dev-runner.ts +4 −0
  37. scripts/lib/public-config.test.ts +74 −0
  38. scripts/lib/public-config.ts +78 −0
.env.example+5−0
@@ -0,0 +1,5 @@
1# Optional root-level overrides for the checked-in public client configuration.
2# Server-side secrets must never be committed here.
3#
4# T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_...
5# T3_RELAY_URL=https://relay.example.com
.github/workflows/release.yml+6−0
@@ -26,6 +26,10 @@ permissions: · 25 unmodified lines
26 contents: read26 contents: read
27 id-token: none27 id-token: none
28 28
29env:
30 T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }}
31 T3_RELAY_URL: ${{ vars.T3_RELAY_URL }}
32
29jobs:33jobs:
30 check_changes:34 check_changes:
31 name: Check for changes since last nightly35 name: Check for changes since last nightly
@@ -648,6 +652,8 @@ jobs: · 616 unmodified lines
648 --token "$VERCEL_TOKEN" \652 --token "$VERCEL_TOKEN" \
649 "${vercel_scope_args[@]}" \653 "${vercel_scope_args[@]}" \
650 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \654 --build-env "APP_VERSION=${{ needs.preflight.outputs.version }}" \
655 --build-env "T3CODE_CLERK_PUBLISHABLE_KEY=${T3CODE_CLERK_PUBLISHABLE_KEY:-}" \
656 --build-env "T3_RELAY_URL=${T3_RELAY_URL:-}" \
651 --build-env "VITE_HOSTED_APP_URL=$router_url" \657 --build-env "VITE_HOSTED_APP_URL=$router_url" \
652 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"658 --build-env "VITE_HOSTED_APP_CHANNEL=$channel_name"
653 )"659 )"
README.md+5−0
@@ -62,6 +62,11 @@ mise install · 61 unmodified lines
62vp install62vp install
63```63```
64 64
65T3 Cloud public client configuration has checked-in development defaults, so a fresh clone works
66without creating app-local `.env` files. To point web, desktop, and mobile at another Clerk/relay
67deployment, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the
68canonical overrides there.
69
65Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.70Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR.
66 71
67Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).72Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv).
apps/desktop/src/ipc/DesktopIpcHandlers.ts+3−0
@@ -9,6 +9,7 @@ import { · 8 unmodified lines
9 setCloudAuthToken,9 setCloudAuthToken,
10} from "./methods/cloudAuth.ts";10} from "./methods/cloudAuth.ts";
11import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";11import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts";
12import { getCloudflaredStatus, installCloudflared } from "./methods/cloudflared.ts";
12import {13import {
13 getSavedEnvironmentRegistry,14 getSavedEnvironmentRegistry,
14 getSavedEnvironmentSecret,15 getSavedEnvironmentSecret,
@@ -87,6 +88,8 @@ export const installDesktopIpcHandlers = Effect.gen(function* () { · 72 unmodified lines
87 yield* ipc.handle(setCloudAuthToken);88 yield* ipc.handle(setCloudAuthToken);
88 yield* ipc.handle(clearCloudAuthToken);89 yield* ipc.handle(clearCloudAuthToken);
89 yield* ipc.handle(fetchCloudAuth);90 yield* ipc.handle(fetchCloudAuth);
91 yield* ipc.handle(getCloudflaredStatus);
92 yield* ipc.handle(installCloudflared);
90 93
91 yield* ipc.handle(getUpdateState);94 yield* ipc.handle(getUpdateState);
92 yield* ipc.handle(setUpdateChannel);95 yield* ipc.handle(setUpdateChannel);
apps/desktop/src/ipc/channels.ts+2−0
@@ -8,6 +8,8 @@ export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; · 7 unmodified lines
8export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token";8export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token";
9export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token";9export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token";
10export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth";10export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth";
11export const GET_CLOUDFLARED_STATUS_CHANNEL = "desktop:get-cloudflared-status";
12export const INSTALL_CLOUDFLARED_CHANNEL = "desktop:install-cloudflared";
11export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback";13export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback";
12export const MENU_ACTION_CHANNEL = "desktop:menu-action";14export const MENU_ACTION_CHANNEL = "desktop:menu-action";
13export const UPDATE_STATE_CHANNEL = "desktop:update-state";15export const UPDATE_STATE_CHANNEL = "desktop:update-state";
apps/desktop/src/ipc/methods/cloudAuth.test.ts+55−0
@@ -0,0 +1,55 @@
1import { assert, describe, it } from "@effect/vitest";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
5
6import { fetchCloudAuth } from "./cloudAuth.ts";
7
8function makeHttpClientLayer(
9 handler: (
10 request: HttpClientRequest.HttpClientRequest,
11 ) => Effect.Effect<HttpClientResponse.HttpClientResponse, never>,
12) {
13 return Layer.succeed(
14 HttpClient.HttpClient,
15 HttpClient.make((request) => handler(request)),
16 );
17}
18
19describe("Desktop cloud auth IPC", () => {
20 it.effect("preserves Clerk's URL-encoded OAuth form content type", () => {
21 const body = "strategy=oauth_google&redirect_url=t3code%3A%2F%2Fauth%2Fcallback";
22 let forwardedRequest: HttpClientRequest.HttpClientRequest | null = null;
23 const layer = makeHttpClientLayer((request) =>
24 Effect.sync(() => {
25 forwardedRequest = request;
26 return HttpClientResponse.fromWeb(
27 request,
28 Response.json({ response: { object: "sign_in_attempt" } }),
29 );
30 }),
31 );
32
33 return Effect.gen(function* () {
34 yield* fetchCloudAuth.handler({
35 url: "https://example.clerk.accounts.dev/v1/client/sign_ins",
36 method: "POST",
37 headers: {
38 "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
39 "x-mobile": "1",
40 },
41 body,
42 });
43
44 assert(forwardedRequest !== null);
45 assert.equal(
46 forwardedRequest.headers["content-type"],
47 "application/x-www-form-urlencoded;charset=UTF-8",
48 );
49 assert.equal(forwardedRequest.body._tag, "Uint8Array");
50 if (forwardedRequest.body._tag === "Uint8Array") {
51 assert.equal(new TextDecoder().decode(forwardedRequest.body.body), body);
52 }
53 }).pipe(Effect.provide(layer));
54 });
55});
apps/desktop/src/ipc/methods/cloudAuth.ts+14−5
@@ -6,7 +6,7 @@ import * as Data from "effect/Data"; · 5 unmodified lines
6import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
7import * as Option from "effect/Option";7import * as Option from "effect/Option";
8import * as Schema from "effect/Schema";8import * as Schema from "effect/Schema";
9import { HttpClient, HttpClientRequest } from "effect/unstable/http";9import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http";
10 10
11import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";11import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts";
12import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";12import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts";
@@ -91,11 +91,20 @@ export const fetchCloudAuth = makeIpcMethod({ · 78 unmodified lines
91 }),91 }),
92 });92 });
93 93
94 const request = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(url, {94 const requestWithoutBody = HttpClientRequest.make((input.method ?? "GET") as "GET" | "POST")(
95 headers: input.headers,95 url,
96 }).pipe(96 {
97 input.body === undefined ? (request) => request : HttpClientRequest.bodyText(input.body),97 headers: input.headers,
98 },
98 );99 );
100 const request =
101 input.body === undefined
102 ? requestWithoutBody
103 : HttpClientRequest.bodyText(
104 requestWithoutBody,
105 input.body,
106 Option.getOrUndefined(Headers.get(requestWithoutBody.headers, "content-type")),
107 );
99 108
100 const response = yield* HttpClient.execute(request).pipe(109 const response = yield* HttpClient.execute(request).pipe(
101 Effect.mapError(110 Effect.mapError(
apps/desktop/src/ipc/methods/cloudflared.test.ts+43−0
@@ -0,0 +1,43 @@
1import { describe, expect, it } from "@effect/vitest";
2import * as Cloudflared from "@t3tools/shared/cloudflared";
3import * as Effect from "effect/Effect";
4import * as Layer from "effect/Layer";
5
6import { getCloudflaredStatus, installCloudflared } from "./cloudflared.ts";
7
8const available = {
9 status: "available",
10 executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared",
11 source: "managed",
12 version: Cloudflared.CLOUDFLARED_VERSION,
13} as const;
14
15describe("Desktop cloudflared IPC", () => {
16 it.effect("reads status and delegates installation to the shared manager", () =>
17 Effect.gen(function* () {
18 const installed: Array<boolean> = [];
19 const layer = Layer.succeed(
20 Cloudflared.CloudflaredExecutable,
21 Cloudflared.CloudflaredExecutable.of({
22 resolve: Effect.succeed({
23 status: "missing",
24 version: Cloudflared.CLOUDFLARED_VERSION,
25 }),
26 install: Effect.sync(() => {
27 installed.push(true);
28 return available;
29 }),
30 }),
31 );
32
33 expect(yield* getCloudflaredStatus.handler(undefined).pipe(Effect.provide(layer))).toEqual({
34 status: "missing",
35 version: Cloudflared.CLOUDFLARED_VERSION,
36 });
37 expect(yield* installCloudflared.handler(undefined).pipe(Effect.provide(layer))).toEqual(
38 available,
39 );
40 expect(installed).toEqual([true]);
41 }),
42 );
43});
apps/desktop/src/ipc/methods/cloudflared.ts+27−0
@@ -0,0 +1,27 @@
1import { DesktopCloudflaredStatusSchema } from "@t3tools/contracts";
2import * as Cloudflared from "@t3tools/shared/cloudflared";
3import * as Effect from "effect/Effect";
4import * as Schema from "effect/Schema";
5
6import * as IpcChannels from "../channels.ts";
7import { makeIpcMethod } from "../DesktopIpc.ts";
8
9export const getCloudflaredStatus = makeIpcMethod({
10 channel: IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL,
11 payload: Schema.Undefined,
12 result: DesktopCloudflaredStatusSchema,
13 handler: Effect.fn("desktop.ipc.cloudflared.getStatus")(function* () {
14 const cloudflared = yield* Cloudflared.CloudflaredExecutable;
15 return yield* cloudflared.resolve;
16 }),
17});
18
19export const installCloudflared = makeIpcMethod({
20 channel: IpcChannels.INSTALL_CLOUDFLARED_CHANNEL,
21 payload: Schema.Undefined,
22 result: DesktopCloudflaredStatusSchema,
23 handler: Effect.fn("desktop.ipc.cloudflared.install")(function* () {
24 const cloudflared = yield* Cloudflared.CloudflaredExecutable;
25 return yield* cloudflared.install;
26 }),
27});
apps/desktop/src/main.ts+17−1
@@ -9,6 +9,7 @@ import * as Option from "effect/Option"; · 8 unmodified lines
9import * as Electron from "electron";9import * as Electron from "electron";
10 10
11import * as NetService from "@t3tools/shared/Net";11import * as NetService from "@t3tools/shared/Net";
12import * as Cloudflared from "@t3tools/shared/cloudflared";
12import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";13import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command";
13import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";14import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel";
14import serverPackageJson from "../../server/package.json" with { type: "json" };15import serverPackageJson from "../../server/package.json" with { type: "json" };
@@ -94,6 +95,17 @@ const desktopSshEnvironmentLayer = Layer.unwrap( · 79 unmodified lines
94 }),95 }),
95);96);
96 97
98const desktopCloudflaredLayer = Layer.unwrap(
99 Effect.gen(function* () {
100 const environment = yield* DesktopEnvironment.DesktopEnvironment;
101 return Cloudflared.layer({
102 baseDir: environment.baseDir,
103 platform: environment.platform,
104 arch: environment.processArch,
105 });
106 }),
107);
108
97const electronLayer = Layer.mergeAll(109const electronLayer = Layer.mergeAll(
98 ElectronApp.layer,110 ElectronApp.layer,
99 ElectronDialog.layer,111 ElectronDialog.layer,
@@ -141,7 +153,11 @@ const desktopApplicationLayer = Layer.mergeAll( · 41 unmodified lines
141 DesktopCloudAuth.layer,153 DesktopCloudAuth.layer,
142 DesktopShellEnvironment.layer,154 DesktopShellEnvironment.layer,
143 desktopSshLayer,155 desktopSshLayer,
144).pipe(Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopBackendLayer));156).pipe(
157 Layer.provideMerge(DesktopUpdates.layer),
158 Layer.provideMerge(desktopCloudflaredLayer),
159 Layer.provideMerge(desktopBackendLayer),
160);
145 161
146const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe(162const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe(
147 Layer.flatMap(() =>163 Layer.flatMap(() =>
apps/desktop/src/preload.ts+2−0
@@ -102,6 +102,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { · 101 unmodified lines
102 ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token),102 ipcRenderer.invoke(IpcChannels.SET_CLOUD_AUTH_TOKEN_CHANNEL, token),
103 clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL),103 clearCloudAuthToken: () => ipcRenderer.invoke(IpcChannels.CLEAR_CLOUD_AUTH_TOKEN_CHANNEL),
104 fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input),104 fetchCloudAuth: (input) => ipcRenderer.invoke(IpcChannels.FETCH_CLOUD_AUTH_CHANNEL, input),
105 getCloudflaredStatus: () => ipcRenderer.invoke(IpcChannels.GET_CLOUDFLARED_STATUS_CHANNEL),
106 installCloudflared: () => ipcRenderer.invoke(IpcChannels.INSTALL_CLOUDFLARED_CHANNEL),
105 onCloudAuthCallback: (listener) => {107 onCloudAuthCallback: (listener) => {
106 const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => {108 const wrappedListener = (_event: Electron.IpcRendererEvent, rawUrl: unknown) => {
107 if (typeof rawUrl !== "string") return;109 if (typeof rawUrl !== "string") return;
apps/mobile/README.md+7−0
@@ -16,6 +16,10 @@ This app has three variants: · 15 unmodified lines
16 16
17Run commands from `apps/mobile`.17Run commands from `apps/mobile`.
18 18
19Public T3 Cloud development defaults are shared with web and desktop. Optional overrides belong in
20the repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See
21[`../../.env.example`](../../.env.example).
22
19## Development23## Development
20 24
21Start Metro for the dev client:25Start Metro for the dev client:
@@ -63,6 +67,9 @@ The native lint task runs SwiftLint for Swift plus ktlint and detekt for Kotlin. · 41 unmodified lines
63 67
64CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy.68CI uses Expo fingerprinting with the `preview:dev` profile to reuse an existing compatible build when possible, or start a new internal EAS build when native runtime inputs change. Production and default local builds continue to use the `appVersion` runtime policy.
65 69
70For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL`
71as EAS environment variables. Expo config maps the canonical values into the mobile build.
72
66Create a PR preview dev-client build manually:73Create a PR preview dev-client build manually:
67 74
68```bash75```bash
apps/mobile/app.config.ts+8−3
@@ -1,8 +1,13 @@
1import type { ExpoConfig } from "expo/config";1import type { ExpoConfig } from "expo/config";
2 2
3import { loadRepoEnv } from "../../scripts/lib/public-config";
4
3type AppVariant = "development" | "preview" | "production";5type AppVariant = "development" | "preview" | "production";
4 6
5const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT);7const repoEnv = loadRepoEnv();
8Object.assign(process.env, repoEnv);
9
10const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT);
6 11
7const VARIANT_CONFIG: Record<12const VARIANT_CONFIG: Record<
8 AppVariant,13 AppVariant,
@@ -150,10 +155,10 @@ const config: ExpoConfig = { · 141 unmodified lines
150 extra: {155 extra: {
151 appVariant: APP_VARIANT,156 appVariant: APP_VARIANT,
152 relay: {157 relay: {
153 url: process.env.T3_RELAY_URL ?? null,158 url: repoEnv.T3_RELAY_URL ?? null,
154 },159 },
155 clerk: {160 clerk: {
156 publishableKey: process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,161 publishableKey: repoEnv.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY ?? null,
157 },162 },
158 eas: {163 eas: {
159 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",164 projectId: "d763fcb8-d37c-41ea-a773-b54a0ab4a454",
apps/mobile/src/app/settings/waitlist.tsx+1−1
@@ -57,7 +57,7 @@ export default function SettingsWaitlistRouteScreen() { · 56 unmodified lines
57 textAlign: "center",57 textAlign: "center",
58 }}58 }}
59 >59 >
60 Add EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment.60 Add T3CODE_CLERK_PUBLISHABLE_KEY to this build to enable waitlist enrollment.
61 </Text>61 </Text>
62 </View>62 </View>
63 )}63 )}
apps/server/src/cloud/ManagedEndpointRuntime.test.ts+117−6
@@ -1,14 +1,36 @@
1import { describe, expect, it } from "@effect/vitest";1import { describe, expect, it } from "@effect/vitest";
2import { vi } from "vitest";
2import * as Deferred from "effect/Deferred";3import * as Deferred from "effect/Deferred";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
5import * as Fiber from "effect/Fiber";
4import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
5import * as PlatformError from "effect/PlatformError";7import * as PlatformError from "effect/PlatformError";
6import * as Sink from "effect/Sink";8import * as Sink from "effect/Sink";
7import * as Stream from "effect/Stream";9import * as Stream from "effect/Stream";
8import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";10import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
11import * as Cloudflared from "@t3tools/shared/cloudflared";
9 12
10import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts";13import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts";
11 14
15const cloudflaredAvailableLayer = Layer.succeed(
16 Cloudflared.CloudflaredExecutable,
17 Cloudflared.CloudflaredExecutable.of({
18 resolve: Effect.succeed({
19 status: "available",
20 executablePath: "cloudflared",
21 source: "path",
22 version: Cloudflared.CLOUDFLARED_VERSION,
23 }),
24 install: Effect.die("unused"),
25 }),
26);
27
28const runtimeDependencies = (spawner: ReturnType<typeof ChildProcessSpawner.make>) =>
29 Layer.mergeAll(
30 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
31 cloudflaredAvailableLayer,
32 );
33
12function makeHandle(input: {34function makeHandle(input: {
13 readonly pid: number;35 readonly pid: number;
14 readonly onKill: () => void;36 readonly onKill: () => void;
@@ -58,7 +80,7 @@ describe("CloudManagedEndpointRuntime", () => { · 43 unmodified lines
58 }),80 }),
59 );81 );
60 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(82 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
61 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),83 Effect.provide(runtimeDependencies(spawner)),
62 );84 );
63 85
64 yield* runtime.applyConfig({86 yield* runtime.applyConfig({
@@ -88,6 +110,7 @@ describe("CloudManagedEndpointRuntime", () => { · 23 unmodified lines
88 ]);110 ]);
89 expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]);111 expect(spawned.map((command) => command.options.stdout)).toEqual(["ignore", "ignore"]);
90 expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]);112 expect(spawned.map((command) => command.options.stderr)).toEqual(["ignore", "ignore"]);
113 expect(spawned.map((command) => command.options.detached)).toEqual([false, false]);
91 expect(killed).toEqual([100, 101]);114 expect(killed).toEqual([100, 101]);
92 expect(stopped).toEqual({ status: "disabled" });115 expect(stopped).toEqual({ status: "disabled" });
93 }),116 }),
@@ -109,7 +132,7 @@ describe("CloudManagedEndpointRuntime", () => { · 15 unmodified lines
109 }),132 }),
110 );133 );
111 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(134 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
112 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),135 Effect.provide(runtimeDependencies(spawner)),
113 );136 );
114 137
115 const started = yield* runtime.applyConfig({138 const started = yield* runtime.applyConfig({
@@ -148,7 +171,7 @@ describe("CloudManagedEndpointRuntime", () => { · 32 unmodified lines
148 }),171 }),
149 );172 );
150 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(173 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
151 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),174 Effect.provide(runtimeDependencies(spawner)),
152 );175 );
153 const config = {176 const config = {
154 providerKind: "cloudflare_tunnel" as const,177 providerKind: "cloudflare_tunnel" as const,
@@ -195,7 +218,7 @@ describe("CloudManagedEndpointRuntime", () => { · 40 unmodified lines
195 }),218 }),
196 );219 );
197 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(220 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
198 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),221 Effect.provide(runtimeDependencies(spawner)),
199 );222 );
200 223
201 const started = yield* runtime.applyConfig({224 const started = yield* runtime.applyConfig({
@@ -208,7 +231,59 @@ describe("CloudManagedEndpointRuntime", () => { · 6 unmodified lines
208 231
209 expect(started).toMatchObject({ status: "running", pid: 400 });232 expect(started).toMatchObject({ status: "running", pid: 400 });
210 expect(spawned).toEqual([400, 401]);233 expect(spawned).toEqual([400, 401]);
211 expect(killed).toEqual([]);234 expect(killed).toEqual([400]);
235 }),
236 );
237
238 it.effect("serializes concurrent connector config changes", () =>
239 Effect.gen(function* () {
240 const spawned: Array<number> = [];
241 const killed: Array<number> = [];
242 const firstSpawnEntered = yield* Deferred.make<void>();
243 const releaseFirstSpawn = yield* Deferred.make<void>();
244 const spawner = ChildProcessSpawner.make(() =>
245 Effect.gen(function* () {
246 const pid = 500 + spawned.length;
247 spawned.push(pid);
248 if (pid === 500) {
249 yield* Deferred.succeed(firstSpawnEntered, undefined);
250 yield* Deferred.await(releaseFirstSpawn);
251 }
252 const handle = makeHandle({
253 pid,
254 onKill: () => {
255 killed.push(pid);
256 },
257 });
258 yield* Effect.addFinalizer(() => handle.kill().pipe(Effect.ignore));
259 return handle;
260 }),
261 );
262 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
263 Effect.provide(runtimeDependencies(spawner)),
264 );
265
266 const first = yield* runtime
267 .applyConfig({
268 providerKind: "cloudflare_tunnel",
269 connectorToken: "token-1",
270 })
271 .pipe(Effect.forkChild);
272 yield* Deferred.await(firstSpawnEntered);
273 const second = yield* runtime
274 .applyConfig({
275 providerKind: "cloudflare_tunnel",
276 connectorToken: "token-2",
277 })
278 .pipe(Effect.forkChild);
279 yield* Deferred.succeed(releaseFirstSpawn, undefined);
280
281 yield* Fiber.join(first);
282 const status = yield* Fiber.join(second);
283
284 expect(status).toMatchObject({ status: "running", pid: 501 });
285 expect(spawned).toEqual([500, 501]);
286 expect(killed).toEqual([500]);
212 }),287 }),
213 );288 );
214 289
@@ -225,7 +300,7 @@ describe("CloudManagedEndpointRuntime", () => { · 10 unmodified lines
225 ),300 ),
226 );301 );
227 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(302 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
228 Effect.provide(Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner)),303 Effect.provide(runtimeDependencies(spawner)),
229 );304 );
230 305
231 const status = yield* runtime.applyConfig({306 const status = yield* runtime.applyConfig({
@@ -241,4 +316,40 @@ describe("CloudManagedEndpointRuntime", () => { · 9 unmodified lines
241 });316 });
242 }),317 }),
243 );318 );
319
320 it.effect("reports a missing cloudflared executable without spawning", () =>
321 Effect.gen(function* () {
322 const spawn = vi.fn();
323 const spawner = ChildProcessSpawner.make(spawn);
324 const runtime = yield* makeCloudManagedEndpointRuntime.pipe(
325 Effect.provide(
326 Layer.mergeAll(
327 Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner),
328 Layer.succeed(
329 Cloudflared.CloudflaredExecutable,
330 Cloudflared.CloudflaredExecutable.of({
331 resolve: Effect.succeed({
332 status: "missing",
333 version: Cloudflared.CLOUDFLARED_VERSION,
334 }),
335 install: Effect.die("unused"),
336 }),
337 ),
338 ),
339 ),
340 );
341
342 const status = yield* runtime.applyConfig({
343 providerKind: "cloudflare_tunnel",
344 connectorToken: "token",
345 });
346
347 expect(status).toEqual({
348 status: "failed",
349 providerKind: "cloudflare_tunnel",
350 reason: "cloudflared is not installed.",
351 });
352 expect(spawn).not.toHaveBeenCalled();
353 }),
354 );
244});355});
apps/server/src/cloud/ManagedEndpointRuntime.ts+73−21
@@ -1,10 +1,13 @@
1import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";1import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay";
2import * as Cloudflared from "@t3tools/shared/cloudflared";
2import * as Context from "effect/Context";3import * as Context from "effect/Context";
3import * as Effect from "effect/Effect";4import * as Effect from "effect/Effect";
4import * as Exit from "effect/Exit";5import * as Exit from "effect/Exit";
5import * as Layer from "effect/Layer";6import * as Layer from "effect/Layer";
6import * as Option from "effect/Option";7import * as Option from "effect/Option";
7import * as Ref from "effect/Ref";8import * as Ref from "effect/Ref";
9import * as Result from "effect/Result";
10import * as Semaphore from "effect/Semaphore";
8import * as Scope from "effect/Scope";11import * as Scope from "effect/Scope";
9import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
10 13
@@ -88,8 +91,11 @@ const stopConnector = (connector: ActiveConnector | null) => · 77 unmodified lines
88 91
89export const makeCloudManagedEndpointRuntime = Effect.gen(function* () {92export const makeCloudManagedEndpointRuntime = Effect.gen(function* () {
90 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;93 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
94 const cloudflared = yield* Cloudflared.CloudflaredExecutable;
91 const activeRef = yield* Ref.make<ActiveConnector | null>(null);95 const activeRef = yield* Ref.make<ActiveConnector | null>(null);
92 let applyConfig: CloudManagedEndpointRuntimeShape["applyConfig"];96 const desiredConfigRef = yield* Ref.make<RelayManagedEndpointRuntimeConfig | null>(null);
97 const reconcileSemaphore = yield* Semaphore.make(1);
98 let reconcileConfig: CloudManagedEndpointRuntimeShape["applyConfig"];
93 99
94 const stopActive = Effect.gen(function* () {100 const stopActive = Effect.gen(function* () {
95 const active = yield* Ref.getAndSet(activeRef, null);101 const active = yield* Ref.getAndSet(activeRef, null);
@@ -98,26 +104,46 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 2 unmodified lines
98 104
99 const superviseConnector = (connector: ActiveConnector) =>105 const superviseConnector = (connector: ActiveConnector) =>
100 Effect.gen(function* () {106 Effect.gen(function* () {
101 const exitCode = yield* connector.child.exitCode;107 const result = yield* Effect.result(connector.child.exitCode);
102 const active = yield* Ref.get(activeRef);108 yield* reconcileSemaphore.withPermits(1)(
103 if (active?.child.pid !== connector.child.pid || active.configKey !== connector.configKey) {109 Effect.gen(function* () {
104 return;110 const active = yield* Ref.get(activeRef);
105 }111 if (
106 yield* Ref.set(activeRef, null);112 active?.child.pid !== connector.child.pid ||
107 yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", {113 active.configKey !== connector.configKey
108 pid: Number(connector.child.pid),114 ) {
109 exitCode: Number(exitCode),115 return;
110 tunnelId: connector.config.tunnelId,116 }
111 tunnelName: connector.config.tunnelName,117 yield* Ref.set(activeRef, null);
112 });118 yield* stopConnector(connector);
113 yield* applyConfig(connector.config);119
120 const desiredConfig = yield* Ref.get(desiredConfigRef);
121 if (
122 !desiredConfig ||
123 desiredConfig.providerKind !== "cloudflare_tunnel" ||
124 runtimeConfigKey(desiredConfig) !== connector.configKey
125 ) {
126 return;
127 }
128
129 yield* Effect.logWarning("Cloudflare managed endpoint connector exited; restarting", {
130 pid: Number(connector.child.pid),
131 ...(Result.isSuccess(result)
132 ? { exitCode: Number(result.success) }
133 : { cause: result.failure }),
134 tunnelId: connector.config.tunnelId,
135 tunnelName: connector.config.tunnelName,
136 });
137 yield* reconcileConfig(desiredConfig);
138 }),
139 );
114 }).pipe(140 }).pipe(
115 Effect.catch((cause) =>141 Effect.catchCause((cause) =>
116 Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }),142 Effect.logWarning("Cloudflare managed endpoint connector supervisor failed", { cause }),
117 ),143 ),
118 );144 );
119 145
120 applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(function* (config) {146 reconcileConfig = Effect.fn("CloudManagedEndpointRuntime.reconcileConfig")(function* (config) {
121 if (!config || config.providerKind !== "cloudflare_tunnel") {147 if (!config || config.providerKind !== "cloudflare_tunnel") {
122 yield* stopActive;148 yield* stopActive;
123 return config149 return config
@@ -144,14 +170,33 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 20 unmodified lines
144 170
145 yield* stopActive;171 yield* stopActive;
146 172
173 const executable = yield* cloudflared.resolve;
174 if (executable.status !== "available") {
175 return {
176 status: "failed",
177 providerKind: "cloudflare_tunnel",
178 reason:
179 executable.status === "unsupported"
180 ? `Managed cloudflared is unsupported on ${executable.platform}-${executable.arch}.`
181 : "cloudflared is not installed.",
182 ...(config.tunnelId ? { tunnelId: config.tunnelId } : {}),
183 ...(config.tunnelName ? { tunnelName: config.tunnelName } : {}),
184 } satisfies CloudManagedEndpointRuntimeStatus;
185 }
186
147 const connectorScope = yield* Scope.make("sequential");187 const connectorScope = yield* Scope.make("sequential");
148 const child = yield* spawner188 const child = yield* spawner
149 .spawn(189 .spawn(
150 ChildProcess.make("cloudflared", ["tunnel", "run", "--token", config.connectorToken], {190 ChildProcess.make(
151 shell: process.platform === "win32",191 executable.executablePath,
152 stderr: "ignore",192 ["tunnel", "run", "--token", config.connectorToken],
153 stdout: "ignore",193 {
154 }),194 detached: false,
195 shell: process.platform === "win32",
196 stderr: "ignore",
197 stdout: "ignore",
198 },
199 ),
155 )200 )
156 .pipe(201 .pipe(
157 Effect.provideService(Scope.Scope, connectorScope),202 Effect.provideService(Scope.Scope, connectorScope),
@@ -210,6 +255,13 @@ export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { · 52 unmodified lines
210 } satisfies CloudManagedEndpointRuntimeStatus;255 } satisfies CloudManagedEndpointRuntimeStatus;
211 });256 });
212 257
258 const applyConfig = Effect.fn("CloudManagedEndpointRuntime.applyConfig")(
259 (config: RelayManagedEndpointRuntimeConfig | null) =>
260 reconcileSemaphore.withPermits(1)(
261 Ref.set(desiredConfigRef, config).pipe(Effect.andThen(reconcileConfig(config))),
262 ),
263 );
264
213 return CloudManagedEndpointRuntime.of({265 return CloudManagedEndpointRuntime.of({
214 applyConfig,266 applyConfig,
215 });267 });
apps/server/src/server.ts+12−1
@@ -81,6 +81,7 @@ import { · 80 unmodified lines
81} from "./serverRuntimeState.ts";81} from "./serverRuntimeState.ts";
82import { orchestrationHttpApiLayer } from "./orchestration/http.ts";82import { orchestrationHttpApiLayer } from "./orchestration/http.ts";
83import * as NetService from "@t3tools/shared/Net";83import * as NetService from "@t3tools/shared/Net";
84import * as Cloudflared from "@t3tools/shared/cloudflared";
84import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";85import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale";
85 86
86const PtyAdapterLive = Layer.unwrap(87const PtyAdapterLive = Layer.unwrap(
@@ -95,6 +96,13 @@ const PtyAdapterLive = Layer.unwrap( · 8 unmodified lines
95 }),96 }),
96);97);
97 98
99const CloudflaredExecutableLive = Layer.unwrap(
100 Effect.gen(function* () {
101 const config = yield* ServerConfig;
102 return Cloudflared.layer({ baseDir: config.baseDir });
103 }),
104);
105
98const HttpServerLive = Layer.unwrap(106const HttpServerLive = Layer.unwrap(
99 Effect.gen(function* () {107 Effect.gen(function* () {
100 const config = yield* ServerConfig;108 const config = yield* ServerConfig;
@@ -277,7 +285,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 176 unmodified lines
277 Layer.provideMerge(AuthLayerLive),285 Layer.provideMerge(AuthLayerLive),
278 Layer.provideMerge(ServerSecretStore.layer),286 Layer.provideMerge(ServerSecretStore.layer),
279 Layer.provideMerge(287 Layer.provideMerge(
280 CloudManagedEndpointRuntime.layer.pipe(Layer.provide(ServerSecretStore.layer)),288 CloudManagedEndpointRuntime.layer.pipe(
289 Layer.provide(ServerSecretStore.layer),
290 Layer.provide(CloudflaredExecutableLive),
291 ),
281 ),292 ),
282);293);
283 294
apps/web/src/cloud/linkEnvironment.test.ts+52−1
@@ -1,6 +1,7 @@
1import { EnvironmentId } from "@t3tools/contracts";1import { EnvironmentId } from "@t3tools/contracts";
2import { RelayWebClientId } from "@t3tools/contracts/relay";2import { RelayWebClientId } from "@t3tools/contracts/relay";
3import { beforeEach, vi } from "vitest";3import { afterEach, beforeEach, vi } from "vitest";
4import type { DesktopBridge } from "@t3tools/contracts";
4import { describe, expect, it } from "@effect/vitest";5import { describe, expect, it } from "@effect/vitest";
5import * as Effect from "effect/Effect";6import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";7import * as Layer from "effect/Layer";
@@ -15,6 +16,7 @@ import { · 8 unmodified lines
15import type { SavedEnvironmentRecord } from "../environments/runtime";16import type { SavedEnvironmentRecord } from "../environments/runtime";
16import {17import {
17 connectManagedCloudEnvironment,18 connectManagedCloudEnvironment,
19 ensureDesktopCloudflaredAvailable,
18 linkEnvironmentToCloud,20 linkEnvironmentToCloud,
19 linkPrimaryEnvironmentToCloud,21 linkPrimaryEnvironmentToCloud,
20 listManagedCloudEnvironments,22 listManagedCloudEnvironments,
@@ -96,6 +98,13 @@ function requestBodyText(body: BodyInit | null | undefined): string { · 75 unmodified lines
96}98}
97 99
98describe("web cloud link environment client", () => {100describe("web cloud link environment client", () => {
101 afterEach(() => {
102 if ("window" in globalThis) {
103 Reflect.deleteProperty(window, "desktopBridge");
104 }
105 vi.unstubAllGlobals();
106 });
107
99 beforeEach(() => {108 beforeEach(() => {
100 vi.restoreAllMocks();109 vi.restoreAllMocks();
101 createProofMock.mockClear();110 createProofMock.mockClear();
@@ -115,6 +124,48 @@ describe("web cloud link environment client", () => { · 13 unmodified lines
115 expect(normalizeRelayBaseUrl(" ")).toBeNull();124 expect(normalizeRelayBaseUrl(" ")).toBeNull();
116 });125 });
117 126
127 it("installs cloudflared after desktop confirmation", async () => {
128 vi.stubGlobal("window", {});
129 const confirm = vi.fn().mockResolvedValue(true);
130 const installCloudflared = vi.fn().mockResolvedValue({
131 status: "available",
132 executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared",
133 source: "managed",
134 version: "2026.5.2",
135 });
136 window.desktopBridge = {
137 confirm,
138 getCloudflaredStatus: vi.fn().mockResolvedValue({
139 status: "missing",
140 version: "2026.5.2",
141 }),
142 installCloudflared,
143 } as unknown as DesktopBridge;
144
145 await Effect.runPromise(ensureDesktopCloudflaredAvailable());
146
147 expect(confirm).toHaveBeenCalledOnce();
148 expect(installCloudflared).toHaveBeenCalledOnce();
149 });
150
151 it("does not install cloudflared when desktop confirmation is declined", async () => {
152 vi.stubGlobal("window", {});
153 const installCloudflared = vi.fn();
154 window.desktopBridge = {
155 confirm: vi.fn().mockResolvedValue(false),
156 getCloudflaredStatus: vi.fn().mockResolvedValue({
157 status: "missing",
158 version: "2026.5.2",
159 }),
160 installCloudflared,
161 } as unknown as DesktopBridge;
162
163 await expect(Effect.runPromise(ensureDesktopCloudflaredAvailable())).rejects.toMatchObject({
164 message: "Cloudflare Tunnel installation was cancelled.",
165 });
166 expect(installCloudflared).not.toHaveBeenCalled();
167 });
168
118 it.effect("lists relay-managed environments for hosted and served web clients", () =>169 it.effect("lists relay-managed environments for hosted and served web clients", () =>
119 Effect.gen(function* () {170 Effect.gen(function* () {
120 const fetchMock = vi.fn().mockResolvedValueOnce(171 const fetchMock = vi.fn().mockResolvedValueOnce(
apps/web/src/cloud/linkEnvironment.ts+59−5
@@ -55,6 +55,59 @@ export class CloudEnvironmentLinkError extends Data.TaggedError("CloudEnvironmen · 54 unmodified lines
55 readonly cause?: unknown;55 readonly cause?: unknown;
56}> {}56}> {}
57 57
58const desktopCloudflaredBridgeError = (cause: unknown) =>
59 new CloudEnvironmentLinkError({
60 message: "Could not prepare Cloudflare Tunnel.",
61 cause,
62 });
63
64export function ensureDesktopCloudflaredAvailable(): Effect.Effect<
65 void,
66 CloudEnvironmentLinkError
67> {
68 const bridge = typeof window === "undefined" ? undefined : window.desktopBridge;
69 if (!bridge) return Effect.void;
70
71 return Effect.gen(function* () {
72 const status = yield* Effect.tryPromise({
73 try: () => bridge.getCloudflaredStatus(),
74 catch: desktopCloudflaredBridgeError,
75 });
76 if (status.status === "available") return;
77 if (status.status === "unsupported") {
78 return yield* new CloudEnvironmentLinkError({
79 message: `T3 Code cannot install cloudflared automatically on ${status.platform}-${status.arch}.`,
80 });
81 }
82
83 const confirmed = yield* Effect.tryPromise({
84 try: () =>
85 bridge.confirm(
86 "T3 Code needs Cloudflare Tunnel to make this environment available through T3 Cloud. Download and install cloudflared now?",
87 ),
88 catch: desktopCloudflaredBridgeError,
89 });
90 if (!confirmed) {
91 return yield* new CloudEnvironmentLinkError({
92 message: "Cloudflare Tunnel installation was cancelled.",
93 });
94 }
95
96 const installed = yield* Effect.tryPromise({
97 try: () => bridge.installCloudflared(),
98 catch: desktopCloudflaredBridgeError,
99 });
100 if (installed.status !== "available") {
101 return yield* new CloudEnvironmentLinkError({
102 message:
103 installed.status === "unsupported"
104 ? `T3 Code cannot install cloudflared automatically on ${installed.platform}-${installed.arch}.`
105 : "cloudflared is still unavailable after installation.",
106 });
107 }
108 });
109}
110
58const isRelayProtectedError = Schema.is(RelayProtectedError);111const isRelayProtectedError = Schema.is(RelayProtectedError);
59const isEnvironmentCloudApiError = Schema.is(112const isEnvironmentCloudApiError = Schema.is(
60 Schema.Union([113 Schema.Union([
@@ -231,7 +284,7 @@ export function listManagedCloudEnvironments(input: { · 170 unmodified lines
231 const configuredRelayUrl = relayUrl();284 const configuredRelayUrl = relayUrl();
232 if (!configuredRelayUrl) {285 if (!configuredRelayUrl) {
233 return yield* new CloudEnvironmentLinkError({286 return yield* new CloudEnvironmentLinkError({
234 message: "VITE_T3_RELAY_URL is not configured.",287 message: "T3_RELAY_URL is not configured.",
235 });288 });
236 }289 }
237 const relayClient = yield* ManagedRelayClient;290 const relayClient = yield* ManagedRelayClient;
@@ -261,7 +314,7 @@ export function listCloudDevices(input: { · 23 unmodified lines
261 return Effect.gen(function* () {314 return Effect.gen(function* () {
262 if (!relayUrl()) {315 if (!relayUrl()) {
263 return yield* new CloudEnvironmentLinkError({316 return yield* new CloudEnvironmentLinkError({
264 message: "VITE_T3_RELAY_URL is not configured.",317 message: "T3_RELAY_URL is not configured.",
265 });318 });
266 }319 }
267 const relayClient = yield* ManagedRelayClient;320 const relayClient = yield* ManagedRelayClient;
@@ -290,7 +343,7 @@ export function connectManagedCloudEnvironment(input: { · 22 unmodified lines
290 const configuredRelayUrl = relayUrl();343 const configuredRelayUrl = relayUrl();
291 if (!configuredRelayUrl) {344 if (!configuredRelayUrl) {
292 return yield* new CloudEnvironmentLinkError({345 return yield* new CloudEnvironmentLinkError({
293 message: "VITE_T3_RELAY_URL is not configured.",346 message: "T3_RELAY_URL is not configured.",
294 });347 });
295 }348 }
296 const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl);349 const persistedRelayUrl = normalizeRelayBaseUrl(input.relayUrl);
@@ -465,7 +518,7 @@ export function linkEnvironmentToCloud(input: { · 168 unmodified lines
465 const configuredRelayUrl = relayUrl();518 const configuredRelayUrl = relayUrl();
466 if (!configuredRelayUrl) {519 if (!configuredRelayUrl) {
467 return yield* new CloudEnvironmentLinkError({520 return yield* new CloudEnvironmentLinkError({
468 message: "VITE_T3_RELAY_URL is not configured.",521 message: "T3_RELAY_URL is not configured.",
469 });522 });
470 }523 }
471 const relayClient = yield* ManagedRelayClient;524 const relayClient = yield* ManagedRelayClient;
@@ -560,7 +613,7 @@ export function linkPrimaryEnvironmentToCloud(input: { · 88 unmodified lines
560 const configuredRelayUrl = relayUrl();613 const configuredRelayUrl = relayUrl();
561 if (!configuredRelayUrl) {614 if (!configuredRelayUrl) {
562 return yield* new CloudEnvironmentLinkError({615 return yield* new CloudEnvironmentLinkError({
563 message: "VITE_T3_RELAY_URL is not configured.",616 message: "T3_RELAY_URL is not configured.",
564 });617 });
565 }618 }
566 const relayClient = yield* ManagedRelayClient;619 const relayClient = yield* ManagedRelayClient;
@@ -570,6 +623,7 @@ export function linkPrimaryEnvironmentToCloud(input: { · 3 unmodified lines
570 message: "Local environment is not ready yet.",623 message: "Local environment is not ready yet.",
571 });624 });
572 }625 }
626 yield* ensureDesktopCloudflaredAvailable();
573 627
574 const challenge = yield* relayClient628 const challenge = yield* relayClient
575 .createEnvironmentLinkChallenge({629 .createEnvironmentLinkChallenge({
apps/web/src/components/settings/CloudSettings.tsx+1−1
@@ -64,7 +64,7 @@ export function CloudSettingsPanel() { · 63 unmodified lines
64 <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}>64 <SettingsSection title="T3 Cloud" icon={<CloudIcon className="size-3.5" />}>
65 <SettingsRow65 <SettingsRow
66 title="Cloud account"66 title="Cloud account"
67 description="Set VITE_CLERK_PUBLISHABLE_KEY to enable optional cloud features."67 description="Set T3CODE_CLERK_PUBLISHABLE_KEY to enable optional cloud features."
68 />68 />
69 </SettingsSection>69 </SettingsSection>
70 </SettingsPageContainer>70 </SettingsPageContainer>
apps/web/src/components/settings/SettingsPanels.browser.tsx+12−0
@@ -470,6 +470,18 @@ const createDesktopBridgeStub = (overrides?: { · 469 unmodified lines
470 headers: {},470 headers: {},
471 body: "",471 body: "",
472 }),472 }),
473 getCloudflaredStatus: vi.fn().mockResolvedValue({
474 status: "available",
475 executablePath: "/usr/local/bin/cloudflared",
476 source: "path",
477 version: "2026.5.2",
478 }),
479 installCloudflared: vi.fn().mockResolvedValue({
480 status: "available",
481 executablePath: "/usr/local/bin/cloudflared",
482 source: "path",
483 version: "2026.5.2",
484 }),
473 onCloudAuthCallback: () => () => {},485 onCloudAuthCallback: () => () => {},
474 onMenuAction: () => () => {},486 onMenuAction: () => () => {},
475 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),487 getUpdateState: vi.fn().mockResolvedValue(idleUpdateState),
apps/web/src/localApi.test.ts+12−0
@@ -245,6 +245,18 @@ function makeDesktopBridge(overrides: Partial<DesktopBridge> = {}): DesktopBridg · 244 unmodified lines
245 headers: {},245 headers: {},
246 body: "",246 body: "",
247 }),247 }),
248 getCloudflaredStatus: async () => ({
249 status: "available",
250 executablePath: "/usr/local/bin/cloudflared",
251 source: "path",
252 version: "2026.5.2",
253 }),
254 installCloudflared: async () => ({
255 status: "available",
256 executablePath: "/usr/local/bin/cloudflared",
257 source: "path",
258 version: "2026.5.2",
259 }),
248 onCloudAuthCallback: () => () => undefined,260 onCloudAuthCallback: () => () => undefined,
249 onMenuAction: () => () => undefined,261 onMenuAction: () => () => undefined,
250 getUpdateState: async () => {262 getUpdateState: async () => {
apps/web/tsconfig.json+1−1
@@ -26,5 +26,5 @@ · 25 unmodified lines
26 }26 }
27 ]27 ]
28 },28 },
29 "include": ["src", "vite.config.ts", "vercel.ts", "test"]29 "include": ["src", "vite.config.ts", "vercel.ts", "test", "../../scripts/lib/public-config.ts"]
30}30}
apps/web/vite.config.ts+9−0
@@ -8,9 +8,16 @@ import "vite-plus/test/config"; · 7 unmodified lines
8import { defineConfig } from "vite-plus";8import { defineConfig } from "vite-plus";
9import pkg from "./package.json" with { type: "json" };9import pkg from "./package.json" with { type: "json" };
10 10
11import { loadRepoEnv } from "../../scripts/lib/public-config";
12
13const repoEnv = loadRepoEnv();
14Object.assign(process.env, repoEnv);
15
11const port = Number(process.env.PORT ?? 5733);16const port = Number(process.env.PORT ?? 5733);
12const host = process.env.HOST?.trim() || "localhost";17const host = process.env.HOST?.trim() || "localhost";
13const configuredWsUrl = process.env.VITE_WS_URL?.trim();18const configuredWsUrl = process.env.VITE_WS_URL?.trim();
19const configuredRelayUrl = repoEnv.VITE_T3_RELAY_URL?.trim() || "";
20const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || "";
14const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";21const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || "";
15const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;22const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version;
16const configuredHostedAppUrl = (() => {23const configuredHostedAppUrl = (() => {
@@ -123,6 +130,8 @@ export default defineConfig(() => { · 106 unmodified lines
123 define: {130 define: {
124 // In dev mode, tell the web app where the WebSocket server lives131 // In dev mode, tell the web app where the WebSocket server lives
125 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),132 "import.meta.env.VITE_WS_URL": JSON.stringify(configuredWsUrl ?? ""),
133 "import.meta.env.VITE_T3_RELAY_URL": JSON.stringify(configuredRelayUrl),
134 "import.meta.env.VITE_CLERK_PUBLISHABLE_KEY": JSON.stringify(configuredClerkPublishableKey),
126 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),135 "import.meta.env.VITE_HOSTED_APP_URL": JSON.stringify(configuredHostedAppUrl ?? ""),
127 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),136 "import.meta.env.VITE_HOSTED_APP_CHANNEL": JSON.stringify(configuredHostedAppChannel),
128 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),137 "import.meta.env.APP_VERSION": JSON.stringify(configuredAppVersion),
docs/t3-cloud-clerk.md+36−9
@@ -6,15 +6,41 @@ audience. · 5 unmodified lines
6 6
7## Application Keys7## Application Keys
8 8
9Use keys from the same Clerk instance in each location:9Web, desktop, and mobile use checked-in public development defaults from
10`packages/shared/src/relayAuth.ts`, so a fresh clone can use T3 Cloud without creating local
11environment files. These values are safe to embed in client builds: the Clerk publishable key and
12relay URL are public identifiers, not secrets.
10 13
11| Consumer | Configuration | Value |14To point all clients at another Clerk/relay deployment, add a repository-root `.env` or
12| ------------------------ | --------------------------------------- | ----------------------------------------------------- |15`.env.local` file:
13| Web and desktop renderer | `apps/web/.env` | `VITE_CLERK_PUBLISHABLE_KEY=<publishable key>` |
14| Mobile build | `apps/mobile/.env` or build environment | `EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=<publishable key>` |
15| Relay deployment | Alchemy secret | `CLERK_SECRET_KEY=<secret key>` |
16 16
17Never put `CLERK_SECRET_KEY` in a client application environment.17```dotenv
18T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key>
19T3_RELAY_URL=https://relay.example.com
20```
21
22The shared client loader projects these canonical values into the framework-specific
23`VITE_CLERK_PUBLISHABLE_KEY`, `VITE_T3_RELAY_URL`, and
24`EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY` aliases. Existing aliases remain accepted as overrides for
25compatibility, but new client configuration should use the canonical names.
26
27Configuration precedence is:
28
291. Process or CI environment variables.
302. Repository-root `.env.local`.
313. Repository-root `.env`.
324. Checked-in public development defaults.
33
34Release builds read `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` from GitHub Actions repository
35variables. EAS preview and production builds should define the same client-facing values in their
36EAS environment.
37
38For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay
39deployment reads `T3_RELAY_DOMAIN` and `T3_RELAY_ZONE_NAME` through Effect `Config`, with the
40checked-in shared values as defaults. `bun --cwd infra/relay run deploy` invokes Alchemy from the
41relay directory, so Alchemy loads `infra/relay/.env`. The relay still requires `CLERK_SECRET_KEY` as
42an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application environment or commit it to
43the repository.
18 44
19## JWT Template45## JWT Template
20 46
@@ -25,8 +51,9 @@ In **Clerk Dashboard > JWT templates**, create a template with: · 4 unmodified lines
25| Name | `t3-relay` |51| Name | `t3-relay` |
26| Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` |52| Claims | `{ "aud": "https://t3code-relay.ineededadomain.com" }` |
27 53
28The `aud` value must be the deployed relay public URL, with no trailing slash, and must match54The `aud` value must be the deployed relay public URL, with no trailing slash. It must match the
29`VITE_T3_RELAY_URL` and `T3_RELAY_URL`. If the relay domain changes, update all three values.55client-facing `T3_RELAY_URL` and the HTTPS URL derived from the deployment's `T3_RELAY_DOMAIN`. If
56the relay domain changes, update both values and the JWT template.
30 57
31## Desktop OAuth Redirect Allowlist58## Desktop OAuth Redirect Allowlist
32 59
infra/relay/.env.example+12−0
@@ -0,0 +1,12 @@
1# Relay deployment overrides. The public relay URL is derived as
2# https://<T3_RELAY_DOMAIN>.
3T3_RELAY_DOMAIN=relay.example.com
4T3_RELAY_ZONE_NAME=example.com
5
6# Relay runtime secrets and APNs configuration.
7CLERK_SECRET_KEY=sk_test_...
8APNS_ENVIRONMENT=sandbox
9APNS_TEAM_ID=...
10APNS_KEY_ID=...
11APNS_BUNDLE_ID=...
12APNS_PRIVATE_KEY=...
infra/relay/README.md+4−3
@@ -80,12 +80,13 @@ dependencies represented at their boundary rather than mocking internal behavior · 79 unmodified lines
80The relay deploys through Alchemy:80The relay deploys through Alchemy:
81 81
82```sh82```sh
83cd infra/relay83bun --cwd infra/relay run deploy
84bun run deploy
85```84```
86 85
87The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database86The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database
88connectivity, and relay tracing resources. Runtime secrets include Clerk and APNs credentials.87connectivity, and relay tracing resources. Copy [`infra/relay/.env.example`](./.env.example) to
88`infra/relay/.env` and fill in the deployment-specific values before deploying. Alchemy loads that
89file from the relay directory. Runtime secrets include Clerk and APNs credentials.
89 90
90See:91See:
91 92
infra/relay/alchemy.run.ts+1−1
@@ -25,7 +25,7 @@ export default Alchemy.Stack( · 24 unmodified lines
25 Effect.gen(function* () {25 Effect.gen(function* () {
26 const db = yield* PlanetscaleDatabase;26 const db = yield* PlanetscaleDatabase;
27 const hyperdrive = yield* RelayHyperdrive;27 const hyperdrive = yield* RelayHyperdrive;
28 const zone = yield* ManagedEndpointZone;28 const zone = yield* ManagedEndpointZone.pipe(Effect.orDie);
29 const api = yield* Api;29 const api = yield* Api;
30 30
31 return {31 return {
infra/relay/src/worker.ts+14−10
@@ -29,7 +29,7 @@ import { · 28 unmodified lines
29 tokenApi,29 tokenApi,
30 withoutCapturedParentSpan,30 withoutCapturedParentSpan,
31} from "./http/Api.ts";31} from "./http/Api.ts";
32import { ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN } from "./zone.ts";32import { ManagedEndpointZone, RelayDeploymentConfig } from "./zone.ts";
33import { makeRelayTraceLayer, RelayObservability } from "./observability.ts";33import { makeRelayTraceLayer, RelayObservability } from "./observability.ts";
34import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts";34import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts";
35import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts";35import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts";
@@ -87,18 +87,22 @@ const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningS · 51 unmodified lines
87 87
88export default class Api extends Cloudflare.Worker<Api>()(88export default class Api extends Cloudflare.Worker<Api>()(
89 "Api",89 "Api",
90 {90 RelayDeploymentConfig.pipe(
91 main: import.meta.filename,91 Config.map(({ relayPublicDomain }) => ({
92 compatibility: {92 main: import.meta.filename,
93 date: "2026-05-22",93 compatibility: {
94 flags: ["nodejs_compat"],94 date: "2026-05-22",
95 },95 flags: ["nodejs_compat"],
96 domain: RELAY_PUBLIC_DOMAIN,96 },
97 },97 domain: relayPublicDomain,
98 })),
99 Effect.orDie,
100 ),
98 Effect.gen(function* () {101 Effect.gen(function* () {
99 //102 //
100 // 1. Provision Infrastructure for the Worker to use103 // 1. Provision Infrastructure for the Worker to use
101 //104 //
105 const { relayPublicOrigin } = yield* RelayDeploymentConfig;
102 const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue;106 const apnsDeliveryQueue = yield* RelayApnsDeliveryQueue;
103 const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue;107 const apnsDeliveryDeadLetterQueue = yield* RelayApnsDeliveryDeadLetterQueue;
104 const cloudMintKeyPair = yield* CloudMintKeyPair;108 const cloudMintKeyPair = yield* CloudMintKeyPair;
@@ -142,7 +146,7 @@ export default class Api extends Cloudflare.Worker<Api>()( · 37 unmodified lines
142 146
143 const loadSettings = Effect.gen(function* () {147 const loadSettings = Effect.gen(function* () {
144 return RelayConfiguration.RelayConfiguration.of({148 return RelayConfiguration.RelayConfiguration.of({
145 relayIssuer: RELAY_PUBLIC_ORIGIN,149 relayIssuer: relayPublicOrigin,
146 apns: {150 apns: {
147 environment,151 environment,
148 teamId: apnsTeamId,152 teamId: apnsTeamId,
infra/relay/src/zone.ts+22−5
@@ -1,9 +1,26 @@
1import { adopt } from "alchemy/AdoptPolicy";1import { adopt } from "alchemy/AdoptPolicy";
2import * as Cloudflare from "alchemy/Cloudflare";2import * as Cloudflare from "alchemy/Cloudflare";
3import * as Config from "effect/Config";
4import * as Effect from "effect/Effect";
3 5
4export const RELAY_PUBLIC_DOMAIN = "t3code-relay.ineededadomain.com";6import { DEFAULT_T3_RELAY_DOMAIN, DEFAULT_T3_RELAY_ZONE_NAME } from "@t3tools/shared/relayAuth";
5export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`;
6 7
7export const ManagedEndpointZone = Cloudflare.Zone("ManagedEndpointZone", {8export const RelayDeploymentConfig = Config.all({
8 name: "ineededadomain.com",9 relayPublicDomain: Config.string("T3_RELAY_DOMAIN").pipe(
9}).pipe(adopt(true));10 Config.withDefault(DEFAULT_T3_RELAY_DOMAIN),
11 ),
12 managedEndpointZoneName: Config.string("T3_RELAY_ZONE_NAME").pipe(
13 Config.withDefault(DEFAULT_T3_RELAY_ZONE_NAME),
14 ),
15}).pipe(
16 Config.map((config) => ({
17 relayPublicDomain: config.relayPublicDomain,
18 relayPublicOrigin: `https://${config.relayPublicDomain}`,
19 managedEndpointZoneName: config.managedEndpointZoneName,
20 })),
21);
22
23export const ManagedEndpointZone = RelayDeploymentConfig.pipe(
24 Config.map(({ managedEndpointZoneName }) => managedEndpointZoneName),
25 Effect.flatMap((name) => Cloudflare.Zone("ManagedEndpointZone", { name }).pipe(adopt(true))),
26);
packages/contracts/src/ipc.ts+22−0
@@ -394,6 +394,26 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ · 393 unmodified lines
394});394});
395export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type;395export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type;
396 396
397export const DesktopCloudflaredStatusSchema = Schema.Union([
398 Schema.Struct({
399 status: Schema.Literal("available"),
400 executablePath: Schema.String,
401 source: Schema.Literals(["override", "managed", "path"]),
402 version: Schema.String,
403 }),
404 Schema.Struct({
405 status: Schema.Literal("missing"),
406 version: Schema.String,
407 }),
408 Schema.Struct({
409 status: Schema.Literal("unsupported"),
410 platform: Schema.String,
411 arch: Schema.String,
412 version: Schema.String,
413 }),
414]);
415export type DesktopCloudflaredStatus = typeof DesktopCloudflaredStatusSchema.Type;
416
397export interface DesktopBridge {417export interface DesktopBridge {
398 getAppBranding: () => DesktopAppBranding | null;418 getAppBranding: () => DesktopAppBranding | null;
399 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;419 getLocalEnvironmentBootstrap: () => DesktopEnvironmentBootstrap | null;
@@ -444,6 +464,8 @@ export interface DesktopBridge { · 44 unmodified lines
444 setCloudAuthToken: (token: string) => Promise<boolean>;464 setCloudAuthToken: (token: string) => Promise<boolean>;
445 clearCloudAuthToken: () => Promise<void>;465 clearCloudAuthToken: () => Promise<void>;
446 fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise<DesktopCloudAuthFetchResult>;466 fetchCloudAuth: (input: DesktopCloudAuthFetchInput) => Promise<DesktopCloudAuthFetchResult>;
467 getCloudflaredStatus: () => Promise<DesktopCloudflaredStatus>;
468 installCloudflared: () => Promise<DesktopCloudflaredStatus>;
447 onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void;469 onCloudAuthCallback: (listener: (rawUrl: string) => void) => () => void;
448 onMenuAction: (listener: (action: string) => void) => () => void;470 onMenuAction: (listener: (action: string) => void) => () => void;
449 getUpdateState: () => Promise<DesktopUpdateState>;471 getUpdateState: () => Promise<DesktopUpdateState>;
packages/shared/package.json+4−0
@@ -138,6 +138,10 @@ · 137 unmodified lines
138 "./terminalLabels": {138 "./terminalLabels": {
139 "types": "./src/terminalLabels.ts",139 "types": "./src/terminalLabels.ts",
140 "import": "./src/terminalLabels.ts"140 "import": "./src/terminalLabels.ts"
141 },
142 "./cloudflared": {
143 "types": "./src/cloudflared.ts",
144 "import": "./src/cloudflared.ts"
141 }145 }
142 },146 },
143 "scripts": {147 "scripts": {
packages/shared/src/cloudflared.test.ts+257−0
@@ -0,0 +1,257 @@
1import { sha256 } from "@noble/hashes/sha2";
2import * as NodeServices from "@effect/platform-node/NodeServices";
3import { describe, expect, it } from "@effect/vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as Encoding from "effect/Encoding";
7import * as FileSystem from "effect/FileSystem";
8import * as Layer from "effect/Layer";
9import * as Sink from "effect/Sink";
10import * as Stream from "effect/Stream";
11import { HttpClient, HttpClientResponse } from "effect/unstable/http";
12import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
13
14import {
15 CloudflaredInstallError,
16 CLOUDFLARED_VERSION,
17 makeCloudflaredExecutable,
18 resolveManagedCloudflaredPath,
19} from "./cloudflared.ts";
20
21const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} });
22
23function makeHandle(exitCode = 0) {
24 return ChildProcessSpawner.makeHandle({
25 pid: ChildProcessSpawner.ProcessId(100),
26 exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(exitCode)),
27 isRunning: Effect.succeed(false),
28 kill: () => Effect.void,
29 unref: Effect.succeed(Effect.void),
30 stdin: Sink.drain,
31 stdout: Stream.empty,
32 stderr: Stream.empty,
33 all: Stream.empty,
34 getInputFd: () => Sink.drain,
35 getOutputFd: () => Stream.empty,
36 });
37}
38
39const makeHttpClientLayer = (bytes: Uint8Array) =>
40 Layer.succeed(
41 HttpClient.HttpClient,
42 HttpClient.make((request) =>
43 Effect.succeed(
44 HttpClientResponse.fromWeb(request, new Response(bytes.buffer as ArrayBuffer)),
45 ),
46 ),
47 );
48
49const makeSpawnerLayer = (commands: Array<string>) =>
50 Layer.succeed(
51 ChildProcessSpawner.ChildProcessSpawner,
52 ChildProcessSpawner.make((command) =>
53 Effect.sync(() => {
54 commands.push(ChildProcess.isStandardCommand(command) ? command.command : "piped-command");
55 return makeHandle();
56 }),
57 ),
58 );
59
60describe("CloudflaredExecutable", () => {
61 it.effect("resolves explicit overrides before managed and PATH executables", () =>
62 Effect.gen(function* () {
63 const fileSystem = yield* FileSystem.FileSystem;
64 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
65 prefix: "t3-cloudflared-test-",
66 });
67 const overridePath = `${baseDir}/override-cloudflared`;
68 yield* fileSystem.writeFileString(overridePath, "override");
69 yield* fileSystem.chmod(overridePath, 0o755);
70 const manager = yield* makeCloudflaredExecutable({
71 baseDir,
72 platform: "linux",
73 arch: "x64",
74 configProvider: () =>
75 ConfigProvider.fromEnv({
76 env: {
77 PATH: "",
78 T3CODE_CLOUDFLARED_PATH: overridePath,
79 },
80 }),
81 });
82
83 expect(yield* manager.resolve).toEqual({
84 status: "available",
85 executablePath: overridePath,
86 source: "override",
87 version: CLOUDFLARED_VERSION,
88 });
89 }).pipe(
90 Effect.scoped,
91 Effect.provide(
92 Layer.mergeAll(
93 NodeServices.layer,
94 makeHttpClientLayer(new Uint8Array()),
95 makeSpawnerLayer([]),
96 ),
97 ),
98 ),
99 );
100
101 it.effect("downloads, verifies, validates, and atomically installs the managed executable", () =>
102 Effect.gen(function* () {
103 const fileSystem = yield* FileSystem.FileSystem;
104 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
105 prefix: "t3-cloudflared-test-",
106 });
107 const bytes = new TextEncoder().encode("test-cloudflared-binary");
108 const manager = yield* makeCloudflaredExecutable({
109 baseDir,
110 platform: "linux",
111 arch: "x64",
112 releaseAsset: {
113 url: "https://example.test/cloudflared",
114 sha256: Encoding.encodeHex(sha256(bytes)),
115 archive: "binary",
116 },
117 configProvider: emptyConfigProvider,
118 });
119
120 const installed = yield* manager.install;
121 const managedPath = resolveManagedCloudflaredPath({
122 baseDir,
123 platform: "linux",
124 arch: "x64",
125 });
126 expect(installed).toEqual({
127 status: "available",
128 executablePath: managedPath,
129 source: "managed",
130 version: CLOUDFLARED_VERSION,
131 });
132 expect(new TextDecoder().decode(yield* fileSystem.readFile(managedPath))).toBe(
133 "test-cloudflared-binary",
134 );
135 expect(yield* manager.resolve).toEqual(installed);
136 }).pipe(
137 Effect.scoped,
138 Effect.provide(
139 Layer.mergeAll(
140 NodeServices.layer,
141 makeHttpClientLayer(new TextEncoder().encode("test-cloudflared-binary")),
142 makeSpawnerLayer([]),
143 ),
144 ),
145 ),
146 );
147
148 it.effect("rejects downloads whose checksum does not match the pinned manifest", () =>
149 Effect.gen(function* () {
150 const fileSystem = yield* FileSystem.FileSystem;
151 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
152 prefix: "t3-cloudflared-test-",
153 });
154 const manager = yield* makeCloudflaredExecutable({
155 baseDir,
156 platform: "linux",
157 arch: "x64",
158 releaseAsset: {
159 url: "https://example.test/cloudflared",
160 sha256: Encoding.encodeHex(sha256(new TextEncoder().encode("expected"))),
161 archive: "binary",
162 },
163 configProvider: emptyConfigProvider,
164 });
165
166 const error = yield* manager.install.pipe(Effect.flip);
167 expect(error).toBeInstanceOf(CloudflaredInstallError);
168 expect(error.reason).toBe("invalid_checksum");
169 }).pipe(
170 Effect.scoped,
171 Effect.provide(
172 Layer.mergeAll(
173 NodeServices.layer,
174 makeHttpClientLayer(new TextEncoder().encode("tampered")),
175 makeSpawnerLayer([]),
176 ),
177 ),
178 ),
179 );
180
181 it.effect("serializes concurrent installs within one runtime", () => {
182 const commands: Array<string> = [];
183 const bytes = new TextEncoder().encode("test-cloudflared-binary");
184 return Effect.gen(function* () {
185 const fileSystem = yield* FileSystem.FileSystem;
186 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
187 prefix: "t3-cloudflared-test-",
188 });
189 const manager = yield* makeCloudflaredExecutable({
190 baseDir,
191 platform: "linux",
192 arch: "x64",
193 releaseAsset: {
194 url: "https://example.test/cloudflared",
195 sha256: Encoding.encodeHex(sha256(bytes)),
196 archive: "binary",
197 },
198 configProvider: emptyConfigProvider,
199 });
200
201 const [first, second] = yield* Effect.all([manager.install, manager.install], {
202 concurrency: "unbounded",
203 });
204 expect(second).toEqual(first);
205 expect(commands).toHaveLength(1);
206 }).pipe(
207 Effect.scoped,
208 Effect.provide(
209 Layer.mergeAll(NodeServices.layer, makeHttpClientLayer(bytes), makeSpawnerLayer(commands)),
210 ),
211 );
212 });
213
214 it.effect("observes PATH changes after the manager has been constructed", () =>
215 Effect.gen(function* () {
216 const fileSystem = yield* FileSystem.FileSystem;
217 const baseDir = yield* fileSystem.makeTempDirectoryScoped({
218 prefix: "t3-cloudflared-test-",
219 });
220 const binDir = `${baseDir}/bin`;
221 const executablePath = `${binDir}/cloudflared`;
222 let path = "";
223 const manager = yield* makeCloudflaredExecutable({
224 baseDir,
225 platform: "linux",
226 arch: "x64",
227 configProvider: () => ConfigProvider.fromEnv({ env: { PATH: path } }),
228 });
229
230 expect(yield* manager.resolve).toEqual({
231 status: "missing",
232 version: CLOUDFLARED_VERSION,
233 });
234
235 yield* fileSystem.makeDirectory(binDir);
236 yield* fileSystem.writeFileString(executablePath, "cloudflared");
237 yield* fileSystem.chmod(executablePath, 0o755);
238 path = binDir;
239
240 expect(yield* manager.resolve).toEqual({
241 status: "available",
242 executablePath,
243 source: "path",
244 version: CLOUDFLARED_VERSION,
245 });
246 }).pipe(
247 Effect.scoped,
248 Effect.provide(
249 Layer.mergeAll(
250 NodeServices.layer,
251 makeHttpClientLayer(new Uint8Array()),
252 makeSpawnerLayer([]),
253 ),
254 ),
255 ),
256 );
257});
packages/shared/src/cloudflared.ts+477−0
@@ -0,0 +1,477 @@
1import * as Clock from "effect/Clock";
2import * as Config from "effect/Config";
3import * as ConfigProvider from "effect/ConfigProvider";
4import * as Context from "effect/Context";
5import * as Crypto from "effect/Crypto";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Encoding from "effect/Encoding";
9import * as FileSystem from "effect/FileSystem";
10import * as Layer from "effect/Layer";
11import * as Option from "effect/Option";
12import * as Path from "effect/Path";
13import * as PlatformError from "effect/PlatformError";
14import * as Semaphore from "effect/Semaphore";
15import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
16import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
17
18export const CLOUDFLARED_VERSION = "2026.5.2";
19export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH";
20
21export type CloudflaredExecutableSource = "override" | "managed" | "path";
22
23export type CloudflaredExecutableStatus =
24 | {
25 readonly status: "available";
26 readonly executablePath: string;
27 readonly source: CloudflaredExecutableSource;
28 readonly version: string;
29 }
30 | {
31 readonly status: "missing";
32 readonly version: string;
33 }
34 | {
35 readonly status: "unsupported";
36 readonly platform: NodeJS.Platform;
37 readonly arch: string;
38 readonly version: string;
39 };
40
41export type AvailableCloudflaredExecutable = Extract<
42 CloudflaredExecutableStatus,
43 { readonly status: "available" }
44>;
45
46export class CloudflaredInstallError extends Data.TaggedError("CloudflaredInstallError")<{
47 readonly reason:
48 | "download_failed"
49 | "invalid_checksum"
50 | "install_locked"
51 | "override_missing"
52 | "unsupported_platform"
53 | "validation_failed"
54 | "write_failed";
55 readonly message: string;
56 readonly cause?: unknown;
57}> {}
58
59class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{
60 readonly command: string;
61 readonly exitCode: number;
62}> {}
63
64export interface CloudflaredReleaseAsset {
65 readonly url: string;
66 readonly sha256: string;
67 readonly archive: "binary" | "tgz";
68}
69
70const CLOUDFLARED_RELEASE_ASSETS: Readonly<
71 Partial<Record<`${NodeJS.Platform}-${string}`, CloudflaredReleaseAsset>>
72> = {
73 "darwin-arm64": {
74 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-arm64.tgz",
75 sha256: "ba94054c9fd4297645093d59d51442e5e546d07bb0516120e694a13d5b216d38",
76 archive: "tgz",
77 },
78 "darwin-x64": {
79 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-darwin-amd64.tgz",
80 sha256: "7240f709506bc2c1eb9da4d89cf2555499c60280ecb854b7d80e8f17d4b7903d",
81 archive: "tgz",
82 },
83 "linux-arm64": {
84 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-arm64",
85 sha256: "5a4e8ce2701105271412059f44b6a0bf1ae4542b4d98ff3180c0c019443a5815",
86 archive: "binary",
87 },
88 "linux-x64": {
89 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-linux-amd64",
90 sha256: "5286698547f03df745adb2355f04c12dde52ef425491e81f433642d695521886",
91 archive: "binary",
92 },
93 "win32-x64": {
94 url: "https://github.com/cloudflare/cloudflared/releases/download/2026.5.2/cloudflared-windows-amd64.exe",
95 sha256: "20b9638f685333d623798e733effbad2487093f15ba592f6c7752360ff3b7ab7",
96 archive: "binary",
97 },
98};
99
100const INSTALL_LOCK_RETRY_COUNT = 100;
101const INSTALL_LOCK_RETRY_DELAY = "100 millis";
102const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000;
103
104const trimmedString = (name: string) =>
105 Config.string(name).pipe(
106 Config.option,
107 Config.map(
108 Option.flatMap((value) => {
109 const trimmed = value.trim();
110 return trimmed.length > 0 ? Option.some(trimmed) : Option.none();
111 }),
112 ),
113 );
114
115const CloudflaredConfig = Config.all({
116 executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME),
117 path: trimmedString("PATH"),
118});
119
120export interface CloudflaredExecutableOptions {
121 readonly baseDir: string;
122 readonly platform?: NodeJS.Platform;
123 readonly arch?: string;
124 readonly releaseAsset?: CloudflaredReleaseAsset;
125 readonly configProvider?: () => ConfigProvider.ConfigProvider;
126}
127
128export interface CloudflaredExecutableShape {
129 readonly resolve: Effect.Effect<CloudflaredExecutableStatus>;
130 readonly install: Effect.Effect<AvailableCloudflaredExecutable, CloudflaredInstallError>;
131}
132
133export class CloudflaredExecutable extends Context.Service<
134 CloudflaredExecutable,
135 CloudflaredExecutableShape
136>()("@t3tools/shared/cloudflared/CloudflaredExecutable") {}
137
138function executableFileName(platform: NodeJS.Platform): string {
139 return platform === "win32" ? "cloudflared.exe" : "cloudflared";
140}
141
142export function resolveManagedCloudflaredPath(input: {
143 readonly baseDir: string;
144 readonly platform: NodeJS.Platform;
145 readonly arch: string;
146}): string {
147 const separator = input.platform === "win32" ? "\\" : "/";
148 return [
149 input.baseDir.replace(/[\\/]+$/u, ""),
150 "tools",
151 "cloudflared",
152 CLOUDFLARED_VERSION,
153 `${input.platform}-${input.arch}`,
154 executableFileName(input.platform),
155 ].join(separator);
156}
157
158function resolveReleaseAsset(
159 platform: NodeJS.Platform,
160 arch: string,
161): CloudflaredReleaseAsset | null {
162 return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null;
163}
164
165function isAlreadyExists(error: PlatformError.PlatformError): boolean {
166 return error.reason._tag === "AlreadyExists";
167}
168
169const wrapInstallFailure =
170 (
171 reason: CloudflaredInstallError["reason"],
172 message: string,
173 ): (<E, R>(
174 effect: Effect.Effect<void, E, R>,
175 ) => Effect.Effect<void, CloudflaredInstallError, R>) =>
176 (effect) =>
177 effect.pipe(
178 Effect.mapError(
179 (cause) =>
180 new CloudflaredInstallError({
181 reason,
182 message,
183 cause,
184 }),
185 ),
186 );
187
188export const makeCloudflaredExecutable = Effect.fn("cloudflared.make")(function* (
189 options: CloudflaredExecutableOptions,
190): Effect.fn.Return<
191 CloudflaredExecutableShape,
192 never,
193 | ChildProcessSpawner.ChildProcessSpawner
194 | Crypto.Crypto
195 | FileSystem.FileSystem
196 | HttpClient.HttpClient
197 | Path.Path
198> {
199 const crypto = yield* Crypto.Crypto;
200 const fileSystem = yield* FileSystem.FileSystem;
201 const httpClient = yield* HttpClient.HttpClient;
202 const path = yield* Path.Path;
203 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
204 const installSemaphore = yield* Semaphore.make(1);
205 const platform = options.platform ?? process.platform;
206 const arch = options.arch ?? process.arch;
207 const releaseAsset = options.releaseAsset ?? resolveReleaseAsset(platform, arch);
208 const loadCloudflaredConfig = Effect.suspend(() =>
209 CloudflaredConfig.pipe(
210 Effect.provideService(
211 ConfigProvider.ConfigProvider,
212 options.configProvider?.() ?? ConfigProvider.fromEnv(),
213 ),
214 ),
215 ).pipe(Effect.orDie);
216 const managedPath = path.join(
217 options.baseDir,
218 "tools",
219 "cloudflared",
220 CLOUDFLARED_VERSION,
221 `${platform}-${arch}`,
222 executableFileName(platform),
223 );
224
225 const isExecutableFile = Effect.fn("cloudflared.isExecutableFile")(function* (
226 executablePath: string,
227 ) {
228 const info = yield* fileSystem.stat(executablePath).pipe(Effect.option);
229 if (Option.isNone(info) || info.value.type !== "File") return false;
230 return platform === "win32" || (info.value.mode & 0o111) !== 0;
231 });
232
233 const resolvePathExecutable = Effect.gen(function* () {
234 const config = yield* loadCloudflaredConfig;
235 const pathValue = Option.getOrUndefined(config.path);
236 if (!pathValue) return null;
237 const delimiter = platform === "win32" ? ";" : ":";
238 for (const directory of pathValue.split(delimiter)) {
239 const trimmed = directory.trim().replace(/^"|"$/gu, "");
240 if (trimmed.length === 0) continue;
241 const candidate = path.join(trimmed, executableFileName(platform));
242 if (yield* isExecutableFile(candidate)) return candidate;
243 }
244 return null;
245 });
246
247 const resolve: CloudflaredExecutableShape["resolve"] = Effect.gen(function* () {
248 const config = yield* loadCloudflaredConfig;
249 if (Option.isSome(config.executableOverride)) {
250 return (yield* isExecutableFile(config.executableOverride.value))
251 ? {
252 status: "available",
253 executablePath: config.executableOverride.value,
254 source: "override",
255 version: CLOUDFLARED_VERSION,
256 }
257 : { status: "missing", version: CLOUDFLARED_VERSION };
258 }
259 if (yield* isExecutableFile(managedPath)) {
260 return {
261 status: "available",
262 executablePath: managedPath,
263 source: "managed",
264 version: CLOUDFLARED_VERSION,
265 };
266 }
267 const pathExecutable = yield* resolvePathExecutable;
268 if (pathExecutable) {
269 return {
270 status: "available",
271 executablePath: pathExecutable,
272 source: "path",
273 version: CLOUDFLARED_VERSION,
274 };
275 }
276 return releaseAsset
277 ? { status: "missing", version: CLOUDFLARED_VERSION }
278 : {
279 status: "unsupported",
280 platform,
281 arch,
282 version: CLOUDFLARED_VERSION,
283 };
284 });
285
286 const runCommand = Effect.fn("cloudflared.runCommand")(function* (
287 command: string,
288 args: ReadonlyArray<string>,
289 ) {
290 const child = yield* spawner.spawn(
291 ChildProcess.make(command, args, {
292 shell: platform === "win32",
293 stdout: "ignore",
294 stderr: "ignore",
295 }),
296 );
297 const exitCode = Number(yield* child.exitCode);
298 if (exitCode !== 0) {
299 return yield* new CloudflaredCommandError({ command, exitCode });
300 }
301 });
302
303 const downloadAsset = Effect.fn("cloudflared.downloadAsset")(function* (
304 asset: CloudflaredReleaseAsset,
305 ) {
306 const response = yield* httpClient.execute(HttpClientRequest.get(asset.url)).pipe(
307 Effect.flatMap(HttpClientResponse.filterStatusOk),
308 Effect.mapError(
309 (cause) =>
310 new CloudflaredInstallError({
311 reason: "download_failed",
312 message: "Could not download cloudflared.",
313 cause,
314 }),
315 ),
316 );
317 const bytes = new Uint8Array(
318 yield* response.arrayBuffer.pipe(
319 Effect.mapError(
320 (cause) =>
321 new CloudflaredInstallError({
322 reason: "download_failed",
323 message: "Could not read the downloaded cloudflared binary.",
324 cause,
325 }),
326 ),
327 ),
328 );
329 const checksum = yield* crypto.digest("SHA-256", bytes).pipe(
330 Effect.mapError(
331 (cause) =>
332 new CloudflaredInstallError({
333 reason: "validation_failed",
334 message: "Could not verify the downloaded cloudflared checksum.",
335 cause,
336 }),
337 ),
338 );
339 if (Encoding.encodeHex(checksum) !== asset.sha256) {
340 return yield* new CloudflaredInstallError({
341 reason: "invalid_checksum",
342 message: "Downloaded cloudflared checksum did not match the pinned release.",
343 });
344 }
345 return bytes;
346 });
347
348 const acquireInstallLock = Effect.fn("cloudflared.acquireInstallLock")(function* (
349 lockPath: string,
350 ) {
351 for (let attempt = 0; attempt < INSTALL_LOCK_RETRY_COUNT; attempt += 1) {
352 const acquired = yield* fileSystem.writeFileString(lockPath, "", { flag: "wx" }).pipe(
353 Effect.as(true),
354 Effect.catch((error) =>
355 isAlreadyExists(error) ? Effect.succeed(false) : Effect.fail(error),
356 ),
357 );
358 if (acquired) return;
359
360 const now = yield* Clock.currentTimeMillis;
361 const lockInfo = yield* fileSystem.stat(lockPath).pipe(Effect.option);
362 const mtime = Option.flatMap(lockInfo, (info) => info.mtime);
363 if (Option.isSome(mtime) && now - mtime.value.getTime() > INSTALL_LOCK_STALE_MS) {
364 yield* fileSystem.remove(lockPath, { force: true });
365 continue;
366 }
367 yield* Effect.sleep(INSTALL_LOCK_RETRY_DELAY);
368 }
369 return yield* new CloudflaredInstallError({
370 reason: "install_locked",
371 message: "Another cloudflared installation is still in progress.",
372 });
373 });
374
375 const installUnlocked: CloudflaredExecutableShape["install"] = Effect.gen(function* () {
376 const existing = yield* resolve;
377 if (existing.status === "available") return existing;
378 const config = yield* loadCloudflaredConfig;
379 if (Option.isSome(config.executableOverride)) {
380 return yield* new CloudflaredInstallError({
381 reason: "override_missing",
382 message: `${CLOUDFLARED_PATH_ENV_NAME} does not point to an executable file.`,
383 });
384 }
385 if (!releaseAsset) {
386 return yield* new CloudflaredInstallError({
387 reason: "unsupported_platform",
388 message: `T3 Code does not provide a managed cloudflared binary for ${platform}-${arch}.`,
389 });
390 }
391
392 const managedDirectory = path.dirname(managedPath);
393 const lockPath = `${managedPath}.lock`;
394 yield* fileSystem
395 .makeDirectory(managedDirectory, { recursive: true })
396 .pipe(wrapInstallFailure("write_failed", "Could not create the cloudflared tool directory."));
397 yield* acquireInstallLock(lockPath).pipe(
398 Effect.catchTag("PlatformError", (cause) =>
399 Effect.fail(
400 new CloudflaredInstallError({
401 reason: "write_failed",
402 message: "Could not acquire the cloudflared installation lock.",
403 cause,
404 }),
405 ),
406 ),
407 );
408 return yield* Effect.gen(function* () {
409 const afterLock = yield* resolve;
410 if (afterLock.status === "available") return afterLock;
411
412 const tempDirectory = yield* fileSystem.makeTempDirectoryScoped({
413 directory: managedDirectory,
414 prefix: ".install-",
415 });
416 const archivePath = path.join(
417 tempDirectory,
418 releaseAsset.archive === "tgz" ? "cloudflared.tgz" : executableFileName(platform),
419 );
420 yield* fileSystem
421 .writeFile(archivePath, yield* downloadAsset(releaseAsset))
422 .pipe(wrapInstallFailure("write_failed", "Could not write the cloudflared download."));
423
424 const executablePath = path.join(tempDirectory, executableFileName(platform));
425 if (releaseAsset.archive === "tgz") {
426 yield* runCommand("tar", ["-xzf", archivePath, "-C", tempDirectory]).pipe(
427 wrapInstallFailure("write_failed", "Could not extract cloudflared."),
428 );
429 }
430 if (platform !== "win32") {
431 yield* fileSystem
432 .chmod(executablePath, 0o755)
433 .pipe(wrapInstallFailure("write_failed", "Could not make cloudflared executable."));
434 }
435 yield* runCommand(executablePath, ["--version"]).pipe(
436 wrapInstallFailure("validation_failed", "The downloaded cloudflared binary did not run."),
437 );
438
439 const stagedPath = `${managedPath}.${yield* crypto.randomUUIDv4}.tmp`;
440 yield* fileSystem
441 .rename(executablePath, stagedPath)
442 .pipe(wrapInstallFailure("write_failed", "Could not stage cloudflared."));
443 yield* fileSystem
444 .rename(stagedPath, managedPath)
445 .pipe(
446 wrapInstallFailure("write_failed", "Could not activate cloudflared."),
447 Effect.ensuring(fileSystem.remove(stagedPath, { force: true }).pipe(Effect.ignore)),
448 );
449 return {
450 status: "available",
451 executablePath: managedPath,
452 source: "managed",
453 version: CLOUDFLARED_VERSION,
454 } satisfies AvailableCloudflaredExecutable;
455 }).pipe(
456 Effect.scoped,
457 Effect.ensuring(fileSystem.remove(lockPath, { force: true }).pipe(Effect.ignore)),
458 Effect.catch((cause) =>
459 cause instanceof CloudflaredInstallError
460 ? Effect.fail(cause)
461 : Effect.fail(
462 new CloudflaredInstallError({
463 reason: "write_failed",
464 message: "Could not install cloudflared.",
465 cause,
466 }),
467 ),
468 ),
469 );
470 });
471 const install = installSemaphore.withPermit(installUnlocked);
472
473 return CloudflaredExecutable.of({ resolve, install });
474});
475
476export const layer = (options: CloudflaredExecutableOptions) =>
477 Layer.effect(CloudflaredExecutable, makeCloudflaredExecutable(options));
packages/shared/src/relayAuth.ts+9−0
@@ -1,3 +1,12 @@
1export const DEFAULT_T3_RELAY_DOMAIN = "t3code-relay.ineededadomain.com";
2
3export const DEFAULT_T3_RELAY_URL = `https://${DEFAULT_T3_RELAY_DOMAIN}`;
4
5export const DEFAULT_T3_RELAY_ZONE_NAME = "ineededadomain.com";
6
7export const DEFAULT_T3_CLERK_PUBLISHABLE_KEY =
8 "pk_test_YXdhaXRlZC1tb25rZmlzaC01OC5jbGVyay5hY2NvdW50cy5kZXYk";
9
1export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay";10export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay";
2 11
3export const RELAY_CLERK_TOKEN_OPTIONS = {12export const RELAY_CLERK_TOKEN_OPTIONS = {
scripts/dev-runner.ts+4−0
@@ -17,6 +17,10 @@ import * as Schema from "effect/Schema"; · 16 unmodified lines
17import { Argument, Command, Flag } from "effect/unstable/cli";17import { Argument, Command, Flag } from "effect/unstable/cli";
18import { ChildProcess } from "effect/unstable/process";18import { ChildProcess } from "effect/unstable/process";
19 19
20import { loadRepoEnv } from "./lib/public-config.ts";
21
22Object.assign(process.env, loadRepoEnv());
23
20const BASE_SERVER_PORT = 13773;24const BASE_SERVER_PORT = 13773;
21const BASE_WEB_PORT = 5733;25const BASE_WEB_PORT = 5733;
22const MAX_HASH_OFFSET = 3000;26const MAX_HASH_OFFSET = 3000;
scripts/lib/public-config.test.ts+74−0
@@ -0,0 +1,74 @@
1// @effect-diagnostics nodeBuiltinImport:off - Tests exercise root env file precedence directly.
2import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
3import { tmpdir } from "node:os";
4import { join } from "node:path";
5import { afterEach, describe, expect, it } from "vitest";
6
7import { DEFAULT_PUBLIC_CONFIG, loadRepoEnv, resolvePublicConfig } from "./public-config.ts";
8
9const temporaryDirectories: string[] = [];
10
11afterEach(() => {
12 for (const directory of temporaryDirectories.splice(0)) {
13 rmSync(directory, { recursive: true, force: true });
14 }
15});
16
17describe("loadRepoEnv", () => {
18 it("projects checked-in public defaults into Vite and Expo aliases", () => {
19 const env = loadRepoEnv({ baseEnv: {}, repoRoot: makeTemporaryDirectory() });
20
21 expect(env.T3CODE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey);
22 expect(env.VITE_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey);
23 expect(env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY).toBe(DEFAULT_PUBLIC_CONFIG.clerkPublishableKey);
24 expect(env.T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl);
25 expect(env.VITE_T3_RELAY_URL).toBe(DEFAULT_PUBLIC_CONFIG.relayUrl);
26 });
27
28 it("applies process, root local, root, and checked-in precedence in that order", () => {
29 const repoRoot = makeTemporaryDirectory();
30 writeFileSync(
31 join(repoRoot, ".env"),
32 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_root\nT3_RELAY_URL=https://root.example.test\n",
33 );
34 writeFileSync(
35 join(repoRoot, ".env.local"),
36 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_local\nT3_RELAY_URL=https://local.example.test\n",
37 );
38
39 expect(loadRepoEnv({ baseEnv: {}, repoRoot }).T3_RELAY_URL).toBe("https://local.example.test");
40 expect(
41 loadRepoEnv({
42 baseEnv: {
43 T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci",
44 T3_RELAY_URL: "https://ci.example.test",
45 },
46 repoRoot,
47 }),
48 ).toMatchObject({
49 T3CODE_CLERK_PUBLISHABLE_KEY: "pk_ci",
50 VITE_CLERK_PUBLISHABLE_KEY: "pk_ci",
51 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: "pk_ci",
52 T3_RELAY_URL: "https://ci.example.test",
53 VITE_T3_RELAY_URL: "https://ci.example.test",
54 });
55 });
56
57 it("accepts legacy framework aliases as root overrides", () => {
58 expect(
59 resolvePublicConfig({
60 VITE_CLERK_PUBLISHABLE_KEY: "pk_legacy",
61 VITE_T3_RELAY_URL: "https://legacy.example.test",
62 }),
63 ).toEqual({
64 clerkPublishableKey: "pk_legacy",
65 relayUrl: "https://legacy.example.test",
66 });
67 });
68});
69
70function makeTemporaryDirectory() {
71 const directory = mkdtempSync(join(tmpdir(), "t3code-public-config-"));
72 temporaryDirectories.push(directory);
73 return directory;
74}
scripts/lib/public-config.ts+78−0
@@ -0,0 +1,78 @@
1// @effect-diagnostics nodeBuiltinImport:off - Build bootstrap reads optional root env files before an Effect runtime exists.
2import * as NodeFS from "node:fs";
3import * as NodePath from "node:path";
4import * as NodeURL from "node:url";
5import * as NodeUtil from "node:util";
6
7import { DEFAULT_T3_CLERK_PUBLISHABLE_KEY, DEFAULT_T3_RELAY_URL } from "@t3tools/shared/relayAuth";
8
9export interface T3CodePublicConfig {
10 readonly clerkPublishableKey: string;
11 readonly relayUrl: string;
12}
13
14type Environment = Readonly<Record<string, string | undefined>>;
15
16const REPO_ROOT = NodePath.dirname(
17 NodePath.dirname(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url))),
18);
19
20// These values are intentionally public. Client builds embed them so a fresh clone can use
21// T3 Cloud without local setup. Use root .env files or CI environment variables to override them.
22export const DEFAULT_PUBLIC_CONFIG: T3CodePublicConfig = {
23 clerkPublishableKey: DEFAULT_T3_CLERK_PUBLISHABLE_KEY,
24 relayUrl: DEFAULT_T3_RELAY_URL,
25};
26
27export function loadRepoEnv({
28 baseEnv = process.env,
29 repoRoot = REPO_ROOT,
30}: {
31 readonly baseEnv?: Environment;
32 readonly repoRoot?: string;
33} = {}): Record<string, string | undefined> {
34 const rootEnv = readEnvFile(NodePath.join(repoRoot, ".env"));
35 const localEnv = readEnvFile(NodePath.join(repoRoot, ".env.local"));
36 const config = resolvePublicConfig(baseEnv, localEnv, rootEnv);
37
38 return {
39 ...rootEnv,
40 ...localEnv,
41 ...baseEnv,
42 T3CODE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey,
43 T3_RELAY_URL: config.relayUrl,
44 VITE_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey,
45 VITE_T3_RELAY_URL: config.relayUrl,
46 EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY: config.clerkPublishableKey,
47 };
48}
49
50export function resolvePublicConfig(...sources: readonly Environment[]): T3CodePublicConfig {
51 return {
52 clerkPublishableKey:
53 firstNonEmpty(
54 sources,
55 "T3CODE_CLERK_PUBLISHABLE_KEY",
56 "VITE_CLERK_PUBLISHABLE_KEY",
57 "EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY",
58 ) ?? DEFAULT_PUBLIC_CONFIG.clerkPublishableKey,
59 relayUrl:
60 firstNonEmpty(sources, "T3_RELAY_URL", "VITE_T3_RELAY_URL") ?? DEFAULT_PUBLIC_CONFIG.relayUrl,
61 };
62}
63
64function firstNonEmpty(sources: readonly Environment[], ...names: readonly string[]) {
65 for (const source of sources) {
66 for (const name of names) {
67 const value = source[name]?.trim();
68 if (value) {
69 return value;
70 }
71 }
72 }
73 return undefined;
74}
75
76function readEnvFile(path: string): Record<string, string | undefined> {
77 return NodeFS.existsSync(path) ? NodeUtil.parseEnv(NodeFS.readFileSync(path, "utf8")) : {};
78}

Keyboard shortcuts

General

Show keyboard shortcuts
?
Focus the current search
/

Issues and pull requests

Open filters
f

Issues, pull requests and Inbox

Select next item
j↓
Select previous item
k↑
Open selected item
Enter
Dismiss filters or clear selection
Esc

Repository code

Go to file
t