Unified · Split
| @@ -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 | ||
| @@ -26,6 +26,10 @@ permissions: · 25 unmodified lines | |||
| 26 | contents: read | 26 | contents: read |
| 27 | id-token: none | 27 | id-token: none |
| 28 | 28 | ||
| 29 | env: | ||
| 30 | T3CODE_CLERK_PUBLISHABLE_KEY: ${{ vars.T3CODE_CLERK_PUBLISHABLE_KEY }} | ||
| 31 | T3_RELAY_URL: ${{ vars.T3_RELAY_URL }} | ||
| 32 | |||
| 29 | jobs: | 33 | jobs: |
| 30 | check_changes: | 34 | check_changes: |
| 31 | name: Check for changes since last nightly | 35 | 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 | )" |
| @@ -62,6 +62,11 @@ mise install · 61 unmodified lines | |||
| 62 | vp install | 62 | vp install |
| 63 | ``` | 63 | ``` |
| 64 | 64 | ||
| 65 | T3 Cloud public client configuration has checked-in development defaults, so a fresh clone works | ||
| 66 | without creating app-local `.env` files. To point web, desktop, and mobile at another Clerk/relay | ||
| 67 | deployment, copy [`.env.example`](./.env.example) to `.env` at the repository root and set the | ||
| 68 | canonical overrides there. | ||
| 69 | |||
| 65 | Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. | 70 | Read [CONTRIBUTING.md](./CONTRIBUTING.md) before opening an issue or PR. |
| 66 | 71 | ||
| 67 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). | 72 | Need support? Join the [Discord](https://discord.gg/jn4EGJjrvv). |
| @@ -9,6 +9,7 @@ import { · 8 unmodified lines | |||
| 9 | setCloudAuthToken, | 9 | setCloudAuthToken, |
| 10 | } from "./methods/cloudAuth.ts"; | 10 | } from "./methods/cloudAuth.ts"; |
| 11 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; | 11 | import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; |
| 12 | import { getCloudflaredStatus, installCloudflared } from "./methods/cloudflared.ts"; | ||
| 12 | import { | 13 | import { |
| 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); |
| @@ -8,6 +8,8 @@ export const GET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:get-cloud-auth-token"; · 7 unmodified lines | |||
| 8 | export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token"; | 8 | export const SET_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:set-cloud-auth-token"; |
| 9 | export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token"; | 9 | export const CLEAR_CLOUD_AUTH_TOKEN_CHANNEL = "desktop:clear-cloud-auth-token"; |
| 10 | export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth"; | 10 | export const FETCH_CLOUD_AUTH_CHANNEL = "desktop:fetch-cloud-auth"; |
| 11 | export const GET_CLOUDFLARED_STATUS_CHANNEL = "desktop:get-cloudflared-status"; | ||
| 12 | export const INSTALL_CLOUDFLARED_CHANNEL = "desktop:install-cloudflared"; | ||
| 11 | export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback"; | 13 | export const CLOUD_AUTH_CALLBACK_CHANNEL = "desktop:cloud-auth-callback"; |
| 12 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; | 14 | export const MENU_ACTION_CHANNEL = "desktop:menu-action"; |
| 13 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; | 15 | export const UPDATE_STATE_CHANNEL = "desktop:update-state"; |
| @@ -0,0 +1,55 @@ | |||
| 1 | import { assert, describe, it } from "@effect/vitest"; | ||
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Layer from "effect/Layer"; | ||
| 4 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 5 | |||
| 6 | import { fetchCloudAuth } from "./cloudAuth.ts"; | ||
| 7 | |||
| 8 | function 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 | |||
| 19 | describe("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 | }); | ||
| @@ -6,7 +6,7 @@ import * as Data from "effect/Data"; · 5 unmodified lines | |||
| 6 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 7 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 8 | import * as Schema from "effect/Schema"; | 8 | import * as Schema from "effect/Schema"; |
| 9 | import { HttpClient, HttpClientRequest } from "effect/unstable/http"; | 9 | import { Headers, HttpClient, HttpClientRequest } from "effect/unstable/http"; |
| 10 | 10 | ||
| 11 | import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; | 11 | import * as DesktopCloudAuth from "../../app/DesktopCloudAuth.ts"; |
| 12 | import * as DesktopCloudAuthTokenStore from "../../app/DesktopCloudAuthTokenStore.ts"; | 12 | import * 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( |
| @@ -0,0 +1,43 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import * as Cloudflared from "@t3tools/shared/cloudflared"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Layer from "effect/Layer"; | ||
| 5 | |||
| 6 | import { getCloudflaredStatus, installCloudflared } from "./cloudflared.ts"; | ||
| 7 | |||
| 8 | const available = { | ||
| 9 | status: "available", | ||
| 10 | executablePath: "/Users/test/.t3/tools/cloudflared/cloudflared", | ||
| 11 | source: "managed", | ||
| 12 | version: Cloudflared.CLOUDFLARED_VERSION, | ||
| 13 | } as const; | ||
| 14 | |||
| 15 | describe("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 | }); | ||
| @@ -0,0 +1,27 @@ | |||
| 1 | import { DesktopCloudflaredStatusSchema } from "@t3tools/contracts"; | ||
| 2 | import * as Cloudflared from "@t3tools/shared/cloudflared"; | ||
| 3 | import * as Effect from "effect/Effect"; | ||
| 4 | import * as Schema from "effect/Schema"; | ||
| 5 | |||
| 6 | import * as IpcChannels from "../channels.ts"; | ||
| 7 | import { makeIpcMethod } from "../DesktopIpc.ts"; | ||
| 8 | |||
| 9 | export 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 | |||
| 19 | export 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 | }); | ||
| @@ -9,6 +9,7 @@ import * as Option from "effect/Option"; · 8 unmodified lines | |||
| 9 | import * as Electron from "electron"; | 9 | import * as Electron from "electron"; |
| 10 | 10 | ||
| 11 | import * as NetService from "@t3tools/shared/Net"; | 11 | import * as NetService from "@t3tools/shared/Net"; |
| 12 | import * as Cloudflared from "@t3tools/shared/cloudflared"; | ||
| 12 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; | 13 | import { resolveRemoteT3CliPackageSpec } from "@t3tools/ssh/command"; |
| 13 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; | 14 | import type { RemoteT3RunnerOptions } from "@t3tools/ssh/tunnel"; |
| 14 | import serverPackageJson from "../../server/package.json" with { type: "json" }; | 15 | import 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 | ||
| 98 | const 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 | |||
| 97 | const electronLayer = Layer.mergeAll( | 109 | const 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 | ||
| 146 | const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( | 162 | const desktopRuntimeLayer = ElectronProtocol.layerSchemePrivileges.pipe( |
| 147 | Layer.flatMap(() => | 163 | Layer.flatMap(() => |
| @@ -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; |
| @@ -16,6 +16,10 @@ This app has three variants: · 15 unmodified lines | |||
| 16 | 16 | ||
| 17 | Run commands from `apps/mobile`. | 17 | Run commands from `apps/mobile`. |
| 18 | 18 | ||
| 19 | Public T3 Cloud development defaults are shared with web and desktop. Optional overrides belong in | ||
| 20 | the repository-root `.env` or `.env.local`, not an `apps/mobile/.env` file. See | ||
| 21 | [`../../.env.example`](../../.env.example). | ||
| 22 | |||
| 19 | ## Development | 23 | ## Development |
| 20 | 24 | ||
| 21 | Start Metro for the dev client: | 25 | Start 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 | ||
| 64 | CI 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. | 68 | CI 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 | ||
| 70 | For preview or production EAS environments, set `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` | ||
| 71 | as EAS environment variables. Expo config maps the canonical values into the mobile build. | ||
| 72 | |||
| 66 | Create a PR preview dev-client build manually: | 73 | Create a PR preview dev-client build manually: |
| 67 | 74 | ||
| 68 | ```bash | 75 | ```bash |
| @@ -1,8 +1,13 @@ | |||
| 1 | import type { ExpoConfig } from "expo/config"; | 1 | import type { ExpoConfig } from "expo/config"; |
| 2 | 2 | ||
| 3 | import { loadRepoEnv } from "../../scripts/lib/public-config"; | ||
| 4 | |||
| 3 | type AppVariant = "development" | "preview" | "production"; | 5 | type AppVariant = "development" | "preview" | "production"; |
| 4 | 6 | ||
| 5 | const APP_VARIANT = resolveAppVariant(process.env.APP_VARIANT); | 7 | const repoEnv = loadRepoEnv(); |
| 8 | Object.assign(process.env, repoEnv); | ||
| 9 | |||
| 10 | const APP_VARIANT = resolveAppVariant(repoEnv.APP_VARIANT); | ||
| 6 | 11 | ||
| 7 | const VARIANT_CONFIG: Record< | 12 | const 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", |
| @@ -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 | )} |
| @@ -1,14 +1,36 @@ | |||
| 1 | import { describe, expect, it } from "@effect/vitest"; | 1 | import { describe, expect, it } from "@effect/vitest"; |
| 2 | import { vi } from "vitest"; | ||
| 2 | import * as Deferred from "effect/Deferred"; | 3 | import * as Deferred from "effect/Deferred"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 4 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 5 | import * as PlatformError from "effect/PlatformError"; | 7 | import * as PlatformError from "effect/PlatformError"; |
| 6 | import * as Sink from "effect/Sink"; | 8 | import * as Sink from "effect/Sink"; |
| 7 | import * as Stream from "effect/Stream"; | 9 | import * as Stream from "effect/Stream"; |
| 8 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 10 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 11 | import * as Cloudflared from "@t3tools/shared/cloudflared"; | ||
| 9 | 12 | ||
| 10 | import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; | 13 | import { makeCloudManagedEndpointRuntime } from "./ManagedEndpointRuntime.ts"; |
| 11 | 14 | ||
| 15 | const 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 | |||
| 28 | const runtimeDependencies = (spawner: ReturnType<typeof ChildProcessSpawner.make>) => | ||
| 29 | Layer.mergeAll( | ||
| 30 | Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), | ||
| 31 | cloudflaredAvailableLayer, | ||
| 32 | ); | ||
| 33 | |||
| 12 | function makeHandle(input: { | 34 | function 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 | }); |
| @@ -1,10 +1,13 @@ | |||
| 1 | import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; | 1 | import type { RelayManagedEndpointRuntimeConfig } from "@t3tools/contracts/relay"; |
| 2 | import * as Cloudflared from "@t3tools/shared/cloudflared"; | ||
| 2 | import * as Context from "effect/Context"; | 3 | import * as Context from "effect/Context"; |
| 3 | import * as Effect from "effect/Effect"; | 4 | import * as Effect from "effect/Effect"; |
| 4 | import * as Exit from "effect/Exit"; | 5 | import * as Exit from "effect/Exit"; |
| 5 | import * as Layer from "effect/Layer"; | 6 | import * as Layer from "effect/Layer"; |
| 6 | import * as Option from "effect/Option"; | 7 | import * as Option from "effect/Option"; |
| 7 | import * as Ref from "effect/Ref"; | 8 | import * as Ref from "effect/Ref"; |
| 9 | import * as Result from "effect/Result"; | ||
| 10 | import * as Semaphore from "effect/Semaphore"; | ||
| 8 | import * as Scope from "effect/Scope"; | 11 | import * as Scope from "effect/Scope"; |
| 9 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; |
| 10 | 13 | ||
| @@ -88,8 +91,11 @@ const stopConnector = (connector: ActiveConnector | null) => · 77 unmodified lines | |||
| 88 | 91 | ||
| 89 | export const makeCloudManagedEndpointRuntime = Effect.gen(function* () { | 92 | export 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 config | 149 | 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* spawner | 188 | 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 | }); |
| @@ -81,6 +81,7 @@ import { · 80 unmodified lines | |||
| 81 | } from "./serverRuntimeState.ts"; | 81 | } from "./serverRuntimeState.ts"; |
| 82 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; | 82 | import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; |
| 83 | import * as NetService from "@t3tools/shared/Net"; | 83 | import * as NetService from "@t3tools/shared/Net"; |
| 84 | import * as Cloudflared from "@t3tools/shared/cloudflared"; | ||
| 84 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; | 85 | import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; |
| 85 | 86 | ||
| 86 | const PtyAdapterLive = Layer.unwrap( | 87 | const PtyAdapterLive = Layer.unwrap( |
| @@ -95,6 +96,13 @@ const PtyAdapterLive = Layer.unwrap( · 8 unmodified lines | |||
| 95 | }), | 96 | }), |
| 96 | ); | 97 | ); |
| 97 | 98 | ||
| 99 | const CloudflaredExecutableLive = Layer.unwrap( | ||
| 100 | Effect.gen(function* () { | ||
| 101 | const config = yield* ServerConfig; | ||
| 102 | return Cloudflared.layer({ baseDir: config.baseDir }); | ||
| 103 | }), | ||
| 104 | ); | ||
| 105 | |||
| 98 | const HttpServerLive = Layer.unwrap( | 106 | const 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 | ||
| @@ -1,6 +1,7 @@ | |||
| 1 | import { EnvironmentId } from "@t3tools/contracts"; | 1 | import { EnvironmentId } from "@t3tools/contracts"; |
| 2 | import { RelayWebClientId } from "@t3tools/contracts/relay"; | 2 | import { RelayWebClientId } from "@t3tools/contracts/relay"; |
| 3 | import { beforeEach, vi } from "vitest"; | 3 | import { afterEach, beforeEach, vi } from "vitest"; |
| 4 | import type { DesktopBridge } from "@t3tools/contracts"; | ||
| 4 | import { describe, expect, it } from "@effect/vitest"; | 5 | import { describe, expect, it } from "@effect/vitest"; |
| 5 | import * as Effect from "effect/Effect"; | 6 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; | 7 | import * as Layer from "effect/Layer"; |
| @@ -15,6 +16,7 @@ import { · 8 unmodified lines | |||
| 15 | import type { SavedEnvironmentRecord } from "../environments/runtime"; | 16 | import type { SavedEnvironmentRecord } from "../environments/runtime"; |
| 16 | import { | 17 | import { |
| 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 | ||
| 98 | describe("web cloud link environment client", () => { | 100 | describe("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( |
| @@ -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 | ||
| 58 | const desktopCloudflaredBridgeError = (cause: unknown) => | ||
| 59 | new CloudEnvironmentLinkError({ | ||
| 60 | message: "Could not prepare Cloudflare Tunnel.", | ||
| 61 | cause, | ||
| 62 | }); | ||
| 63 | |||
| 64 | export 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 | |||
| 58 | const isRelayProtectedError = Schema.is(RelayProtectedError); | 111 | const isRelayProtectedError = Schema.is(RelayProtectedError); |
| 59 | const isEnvironmentCloudApiError = Schema.is( | 112 | const 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* relayClient | 628 | const challenge = yield* relayClient |
| 575 | .createEnvironmentLinkChallenge({ | 629 | .createEnvironmentLinkChallenge({ |
| @@ -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 | <SettingsRow | 65 | <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> |
| @@ -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), |
| @@ -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 () => { |
| @@ -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 | } |
| @@ -8,9 +8,16 @@ import "vite-plus/test/config"; · 7 unmodified lines | |||
| 8 | import { defineConfig } from "vite-plus"; | 8 | import { defineConfig } from "vite-plus"; |
| 9 | import pkg from "./package.json" with { type: "json" }; | 9 | import pkg from "./package.json" with { type: "json" }; |
| 10 | 10 | ||
| 11 | import { loadRepoEnv } from "../../scripts/lib/public-config"; | ||
| 12 | |||
| 13 | const repoEnv = loadRepoEnv(); | ||
| 14 | Object.assign(process.env, repoEnv); | ||
| 15 | |||
| 11 | const port = Number(process.env.PORT ?? 5733); | 16 | const port = Number(process.env.PORT ?? 5733); |
| 12 | const host = process.env.HOST?.trim() || "localhost"; | 17 | const host = process.env.HOST?.trim() || "localhost"; |
| 13 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); | 18 | const configuredWsUrl = process.env.VITE_WS_URL?.trim(); |
| 19 | const configuredRelayUrl = repoEnv.VITE_T3_RELAY_URL?.trim() || ""; | ||
| 20 | const configuredClerkPublishableKey = repoEnv.VITE_CLERK_PUBLISHABLE_KEY?.trim() || ""; | ||
| 14 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; | 21 | const configuredHostedAppChannel = process.env.VITE_HOSTED_APP_CHANNEL?.trim() || ""; |
| 15 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; | 22 | const configuredAppVersion = process.env.APP_VERSION?.trim() || pkg.version; |
| 16 | const configuredHostedAppUrl = (() => { | 23 | const 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 lives | 131 | // 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), |
| @@ -6,15 +6,41 @@ audience. · 5 unmodified lines | |||
| 6 | 6 | ||
| 7 | ## Application Keys | 7 | ## Application Keys |
| 8 | 8 | ||
| 9 | Use keys from the same Clerk instance in each location: | 9 | Web, 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 | ||
| 11 | environment files. These values are safe to embed in client builds: the Clerk publishable key and | ||
| 12 | relay URL are public identifiers, not secrets. | ||
| 10 | 13 | ||
| 11 | | Consumer | Configuration | Value | | 14 | To 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 | ||
| 17 | Never put `CLERK_SECRET_KEY` in a client application environment. | 17 | ```dotenv |
| 18 | T3CODE_CLERK_PUBLISHABLE_KEY=<publishable key> | ||
| 19 | T3_RELAY_URL=https://relay.example.com | ||
| 20 | ``` | ||
| 21 | |||
| 22 | The 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 | ||
| 25 | compatibility, but new client configuration should use the canonical names. | ||
| 26 | |||
| 27 | Configuration precedence is: | ||
| 28 | |||
| 29 | 1. Process or CI environment variables. | ||
| 30 | 2. Repository-root `.env.local`. | ||
| 31 | 3. Repository-root `.env`. | ||
| 32 | 4. Checked-in public development defaults. | ||
| 33 | |||
| 34 | Release builds read `T3CODE_CLERK_PUBLISHABLE_KEY` and `T3_RELAY_URL` from GitHub Actions repository | ||
| 35 | variables. EAS preview and production builds should define the same client-facing values in their | ||
| 36 | EAS environment. | ||
| 37 | |||
| 38 | For a hosted relay deployment, copy `infra/relay/.env.example` to `infra/relay/.env`. The relay | ||
| 39 | deployment reads `T3_RELAY_DOMAIN` and `T3_RELAY_ZONE_NAME` through Effect `Config`, with the | ||
| 40 | checked-in shared values as defaults. `bun --cwd infra/relay run deploy` invokes Alchemy from the | ||
| 41 | relay directory, so Alchemy loads `infra/relay/.env`. The relay still requires `CLERK_SECRET_KEY` as | ||
| 42 | an Alchemy secret. Never put `CLERK_SECRET_KEY` in a client application environment or commit it to | ||
| 43 | the repository. | ||
| 18 | 44 | ||
| 19 | ## JWT Template | 45 | ## 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 | ||
| 28 | The `aud` value must be the deployed relay public URL, with no trailing slash, and must match | 54 | The `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. | 55 | client-facing `T3_RELAY_URL` and the HTTPS URL derived from the deployment's `T3_RELAY_DOMAIN`. If |
| 56 | the relay domain changes, update both values and the JWT template. | ||
| 30 | 57 | ||
| 31 | ## Desktop OAuth Redirect Allowlist | 58 | ## Desktop OAuth Redirect Allowlist |
| 32 | 59 | ||
| @@ -0,0 +1,12 @@ | |||
| 1 | # Relay deployment overrides. The public relay URL is derived as | ||
| 2 | # https://<T3_RELAY_DOMAIN>. | ||
| 3 | T3_RELAY_DOMAIN=relay.example.com | ||
| 4 | T3_RELAY_ZONE_NAME=example.com | ||
| 5 | |||
| 6 | # Relay runtime secrets and APNs configuration. | ||
| 7 | CLERK_SECRET_KEY=sk_test_... | ||
| 8 | APNS_ENVIRONMENT=sandbox | ||
| 9 | APNS_TEAM_ID=... | ||
| 10 | APNS_KEY_ID=... | ||
| 11 | APNS_BUNDLE_ID=... | ||
| 12 | APNS_PRIVATE_KEY=... | ||
| @@ -80,12 +80,13 @@ dependencies represented at their boundary rather than mocking internal behavior · 79 unmodified lines | |||
| 80 | The relay deploys through Alchemy: | 80 | The relay deploys through Alchemy: |
| 81 | 81 | ||
| 82 | ```sh | 82 | ```sh |
| 83 | cd infra/relay | 83 | bun --cwd infra/relay run deploy |
| 84 | bun run deploy | ||
| 85 | ``` | 84 | ``` |
| 86 | 85 | ||
| 87 | The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database | 86 | The stack provisions the Cloudflare Worker and queues, managed endpoint resources, database |
| 88 | connectivity, and relay tracing resources. Runtime secrets include Clerk and APNs credentials. | 87 | connectivity, 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 | ||
| 89 | file from the relay directory. Runtime secrets include Clerk and APNs credentials. | ||
| 89 | 90 | ||
| 90 | See: | 91 | See: |
| 91 | 92 | ||
| @@ -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 { |
| @@ -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"; |
| 32 | import { ManagedEndpointZone, RELAY_PUBLIC_DOMAIN, RELAY_PUBLIC_ORIGIN } from "./zone.ts"; | 32 | import { ManagedEndpointZone, RelayDeploymentConfig } from "./zone.ts"; |
| 33 | import { makeRelayTraceLayer, RelayObservability } from "./observability.ts"; | 33 | import { makeRelayTraceLayer, RelayObservability } from "./observability.ts"; |
| 34 | import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts"; | 34 | import * as DeliveryAttempts from "./agentActivity/DeliveryAttempts.ts"; |
| 35 | import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts"; | 35 | import * as AgentActivityRows from "./agentActivity/AgentActivityRows.ts"; |
| @@ -87,18 +87,22 @@ const ApnsDeliveryJobSigningSecret = Alchemy.makeRandom("ApnsDeliveryJobSigningS · 51 unmodified lines | |||
| 87 | 87 | ||
| 88 | export default class Api extends Cloudflare.Worker<Api>()( | 88 | export 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 use | 103 | // 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, |
| @@ -1,9 +1,26 @@ | |||
| 1 | import { adopt } from "alchemy/AdoptPolicy"; | 1 | import { adopt } from "alchemy/AdoptPolicy"; |
| 2 | import * as Cloudflare from "alchemy/Cloudflare"; | 2 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 3 | import * as Config from "effect/Config"; | ||
| 4 | import * as Effect from "effect/Effect"; | ||
| 3 | 5 | ||
| 4 | export const RELAY_PUBLIC_DOMAIN = "t3code-relay.ineededadomain.com"; | 6 | import { DEFAULT_T3_RELAY_DOMAIN, DEFAULT_T3_RELAY_ZONE_NAME } from "@t3tools/shared/relayAuth"; |
| 5 | export const RELAY_PUBLIC_ORIGIN = `https://${RELAY_PUBLIC_DOMAIN}`; | ||
| 6 | 7 | ||
| 7 | export const ManagedEndpointZone = Cloudflare.Zone("ManagedEndpointZone", { | 8 | export 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 | |||
| 23 | export const ManagedEndpointZone = RelayDeploymentConfig.pipe( | ||
| 24 | Config.map(({ managedEndpointZoneName }) => managedEndpointZoneName), | ||
| 25 | Effect.flatMap((name) => Cloudflare.Zone("ManagedEndpointZone", { name }).pipe(adopt(true))), | ||
| 26 | ); | ||
| @@ -394,6 +394,26 @@ export const DesktopCloudAuthFetchResultSchema = Schema.Struct({ · 393 unmodified lines | |||
| 394 | }); | 394 | }); |
| 395 | export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; | 395 | export type DesktopCloudAuthFetchResult = typeof DesktopCloudAuthFetchResultSchema.Type; |
| 396 | 396 | ||
| 397 | export 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 | ]); | ||
| 415 | export type DesktopCloudflaredStatus = typeof DesktopCloudflaredStatusSchema.Type; | ||
| 416 | |||
| 397 | export interface DesktopBridge { | 417 | export 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>; |
| @@ -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": { |
| @@ -0,0 +1,257 @@ | |||
| 1 | import { sha256 } from "@noble/hashes/sha2"; | ||
| 2 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 3 | import { describe, expect, it } from "@effect/vitest"; | ||
| 4 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 5 | import * as Effect from "effect/Effect"; | ||
| 6 | import * as Encoding from "effect/Encoding"; | ||
| 7 | import * as FileSystem from "effect/FileSystem"; | ||
| 8 | import * as Layer from "effect/Layer"; | ||
| 9 | import * as Sink from "effect/Sink"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import { HttpClient, HttpClientResponse } from "effect/unstable/http"; | ||
| 12 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 13 | |||
| 14 | import { | ||
| 15 | CloudflaredInstallError, | ||
| 16 | CLOUDFLARED_VERSION, | ||
| 17 | makeCloudflaredExecutable, | ||
| 18 | resolveManagedCloudflaredPath, | ||
| 19 | } from "./cloudflared.ts"; | ||
| 20 | |||
| 21 | const emptyConfigProvider = () => ConfigProvider.fromEnv({ env: {} }); | ||
| 22 | |||
| 23 | function 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 | |||
| 39 | const 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 | |||
| 49 | const 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 | |||
| 60 | describe("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 | }); | ||
| @@ -0,0 +1,477 @@ | |||
| 1 | import * as Clock from "effect/Clock"; | ||
| 2 | import * as Config from "effect/Config"; | ||
| 3 | import * as ConfigProvider from "effect/ConfigProvider"; | ||
| 4 | import * as Context from "effect/Context"; | ||
| 5 | import * as Crypto from "effect/Crypto"; | ||
| 6 | import * as Data from "effect/Data"; | ||
| 7 | import * as Effect from "effect/Effect"; | ||
| 8 | import * as Encoding from "effect/Encoding"; | ||
| 9 | import * as FileSystem from "effect/FileSystem"; | ||
| 10 | import * as Layer from "effect/Layer"; | ||
| 11 | import * as Option from "effect/Option"; | ||
| 12 | import * as Path from "effect/Path"; | ||
| 13 | import * as PlatformError from "effect/PlatformError"; | ||
| 14 | import * as Semaphore from "effect/Semaphore"; | ||
| 15 | import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/unstable/http"; | ||
| 16 | import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; | ||
| 17 | |||
| 18 | export const CLOUDFLARED_VERSION = "2026.5.2"; | ||
| 19 | export const CLOUDFLARED_PATH_ENV_NAME = "T3CODE_CLOUDFLARED_PATH"; | ||
| 20 | |||
| 21 | export type CloudflaredExecutableSource = "override" | "managed" | "path"; | ||
| 22 | |||
| 23 | export 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 | |||
| 41 | export type AvailableCloudflaredExecutable = Extract< | ||
| 42 | CloudflaredExecutableStatus, | ||
| 43 | { readonly status: "available" } | ||
| 44 | >; | ||
| 45 | |||
| 46 | export 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 | |||
| 59 | class CloudflaredCommandError extends Data.TaggedError("CloudflaredCommandError")<{ | ||
| 60 | readonly command: string; | ||
| 61 | readonly exitCode: number; | ||
| 62 | }> {} | ||
| 63 | |||
| 64 | export interface CloudflaredReleaseAsset { | ||
| 65 | readonly url: string; | ||
| 66 | readonly sha256: string; | ||
| 67 | readonly archive: "binary" | "tgz"; | ||
| 68 | } | ||
| 69 | |||
| 70 | const 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 | |||
| 100 | const INSTALL_LOCK_RETRY_COUNT = 100; | ||
| 101 | const INSTALL_LOCK_RETRY_DELAY = "100 millis"; | ||
| 102 | const INSTALL_LOCK_STALE_MS = 5 * 60 * 1_000; | ||
| 103 | |||
| 104 | const 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 | |||
| 115 | const CloudflaredConfig = Config.all({ | ||
| 116 | executableOverride: trimmedString(CLOUDFLARED_PATH_ENV_NAME), | ||
| 117 | path: trimmedString("PATH"), | ||
| 118 | }); | ||
| 119 | |||
| 120 | export 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 | |||
| 128 | export interface CloudflaredExecutableShape { | ||
| 129 | readonly resolve: Effect.Effect<CloudflaredExecutableStatus>; | ||
| 130 | readonly install: Effect.Effect<AvailableCloudflaredExecutable, CloudflaredInstallError>; | ||
| 131 | } | ||
| 132 | |||
| 133 | export class CloudflaredExecutable extends Context.Service< | ||
| 134 | CloudflaredExecutable, | ||
| 135 | CloudflaredExecutableShape | ||
| 136 | >()("@t3tools/shared/cloudflared/CloudflaredExecutable") {} | ||
| 137 | |||
| 138 | function executableFileName(platform: NodeJS.Platform): string { | ||
| 139 | return platform === "win32" ? "cloudflared.exe" : "cloudflared"; | ||
| 140 | } | ||
| 141 | |||
| 142 | export 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 | |||
| 158 | function resolveReleaseAsset( | ||
| 159 | platform: NodeJS.Platform, | ||
| 160 | arch: string, | ||
| 161 | ): CloudflaredReleaseAsset | null { | ||
| 162 | return CLOUDFLARED_RELEASE_ASSETS[`${platform}-${arch}`] ?? null; | ||
| 163 | } | ||
| 164 | |||
| 165 | function isAlreadyExists(error: PlatformError.PlatformError): boolean { | ||
| 166 | return error.reason._tag === "AlreadyExists"; | ||
| 167 | } | ||
| 168 | |||
| 169 | const 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 | |||
| 188 | export 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 | |||
| 476 | export const layer = (options: CloudflaredExecutableOptions) => | ||
| 477 | Layer.effect(CloudflaredExecutable, makeCloudflaredExecutable(options)); | ||
| @@ -1,3 +1,12 @@ | |||
| 1 | export const DEFAULT_T3_RELAY_DOMAIN = "t3code-relay.ineededadomain.com"; | ||
| 2 | |||
| 3 | export const DEFAULT_T3_RELAY_URL = `https://${DEFAULT_T3_RELAY_DOMAIN}`; | ||
| 4 | |||
| 5 | export const DEFAULT_T3_RELAY_ZONE_NAME = "ineededadomain.com"; | ||
| 6 | |||
| 7 | export const DEFAULT_T3_CLERK_PUBLISHABLE_KEY = | ||
| 8 | "pk_test_YXdhaXRlZC1tb25rZmlzaC01OC5jbGVyay5hY2NvdW50cy5kZXYk"; | ||
| 9 | |||
| 1 | export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay"; | 10 | export const RELAY_CLERK_JWT_TEMPLATE = "t3-relay"; |
| 2 | 11 | ||
| 3 | export const RELAY_CLERK_TOKEN_OPTIONS = { | 12 | export const RELAY_CLERK_TOKEN_OPTIONS = { |
| @@ -17,6 +17,10 @@ import * as Schema from "effect/Schema"; · 16 unmodified lines | |||
| 17 | import { Argument, Command, Flag } from "effect/unstable/cli"; | 17 | import { Argument, Command, Flag } from "effect/unstable/cli"; |
| 18 | import { ChildProcess } from "effect/unstable/process"; | 18 | import { ChildProcess } from "effect/unstable/process"; |
| 19 | 19 | ||
| 20 | import { loadRepoEnv } from "./lib/public-config.ts"; | ||
| 21 | |||
| 22 | Object.assign(process.env, loadRepoEnv()); | ||
| 23 | |||
| 20 | const BASE_SERVER_PORT = 13773; | 24 | const BASE_SERVER_PORT = 13773; |
| 21 | const BASE_WEB_PORT = 5733; | 25 | const BASE_WEB_PORT = 5733; |
| 22 | const MAX_HASH_OFFSET = 3000; | 26 | const MAX_HASH_OFFSET = 3000; |
| @@ -0,0 +1,74 @@ | |||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Tests exercise root env file precedence directly. | ||
| 2 | import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; | ||
| 3 | import { tmpdir } from "node:os"; | ||
| 4 | import { join } from "node:path"; | ||
| 5 | import { afterEach, describe, expect, it } from "vitest"; | ||
| 6 | |||
| 7 | import { DEFAULT_PUBLIC_CONFIG, loadRepoEnv, resolvePublicConfig } from "./public-config.ts"; | ||
| 8 | |||
| 9 | const temporaryDirectories: string[] = []; | ||
| 10 | |||
| 11 | afterEach(() => { | ||
| 12 | for (const directory of temporaryDirectories.splice(0)) { | ||
| 13 | rmSync(directory, { recursive: true, force: true }); | ||
| 14 | } | ||
| 15 | }); | ||
| 16 | |||
| 17 | describe("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 | |||
| 70 | function makeTemporaryDirectory() { | ||
| 71 | const directory = mkdtempSync(join(tmpdir(), "t3code-public-config-")); | ||
| 72 | temporaryDirectories.push(directory); | ||
| 73 | return directory; | ||
| 74 | } | ||
| @@ -0,0 +1,78 @@ | |||
| 1 | // @effect-diagnostics nodeBuiltinImport:off - Build bootstrap reads optional root env files before an Effect runtime exists. | ||
| 2 | import * as NodeFS from "node:fs"; | ||
| 3 | import * as NodePath from "node:path"; | ||
| 4 | import * as NodeURL from "node:url"; | ||
| 5 | import * as NodeUtil from "node:util"; | ||
| 6 | |||
| 7 | import { DEFAULT_T3_CLERK_PUBLISHABLE_KEY, DEFAULT_T3_RELAY_URL } from "@t3tools/shared/relayAuth"; | ||
| 8 | |||
| 9 | export interface T3CodePublicConfig { | ||
| 10 | readonly clerkPublishableKey: string; | ||
| 11 | readonly relayUrl: string; | ||
| 12 | } | ||
| 13 | |||
| 14 | type Environment = Readonly<Record<string, string | undefined>>; | ||
| 15 | |||
| 16 | const 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. | ||
| 22 | export const DEFAULT_PUBLIC_CONFIG: T3CodePublicConfig = { | ||
| 23 | clerkPublishableKey: DEFAULT_T3_CLERK_PUBLISHABLE_KEY, | ||
| 24 | relayUrl: DEFAULT_T3_RELAY_URL, | ||
| 25 | }; | ||
| 26 | |||
| 27 | export 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 | |||
| 50 | export 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 | |||
| 64 | function 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 | |||
| 76 | function readEnvFile(path: string): Record<string, string | undefined> { | ||
| 77 | return NodeFS.existsSync(path) ? NodeUtil.parseEnv(NodeFS.readFileSync(path, "utf8")) : {}; | ||
| 78 | } | ||