authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-09-17 23:21:25-07:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 23:21:25-07:00
log74796256868916da550ecbe9b5e37d359a8cb321
treeaefafcecf35e351cec5d77e468a057c52defe87d
parente3c85ead63c3a69f9be5dbb873b3d87c9b76b43f
signature Signed by PGP key B5690EEEBB952194

chore(relay): deploy with the Alchemy CLI and publish client config through an Action (#12401)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>

10 files changed, 369 insertions(+), 755 deletions(-)

.github/workflows/deploy-relay.yml+5-11
...@@ -54,9 +54,11 @@ jobs:...@@ -54,9 +54,11 @@ jobs:
54 - --filter=t3code-relay...54 - --filter=t3code-relay...
5555
56 - name: Deploy production relay stage56 - name: Deploy production relay stage
57 id: deploy57 run: vp run --filter t3code-relay deploy --stage prod --yes --no-input
58 run: vp run --filter t3code-relay deploy --stage prod --yes --github-output
59 env:58 env:
59 # The PublishClientConfig action writes the client env here instead
60 # of the repo-root .env; nothing on the runner reads it.
61 T3CODE_RELAY_CLIENT_CONFIG_ENV: ${{ runner.temp }}/relay-client-config.env
60 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}62 CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
61 PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }}63 PLANETSCALE_API_TOKEN_ID: ${{ secrets.PLANETSCALE_API_TOKEN_ID }}
62 PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }}64 PLANETSCALE_API_TOKEN: ${{ secrets.PLANETSCALE_API_TOKEN }}
...@@ -69,20 +71,12 @@ jobs:...@@ -69,20 +71,12 @@ jobs:
69 uses: actions/github-script@v871 uses: actions/github-script@v8
70 with:72 with:
71 script: |73 script: |
72 const result = "${{ steps.deploy.outputs.result }}";
73 const changed = "${{ steps.deploy.outputs.changed }}" === "true";
74 const description = changed
75 ? "Relay production deploy applied infrastructure changes."
76 : result === "noop"
77 ? "Relay production deploy was a no-op."
78 : `Relay production deploy completed with result: ${result}.`;
79
80 await github.rest.repos.createCommitStatus({74 await github.rest.repos.createCommitStatus({
81 owner: context.repo.owner,75 owner: context.repo.owner,
82 repo: context.repo.repo,76 repo: context.repo.repo,
83 sha: context.sha,77 sha: context.sha,
84 state: "success",78 state: "success",
85 context: "Relay deploy / production",79 context: "Relay deploy / production",
86 description,80 description: "Relay production deploy completed.",
87 target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,81 target_url: `${context.serverUrl}/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`,
88 });82 });
.github/workflows/release.yml+20-7
...@@ -311,15 +311,28 @@ jobs:...@@ -311,15 +311,28 @@ jobs:
311 args:311 args:
312 - --filter=t3code-relay...312 - --filter=t3code-relay...
313313
314 - id: relay_state314 # The deployed stack's outputs, read from Alchemy's state store without
315 name: Read production relay tracing config315 # planning or applying. Redacted values are persisted as
316 # {"__redacted__": "<value>"}; the token is masked before it is written.
317 - name: Read production relay tracing config
316 shell: bash318 shell: bash
319 working-directory: infra/relay
317 run: |320 run: |
318 vp run --filter t3code-relay deploy \321 set -euo pipefail
319 --stage prod \322 output="$(npx alchemy state read T3CodeRelay/prod/output --no-input)"
320 --read-state \323 field() {
321 --github-output \324 jq -er --arg key "$1" '.[$key] | if type == "object" then .__redacted__ else . end | select(. != null and . != "")' <<<"$output" \
322 --github-env-file "$RUNNER_TEMP/relay-client-tracing.env"325 || { echo "Relay stack output is missing $1" >&2; exit 1; }
326 }
327 url="$(field clientTracingUrl)"
328 dataset="$(field clientTracingDataset)"
329 token="$(field clientTracingToken)"
330 echo "::add-mask::$token"
331 {
332 echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=$url"
333 echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=$dataset"
334 echo "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=$token"
335 } > "$RUNNER_TEMP/relay-client-tracing.env"
323336
324 - name: Upload relay client tracing config337 - name: Upload relay client tracing config
325 uses: actions/upload-artifact@v7338 uses: actions/upload-artifact@v7
docs/operations/connect-setup.md+1-1
...@@ -32,7 +32,7 @@ Bundled servers also accept runtime overrides for operator-managed deployments....@@ -32,7 +32,7 @@ Bundled servers also accept runtime overrides for operator-managed deployments.
3232
33Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment settings.33Copy `infra/relay/.env.example` to `infra/relay/.env` for relay deployment settings.
34Deploy `prod` before personal stages because it owns the retained database that their branches34Deploy `prod` before personal stages because it owns the retained database that their branches
35depend on. The deploy wrapper writes the resulting relay URL back to the root `.env`.35depend on. The stack's `PublishClientConfig` action writes the resulting relay URL back to the root `.env`.
3636
37## CLI OAuth application37## CLI OAuth application
3838
infra/relay/README.md+6-4
...@@ -76,7 +76,8 @@ dependencies represented at their boundary rather than mocking internal behavior...@@ -76,7 +76,8 @@ dependencies represented at their boundary rather than mocking internal behavior
7676
77## Deployment77## Deployment
7878
79The relay deploys through Alchemy:79The relay deploys with the Alchemy CLI (`vp run --filter t3code-relay deploy` is `alchemy deploy`
80in this directory):
8081
81```sh82```sh
82vp run --filter t3code-relay deploy83vp run --filter t3code-relay deploy
...@@ -109,9 +110,10 @@ DNS-safe sanitization as Alchemy physical resource names, so `prod` uses...@@ -109,9 +110,10 @@ DNS-safe sanitization as Alchemy physical resource names, so `prod` uses
109`<stage>-<digest>.<RELAY_TUNNEL_ZONE_NAME>`. `RELAY_DOMAIN` remains available as an explicit API110`<stage>-<digest>.<RELAY_TUNNEL_ZONE_NAME>`. `RELAY_DOMAIN` remains available as an explicit API
110domain override.111domain override.
111112
112After a successful deploy, the wrapper updates the repository-root `.env` file with the derived relay113The stack's `PublishClientConfig` action ([`src/clientConfig.ts`](./src/clientConfig.ts)) writes the
113URL. That makes subsequent source builds point at the relay that was just deployed without copying114deployed relay URL and tracing configuration into the repository-root `.env`, so subsequent source
114the URL manually.115builds point at the relay that was just deployed without copying values manually. It runs only when
116one of those outputs changed, and `T3CODE_RELAY_CLIENT_CONFIG_ENV` redirects it to another file.
115117
116### Deployment CI118### Deployment CI
117119
infra/relay/alchemy.run.ts+15
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1// @effect-diagnostics anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements.1// @effect-diagnostics anyUnknownInErrorContext:off layerMergeAllWithDependencies:off - Alchemy provider helpers expose framework-owned any requirements.
2import * as Alchemy from "alchemy";2import * as Alchemy from "alchemy";
3import * as Output from "alchemy/Output";
3import * as Axiom from "alchemy/Axiom";4import * as Axiom from "alchemy/Axiom";
4import * as Cloudflare from "alchemy/Cloudflare";5import * as Cloudflare from "alchemy/Cloudflare";
5import * as Drizzle from "alchemy/Drizzle";6import * as Drizzle from "alchemy/Drizzle";
...@@ -7,6 +8,7 @@ import * as Effect from "effect/Effect";...@@ -7,6 +8,7 @@ import * as Effect from "effect/Effect";
7import * as Layer from "effect/Layer";8import * as Layer from "effect/Layer";
8import * as Planetscale from "alchemy/Planetscale";9import * as Planetscale from "alchemy/Planetscale";
910
11import { PublishClientConfig, tokenDigest } from "./src/clientConfig.ts";
10import * as RelayDb from "./src/db.ts";12import * as RelayDb from "./src/db.ts";
11import { RelayObservability } from "./src/observability.ts";13import { RelayObservability } from "./src/observability.ts";
12import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts";14import { ManagedEndpointZone, RelayApiZone } from "./src/zone.ts";
...@@ -30,6 +32,19 @@ export default Alchemy.Stack(...@@ -30,6 +32,19 @@ export default Alchemy.Stack(
30 const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie);32 const relayApiZone = yield* RelayApiZone.pipe(Effect.orDie);
31 const observability = yield* RelayObservability;33 const observability = yield* RelayObservability;
32 const api = yield* Api;34 const api = yield* Api;
35 yield* PublishClientConfig({
36 url: api.url,
37 mobileTracingUrl: observability.traces.otelTracesEndpoint,
38 mobileTracingDataset: observability.traces.name,
39 mobileTracingToken: observability.mobileIngestToken.token,
40 clientTracingUrl: observability.traces.otelTracesEndpoint,
41 clientTracingDataset: observability.traces.name,
42 clientTracingToken: observability.clientIngestToken.token,
43 tokenDigest: Output.map(
44 Output.all(observability.mobileIngestToken.token, observability.clientIngestToken.token),
45 tokenDigest,
46 ),
47 });
3348
34 return {49 return {
35 databaseName: db.database.name,50 databaseName: db.database.name,
infra/relay/package.json+1-1
...@@ -3,7 +3,7 @@...@@ -3,7 +3,7 @@
3 "private": true,3 "private": true,
4 "type": "module",4 "type": "module",
5 "scripts": {5 "scripts": {
6 "deploy": "node -- scripts/deploy.ts",6 "deploy": "alchemy deploy",
7 "push:android:smoke": "node scripts/android-push-smoke.ts",7 "push:android:smoke": "node scripts/android-push-smoke.ts",
8 "push:android:watch": "node scripts/android-push-watch.ts",8 "push:android:watch": "node scripts/android-push-watch.ts",
9 "destroy": "alchemy destroy",9 "destroy": "alchemy destroy",
infra/relay/scripts/deploy.test.ts deleted-227
...@@ -1,227 +0,0 @@
1import * as NodeServices from "@effect/platform-node/NodeServices";
2import { describe, expect, it } from "@effect/vitest";
3import * as Effect from "effect/Effect";
4import * as FileSystem from "effect/FileSystem";
5import * as Path from "effect/Path";
6
7import {
8 hasDeployChanges,
9 missingRelayPublicConfigFields,
10 publicConfigFromOutput,
11 reconcileRootEnvPublicConfig,
12 RelayDeployError,
13 RelayDeployPublicConfigUnavailableError,
14 serializeGithubOutput,
15 serializeRelayClientTracingEnvironment,
16} from "./deploy.ts";
17
18describe("RelayDeployError", () => {
19 it("reports the incomplete state source, stage, and missing fields", () => {
20 const missingFields = missingRelayPublicConfigFields({
21 url: "https://relay.example.test",
22 mobileTracingUrl: "https://api.axiom.co/v1/traces",
23 });
24 const error = new RelayDeployError({
25 source: "alchemy_state",
26 stage: "production",
27 missingFields,
28 });
29
30 expect(error).toMatchObject({
31 source: "alchemy_state",
32 stage: "production",
33 missingFields: [
34 "mobileTracingDataset",
35 "mobileTracingToken",
36 "clientTracingUrl",
37 "clientTracingDataset",
38 "clientTracingToken",
39 ],
40 });
41 expect(error.message).toBe(
42 "Relay deploy output from 'alchemy_state' for stage 'production' is missing required public config fields: mobileTracingDataset, mobileTracingToken, clientTracingUrl, clientTracingDataset, clientTracingToken",
43 );
44 });
45
46 it("distinguishes deploy results that do not produce public config", () => {
47 const error = new RelayDeployPublicConfigUnavailableError({
48 result: "dry-run",
49 stage: "production",
50 outputPath: "/tmp/relay-client.env",
51 });
52
53 expect(error.message).toBe(
54 "Relay deploy result 'dry-run' for stage 'production' did not produce public config required by GitHub environment output '/tmp/relay-client.env'.",
55 );
56 });
57});
58
59describe("hasDeployChanges", () => {
60 it("detects resource, binding, and deletion changes", () => {
61 expect(hasDeployChanges({ resources: {}, deletions: {} } as never)).toBe(false);
62 expect(
63 hasDeployChanges({
64 resources: {
65 api: { action: "create", bindings: [] },
66 },
67 deletions: {},
68 } as never),
69 ).toBe(true);
70 expect(
71 hasDeployChanges({
72 resources: {
73 api: { action: "noop", bindings: [{ action: "update" }] },
74 },
75 deletions: {},
76 } as never),
77 ).toBe(true);
78 expect(
79 hasDeployChanges({
80 resources: {},
81 deletions: {
82 api: { action: "delete", bindings: [] },
83 },
84 } as never),
85 ).toBe(true);
86 });
87});
88
89describe("reconcileRootEnvPublicConfig", () => {
90 const config = {
91 relayUrl: "https://relay.example.test",
92 mobileTracingUrl: "https://api.axiom.co/v1/traces",
93 mobileTracingDataset: "t3-code-mobile-traces-dev",
94 mobileTracingToken: "xaat-public-ingest",
95 clientTracingUrl: "https://api.axiom.co/v1/traces",
96 clientTracingDataset: "t3-code-relay-client-traces-dev",
97 clientTracingToken: "xaat-relay-client-ingest",
98 } as const;
99
100 it("adds the complete local client config", () => {
101 expect(reconcileRootEnvPublicConfig("", config)).toBe(
102 [
103 "T3CODE_RELAY_URL=https://relay.example.test",
104 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
105 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
106 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",
107 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
108 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev",
109 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest",
110 "",
111 ].join("\n"),
112 );
113 });
114
115 it("replaces stale values while preserving unrelated entries", () => {
116 expect(
117 reconcileRootEnvPublicConfig(
118 [
119 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example",
120 "T3CODE_RELAY_URL=https://old.example.test",
121 "T3CODE_MOBILE_OTLP_TRACES_URL=https://old.example.test/v1/traces",
122 "T3CODE_MOBILE_OTLP_TRACES_DATASET=old-dataset",
123 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=old-token",
124 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://old.example.test/v1/traces",
125 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=old-client-dataset",
126 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=old-client-token",
127 "",
128 ].join("\n"),
129 config,
130 ),
131 ).toBe(
132 [
133 "T3CODE_CLERK_PUBLISHABLE_KEY=pk_test_example",
134 "T3CODE_RELAY_URL=https://relay.example.test",
135 "T3CODE_MOBILE_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
136 "T3CODE_MOBILE_OTLP_TRACES_DATASET=t3-code-mobile-traces-dev",
137 "T3CODE_MOBILE_OTLP_TRACES_TOKEN=xaat-public-ingest",
138 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
139 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=t3-code-relay-client-traces-dev",
140 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=xaat-relay-client-ingest",
141 "",
142 ].join("\n"),
143 );
144 });
145});
146
147describe("serializeGithubOutput", () => {
148 it("serializes relay deploy metadata for GitHub Actions outputs", () => {
149 expect(
150 serializeGithubOutput({
151 changed: false,
152 result: "noop",
153 relay_url: "https://relay.example.test",
154 }),
155 ).toBe("changed=false\nresult=noop\nrelay_url=https://relay.example.test\n");
156 });
157});
158
159describe("serializeRelayClientTracingEnvironment", () => {
160 it("serializes tracing config for downstream GITHUB_ENV loading", () => {
161 expect(
162 serializeRelayClientTracingEnvironment({
163 relayUrl: "https://relay.example.test",
164 mobileTracingUrl: "https://api.axiom.co/v1/traces",
165 mobileTracingDataset: "mobile",
166 mobileTracingToken: "mobile-token",
167 clientTracingUrl: "https://api.axiom.co/v1/traces",
168 clientTracingDataset: "relay",
169 clientTracingToken: "client-token",
170 }),
171 ).toBe(
172 [
173 "T3CODE_RELAY_CLIENT_OTLP_TRACES_URL=https://api.axiom.co/v1/traces",
174 "T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET=relay",
175 "T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-token",
176 "",
177 ].join("\n"),
178 );
179 });
180});
181
182describe("release workflow tracing config propagation", () => {
183 it.effect("uses an artifact instead of a masked cross-job token output", () =>
184 Effect.gen(function* () {
185 const fileSystem = yield* FileSystem.FileSystem;
186 const path = yield* Path.Path;
187 const workflowPath = yield* path.fromFileUrl(
188 new URL("../../../.github/workflows/release.yml", import.meta.url),
189 );
190 const workflow = yield* fileSystem.readFileString(workflowPath);
191
192 expect(workflow).not.toContain("client_tracing_token:");
193 expect(workflow).not.toContain("needs.relay_public_config.outputs.client_tracing_token");
194 expect(workflow).toContain('--github-env-file "$RUNNER_TEMP/relay-client-tracing.env"');
195 expect(workflow).toContain("name: relay-client-tracing-config");
196 expect(workflow).toContain('cat "$config_path" >> "$GITHUB_ENV"');
197 }).pipe(Effect.provide(NodeServices.layer)),
198 );
199});
200
201describe("publicConfigFromOutput", () => {
202 it("reads the complete public tracing config from persisted Alchemy output", () => {
203 expect(
204 publicConfigFromOutput({
205 url: "https://relay.example.test",
206 mobileTracingUrl: "https://api.axiom.co/v1/traces",
207 mobileTracingDataset: "mobile",
208 mobileTracingToken: "mobile-token",
209 clientTracingUrl: "https://api.axiom.co/v1/traces",
210 clientTracingDataset: "relay",
211 clientTracingToken: "client-token",
212 }),
213 ).toEqual({
214 relayUrl: "https://relay.example.test",
215 mobileTracingUrl: "https://api.axiom.co/v1/traces",
216 mobileTracingDataset: "mobile",
217 mobileTracingToken: "mobile-token",
218 clientTracingUrl: "https://api.axiom.co/v1/traces",
219 clientTracingDataset: "relay",
220 clientTracingToken: "client-token",
221 });
222 });
223
224 it("rejects incomplete stack output", () => {
225 expect(publicConfigFromOutput({ url: "https://relay.example.test" })).toBeNull();
226 });
227});
infra/relay/scripts/deploy.ts deleted-504
...@@ -1,504 +0,0 @@
1#!/usr/bin/env node
2
3import * as NodeRuntime from "@effect/platform-node/NodeRuntime";
4import { AdoptPolicy } from "alchemy/AdoptPolicy";
5import { AlchemyContext, AlchemyContextLive } from "alchemy/AlchemyContext";
6import * as Apply from "alchemy/Apply";
7import { ArtifactStore, createArtifactStore, provideFreshArtifactStore } from "alchemy/Artifacts";
8import { AuthProviders } from "alchemy/Auth/AuthProvider";
9import { CredentialsStoreLive } from "alchemy/Auth/Credentials";
10import { ProfileStoreLive } from "alchemy/Auth/Profile";
11import * as Cloudflare from "alchemy/Cloudflare";
12import { layerNonInteractive } from "alchemy/Interaction";
13import { LoggingCli } from "alchemy/Cli/LoggingCli";
14import * as Plan from "alchemy/Plan";
15import { Cli } from "alchemy/Report";
16import * as Stage from "alchemy/Stage";
17import * as State from "alchemy/State/State";
18import { TelemetryLive } from "alchemy/Telemetry/Layer";
19import { PlatformServices } from "alchemy/Util/PlatformServices";
20import * as Config from "effect/Config";
21import * as ConfigProvider from "effect/ConfigProvider";
22import * as Console from "effect/Console";
23import * as Effect from "effect/Effect";
24import * as FileSystem from "effect/FileSystem";
25import * as Layer from "effect/Layer";
26import * as Option from "effect/Option";
27import * as Path from "effect/Path";
28import * as Redacted from "effect/Redacted";
29import * as Schema from "effect/Schema";
30import { Command, Flag, Prompt } from "effect/unstable/cli";
31import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
32
33import RelayStack from "../alchemy.run.ts";
34
35const relayDeployOutputFields = [
36 "url",
37 "mobileTracingUrl",
38 "mobileTracingDataset",
39 "mobileTracingToken",
40 "clientTracingUrl",
41 "clientTracingDataset",
42 "clientTracingToken",
43] as const;
44
45export const RelayDeployOutputField = Schema.Literals(relayDeployOutputFields);
46export type RelayDeployOutputField = typeof RelayDeployOutputField.Type;
47
48export const RelayDeployResult = Schema.Literals([
49 "applied",
50 "noop",
51 "dry-run",
52 "cancelled",
53 "state",
54]);
55export type RelayDeployResult = typeof RelayDeployResult.Type;
56
57export class RelayDeployError extends Schema.TaggedError<RelayDeployError>()("RelayDeployError", {
58 source: Schema.Literals(["alchemy_state", "alchemy_apply"]),
59 stage: Schema.String,
60 missingFields: Schema.Array(RelayDeployOutputField),
61}) {
62 override get message(): string {
63 return `Relay deploy output from '${this.source}' for stage '${this.stage}' is missing required public config fields: ${this.missingFields.join(", ")}`;
64 }
65}
66
67export class RelayDeployPublicConfigUnavailableError extends Schema.TaggedError<RelayDeployPublicConfigUnavailableError>()(
68 "RelayDeployPublicConfigUnavailableError",
69 {
70 result: RelayDeployResult,
71 stage: Schema.String,
72 outputPath: Schema.String,
73 },
74) {
75 override get message(): string {
76 return `Relay deploy result '${this.result}' for stage '${this.stage}' did not produce public config required by GitHub environment output '${this.outputPath}'.`;
77 }
78}
79
80export interface RelayDeployOptions {
81 readonly dryRun: boolean;
82 readonly force: boolean;
83 readonly envFile: Option.Option<string>;
84 readonly stage: Option.Option<string>;
85 readonly yes: boolean;
86 readonly adopt: boolean;
87 readonly githubOutput: boolean;
88 readonly githubEnvFile: Option.Option<string>;
89 readonly readState: boolean;
90}
91
92export interface RelayPublicConfig {
93 readonly relayUrl: string;
94 readonly mobileTracingUrl: string;
95 readonly mobileTracingDataset: string;
96 readonly mobileTracingToken: string;
97 readonly clientTracingUrl: string;
98 readonly clientTracingDataset: string;
99 readonly clientTracingToken: string;
100}
101
102const publicConfigEnvEntries = (config: RelayPublicConfig) =>
103 ({
104 T3CODE_RELAY_URL: config.relayUrl,
105 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl,
106 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset,
107 T3CODE_MOBILE_OTLP_TRACES_TOKEN: config.mobileTracingToken,
108 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl,
109 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset,
110 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken,
111 }) as const;
112
113export function reconcileRootEnvPublicConfig(contents: string, config: RelayPublicConfig): string {
114 let next = contents;
115 for (const [name, value] of Object.entries(publicConfigEnvEntries(config))) {
116 const entry = `${name}=${value}`;
117 const pattern = new RegExp(`^${name}=.*$`, "mu");
118 if (pattern.test(next)) {
119 next = next.replace(pattern, entry);
120 continue;
121 }
122 if (!next) {
123 next = `${entry}\n`;
124 continue;
125 }
126 next = `${next}${next.endsWith("\n") ? "" : "\n"}${entry}\n`;
127 }
128 return next;
129}
130
131export function reconcileRootEnvRelayUrl(contents: string, relayUrl: string): string {
132 return reconcileRootEnvPublicConfig(contents, {
133 relayUrl,
134 mobileTracingUrl: "",
135 mobileTracingDataset: "",
136 mobileTracingToken: "",
137 clientTracingUrl: "",
138 clientTracingDataset: "",
139 clientTracingToken: "",
140 })
141 .split("\n")
142 .filter((line) => !line.startsWith("T3CODE_MOBILE_OTLP_TRACES_"))
143 .filter((line) => !line.startsWith("T3CODE_RELAY_CLIENT_OTLP_TRACES_"))
144 .join("\n");
145}
146
147export function hasDeployChanges(plan: Plan.Plan): boolean {
148 return (
149 Object.keys(plan.deletions).length > 0 ||
150 Object.values(plan.resources).some(
151 (node) =>
152 node.action !== "noop" || node.bindings.some((binding) => binding.action !== "noop"),
153 )
154 );
155}
156
157export interface RelayDeployOutcome {
158 readonly result: RelayDeployResult;
159 readonly changed: boolean;
160 readonly publicConfig: Option.Option<RelayPublicConfig>;
161}
162
163export function serializeGithubOutput(entries: Readonly<Record<string, string | boolean>>): string {
164 return Object.entries(entries)
165 .map(([key, value]) => `${key}=${value}\n`)
166 .join("");
167}
168
169export function serializeRelayClientTracingEnvironment(config: RelayPublicConfig): string {
170 return serializeGithubOutput({
171 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl,
172 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset,
173 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: config.clientTracingToken,
174 });
175}
176
177const relayRoot = Effect.service(Path.Path).pipe(
178 Effect.flatMap((path) => path.fromFileUrl(new URL("..", import.meta.url))),
179);
180const repoRoot = Effect.service(Path.Path).pipe(
181 Effect.flatMap((path) => path.fromFileUrl(new URL("../../..", import.meta.url))),
182);
183
184const loadDeployConfigProvider = Effect.fn("relay.deploy.loadConfigProvider")(function* (
185 envFileOverride: Option.Option<string>,
186) {
187 const path = yield* Path.Path;
188 const root = yield* relayRoot;
189
190 if (Option.isSome(envFileOverride)) {
191 return yield* ConfigProvider.fromDotEnv({ path: path.resolve(root, envFileOverride.value) });
192 }
193
194 return yield* ConfigProvider.fromDotEnv({ path: path.join(root, ".env") }).pipe(
195 Effect.orElseSucceed(() => ConfigProvider.fromEnv()),
196 );
197});
198
199const relayDeployStage = Config.NonEmptyString("stage").pipe(
200 Config.option,
201 Config.map(
202 Option.getOrElse(() => `dev_${process.env.USER ?? process.env.USERNAME ?? "unknown"}`),
203 ),
204);
205
206const reconcileRootEnv = Effect.fn("relay.deploy.reconcileRootEnv")(function* (
207 config: RelayPublicConfig,
208) {
209 const fs = yield* FileSystem.FileSystem;
210 const path = yield* Path.Path;
211 const root = yield* repoRoot;
212 const rootEnvPath = path.join(root, ".env");
213 const contents = (yield* fs.exists(rootEnvPath)) ? yield* fs.readFileString(rootEnvPath) : "";
214
215 yield* fs.writeFileString(rootEnvPath, reconcileRootEnvPublicConfig(contents, config));
216 yield* Console.log(`Updated ${rootEnvPath} with relay public client configuration`);
217});
218
219const writeGithubOutput = Effect.fn("relay.deploy.writeGithubOutput")(function* (
220 outcome: RelayDeployOutcome,
221) {
222 const fs = yield* FileSystem.FileSystem;
223 const githubOutputPath = yield* Config.NonEmptyString("GITHUB_OUTPUT");
224 yield* fs.writeFileString(
225 githubOutputPath,
226 serializeGithubOutput({
227 changed: outcome.changed,
228 result: outcome.result,
229 ...(Option.isSome(outcome.publicConfig)
230 ? {
231 relay_url: outcome.publicConfig.value.relayUrl,
232 }
233 : {}),
234 }),
235 { flag: "a" },
236 );
237});
238
239const writeGithubEnvFile = Effect.fn("relay.deploy.writeGithubEnvFile")(function* (
240 outcome: RelayDeployOutcome,
241 outputPath: string,
242 stage: string,
243) {
244 if (Option.isNone(outcome.publicConfig)) {
245 return yield* new RelayDeployPublicConfigUnavailableError({
246 result: outcome.result,
247 stage,
248 outputPath,
249 });
250 }
251 const fs = yield* FileSystem.FileSystem;
252 yield* Console.log(`::add-mask::${outcome.publicConfig.value.clientTracingToken}`);
253 yield* fs.writeFileString(
254 outputPath,
255 serializeRelayClientTracingEnvironment(outcome.publicConfig.value),
256 );
257});
258
259const deployBaseServices = Layer.mergeAll(
260 Layer.succeed(AuthProviders, {}),
261 Layer.succeed(ArtifactStore, createArtifactStore()),
262 Layer.provideMerge(AlchemyContextLive, PlatformServices),
263 Layer.provide(ProfileStoreLive, PlatformServices),
264 Layer.provide(CredentialsStoreLive, PlatformServices),
265 FetchHttpClient.layer,
266 TelemetryLive,
267 // LoggingCli captures the prompt environment when it is built.
268 Layer.provide(LoggingCli, PlatformServices),
269 // Alchemy's auth providers prompt through this service. None are configured
270 // here and the apply confirmation is this script's own prompt, so a prompt
271 // reaching it is a typed failure rather than a hang in CI.
272 layerNonInteractive(),
273);
274const deployServices = deployBaseServices;
275
276function relayPublicConfigValues(
277 output: unknown,
278): Readonly<Record<RelayDeployOutputField, string | undefined>> {
279 if (typeof output !== "object" || output === null) {
280 return {
281 url: undefined,
282 mobileTracingUrl: undefined,
283 mobileTracingDataset: undefined,
284 mobileTracingToken: undefined,
285 clientTracingUrl: undefined,
286 clientTracingDataset: undefined,
287 clientTracingToken: undefined,
288 };
289 }
290 const value = output as Record<string, unknown>;
291 const text = (name: string) => {
292 const candidate = value[name];
293 return typeof candidate === "string" && candidate.length > 0 ? candidate : undefined;
294 };
295 const secret = (name: string): string | undefined => {
296 const candidate = value[name];
297 if (!Redacted.isRedacted(candidate)) {
298 return text(name);
299 }
300 const redacted = Redacted.value(candidate);
301 return typeof redacted === "string" && redacted.length > 0 ? redacted : undefined;
302 };
303 return {
304 url: text("url"),
305 mobileTracingUrl: text("mobileTracingUrl"),
306 mobileTracingDataset: text("mobileTracingDataset"),
307 mobileTracingToken: secret("mobileTracingToken"),
308 clientTracingUrl: text("clientTracingUrl"),
309 clientTracingDataset: text("clientTracingDataset"),
310 clientTracingToken: secret("clientTracingToken"),
311 };
312}
313
314export function missingRelayPublicConfigFields(
315 output: unknown,
316): ReadonlyArray<RelayDeployOutputField> {
317 const values = relayPublicConfigValues(output);
318 return relayDeployOutputFields.filter((field) => values[field] === undefined);
319}
320
321function hasCompleteRelayPublicConfigValues(
322 values: Readonly<Record<RelayDeployOutputField, string | undefined>>,
323): values is Readonly<Record<RelayDeployOutputField, string>> {
324 return relayDeployOutputFields.every((field) => values[field] !== undefined);
325}
326
327export function publicConfigFromOutput(output: unknown): RelayPublicConfig | null {
328 const values = relayPublicConfigValues(output);
329 if (!hasCompleteRelayPublicConfigValues(values)) {
330 return null;
331 }
332 return {
333 relayUrl: values.url,
334 mobileTracingUrl: values.mobileTracingUrl,
335 mobileTracingDataset: values.mobileTracingDataset,
336 mobileTracingToken: values.mobileTracingToken,
337 clientTracingUrl: values.clientTracingUrl,
338 clientTracingDataset: values.clientTracingDataset,
339 clientTracingToken: values.clientTracingToken,
340 };
341}
342
343const readRelayPublicConfig = Effect.fn("relay.deploy.readState")(function* (stage: string) {
344 const state = yield* State.State;
345 const service = yield* state;
346 const output = yield* service.getOutput({ stack: "T3CodeRelay", stage });
347 const publicConfig = publicConfigFromOutput(output);
348 if (publicConfig === null) {
349 return yield* new RelayDeployError({
350 source: "alchemy_state",
351 stage,
352 missingFields: missingRelayPublicConfigFields(output),
353 });
354 }
355 return {
356 result: "state",
357 changed: false,
358 publicConfig: Option.some(publicConfig),
359 } satisfies RelayDeployOutcome;
360});
361
362const runRelayDeploy = Effect.fn("relay.deploy.run")(
363 function* (
364 options: RelayDeployOptions,
365 _configProvider: ConfigProvider.ConfigProvider,
366 stage: string,
367 ) {
368 const stack = yield* RelayStack;
369 const cli = yield* Cli;
370 const plan = yield* Plan.make(stack, { force: options.force }).pipe(
371 Effect.provide(stack.services),
372 );
373 const changed = hasDeployChanges(plan);
374 if (options.dryRun) {
375 yield* cli.displayPlan(plan);
376 return {
377 result: "dry-run",
378 changed,
379 publicConfig: Option.none<RelayPublicConfig>(),
380 } satisfies RelayDeployOutcome;
381 }
382 if (!options.yes && changed) {
383 yield* cli.displayPlan(plan);
384 const approved = yield* Prompt.run(
385 Prompt.Confirm({
386 message: "Apply this relay deployment?",
387 }),
388 );
389 if (!approved) {
390 yield* Console.log("Deployment cancelled.");
391 return {
392 result: "cancelled",
393 changed,
394 publicConfig: Option.none<RelayPublicConfig>(),
395 } satisfies RelayDeployOutcome;
396 }
397 }
398 const output = yield* Apply.apply(plan).pipe(Effect.provide(stack.services));
399 const publicConfig = publicConfigFromOutput(output);
400 if (publicConfig === null) {
401 return yield* new RelayDeployError({
402 source: "alchemy_apply",
403 stage,
404 missingFields: missingRelayPublicConfigFields(output),
405 });
406 }
407 return {
408 result: changed ? "applied" : "noop",
409 changed,
410 publicConfig: Option.some(publicConfig),
411 } satisfies RelayDeployOutcome;
412 },
413 (effect, options, configProvider, stage) =>
414 effect.pipe(
415 Effect.provide(
416 Layer.mergeAll(
417 Layer.effect(
418 AlchemyContext,
419 AlchemyContext.pipe(Effect.map((context) => ({ ...context, adopt: options.adopt }))),
420 ),
421 Layer.succeed(AdoptPolicy, options.adopt),
422 Layer.succeed(AuthProviders, {}),
423 ConfigProvider.layer(configProvider),
424 Layer.succeed(Stage.Stage, stage),
425 ),
426 ),
427 provideFreshArtifactStore,
428 ),
429);
430
431export const deploy = Effect.fn("relay.deploy")(function* (options: RelayDeployOptions) {
432 const configProvider = yield* loadDeployConfigProvider(options.envFile);
433 const configuredStage = yield* relayDeployStage.pipe(
434 Effect.provide(ConfigProvider.layer(configProvider)),
435 );
436 const stage = Option.getOrElse(options.stage, () => configuredStage);
437 const outcome = options.readState
438 ? yield* readRelayPublicConfig(stage).pipe(Effect.provide(Cloudflare.state()))
439 : yield* runRelayDeploy(options, configProvider, stage);
440 if (Option.isSome(outcome.publicConfig)) {
441 yield* reconcileRootEnv(outcome.publicConfig.value);
442 }
443 if (options.githubOutput) {
444 yield* writeGithubOutput(outcome);
445 }
446 if (Option.isSome(options.githubEnvFile)) {
447 yield* writeGithubEnvFile(outcome, options.githubEnvFile.value, stage);
448 }
449});
450
451export const relayDeployCommand = Command.make(
452 "relay-deploy",
453 {
454 dryRun: Flag.Boolean("dry-run").pipe(
455 Flag.withDescription("Dry run the deployment without applying changes."),
456 Flag.withDefault(false),
457 ),
458 force: Flag.Boolean("force").pipe(
459 Flag.withDescription("Force updates for resources that would otherwise no-op."),
460 Flag.withDefault(false),
461 ),
462 envFile: Flag.String("env-file").pipe(
463 Flag.withDescription(
464 "Environment file to load. Defaults to infra/relay/.env with process env fallback.",
465 ),
466 Flag.optional,
467 ),
468 stage: Flag.String("stage").pipe(
469 Flag.withDescription("Stage to deploy. Defaults to dev_${USER}."),
470 Flag.optional,
471 ),
472 yes: Flag.Boolean("yes").pipe(
473 Flag.withDescription("Skip the deployment confirmation prompt."),
474 Flag.withDefault(false),
475 ),
476 adopt: Flag.Boolean("adopt").pipe(
477 Flag.withDescription("Adopt pre-existing cloud resources that conflict with this stack."),
478 Flag.withDefault(false),
479 ),
480 githubOutput: Flag.Boolean("github-output").pipe(
481 Flag.withDescription("Append relay deployment metadata to GITHUB_OUTPUT."),
482 Flag.withDefault(false),
483 ),
484 githubEnvFile: Flag.String("github-env-file").pipe(
485 Flag.withDescription(
486 "Write relay client tracing variables to a file suitable for GITHUB_ENV.",
487 ),
488 Flag.optional,
489 ),
490 readState: Flag.Boolean("read-state").pipe(
491 Flag.withDescription("Read the deployed stack output without planning or applying changes."),
492 Flag.withDefault(false),
493 ),
494 },
495 deploy,
496).pipe(Command.withDescription("Deploy the T3 Code relay through Alchemy."));
497
498if (import.meta.main) {
499 Command.run(relayDeployCommand, { version: "0.0.0" }).pipe(
500 Effect.provide(deployServices),
501 Effect.scoped,
502 NodeRuntime.runMain,
503 );
504}
infra/relay/src/clientConfig.test.ts created+177
...@@ -0,0 +1,177 @@
1// @effect-diagnostics anyUnknownInErrorContext:off - ScratchStack.deploy is typed with `any` in its error channel by Alchemy.
2import { inMemoryState } from "alchemy/State";
3import * as Test from "alchemy/Test/Vitest";
4import * as ConfigProvider from "effect/ConfigProvider";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Layer from "effect/Layer";
8import * as Path from "effect/Path";
9import * as Redacted from "effect/Redacted";
10import { describe, expect, it } from "vite-plus/test";
11import * as NodeServices from "@effect/platform-node/NodeServices";
12
13import { PublishClientConfig, reconcileEnvFile, tokenDigest } from "./clientConfig.ts";
14
15describe("reconcileEnvFile", () => {
16 it("adds every entry to an empty file", () => {
17 expect(reconcileEnvFile("", { A: "1", B: "2" })).toBe("A=1\nB=2\n");
18 });
19
20 it("replaces stale values and keeps unrelated lines", () => {
21 const existing = "KEEP=yes\nA=old\n# comment\n";
22 expect(reconcileEnvFile(existing, { A: "new", B: "2" })).toBe(
23 "KEEP=yes\nA=new\n# comment\nB=2\n",
24 );
25 });
26
27 it("appends a newline before adding to a file without one", () => {
28 expect(reconcileEnvFile("A=1", { B: "2" })).toBe("A=1\nB=2\n");
29 });
30
31 it("collapses duplicate assignments to one, since parseEnv lets the last win", () => {
32 expect(reconcileEnvFile("A=old\nKEEP=1\nA=older\n", { A: "new" })).toBe("A=new\nKEEP=1\n");
33 });
34
35 it("recognises every assignment form parseEnv accepts", () => {
36 expect(reconcileEnvFile("A=old\n export A = stale\nexport A=older\n", { A: "new" })).toBe(
37 "A=new\n",
38 );
39 });
40
41 it("replaces a quoted multi-line value whole", () => {
42 expect(reconcileEnvFile('A="one\ntwo"\nKEEP=1\n', { A: "new" })).toBe("A=new\nKEEP=1\n");
43 expect(reconcileEnvFile("A='x'\nKEEP=1\n", { A: "new" })).toBe("A=new\nKEEP=1\n");
44 });
45
46 it("keeps later lines when a quoted value never closes", () => {
47 expect(reconcileEnvFile('A="one\nKEEP=1\n', { A: "new" })).toBe("A=new\nKEEP=1\n");
48 });
49
50 it("leaves a commented-out assignment alone", () => {
51 expect(reconcileEnvFile("#A=old\n", { A: "new" })).toBe("#A=old\nA=new\n");
52 });
53});
54
55const { test } = Test.make({
56 providers: Layer.empty,
57 state: inMemoryState(),
58});
59
60const clientConfig = (token: string) => ({
61 url: "https://relay.example.com",
62 mobileTracingUrl: "https://axiom.example.com/v1/traces",
63 mobileTracingDataset: "relay-traces",
64 mobileTracingToken: Redacted.make(`mobile-${token}`),
65 clientTracingUrl: "https://axiom.example.com/v1/traces",
66 clientTracingDataset: "relay-traces",
67 clientTracingToken: Redacted.make(`client-${token}`),
68 tokenDigest: tokenDigest([Redacted.make(`mobile-${token}`), Redacted.make(`client-${token}`)]),
69});
70
71describe("PublishClientConfig", () => {
72 test.provider("writes the env file on deploy and skips an unchanged redeploy", (stack) =>
73 Effect.gen(function* () {
74 const fs = yield* FileSystem.FileSystem;
75 const path = yield* Path.Path;
76 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" });
77 const target = path.join(dir, "client.env");
78 yield* fs.writeFileString(target, "KEEP=yes\n");
79 const configured = Effect.provide(
80 ConfigProvider.layer(
81 ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }),
82 ),
83 );
84
85 yield* stack
86 .deploy(
87 Effect.gen(function* () {
88 return yield* PublishClientConfig(clientConfig("v1"));
89 }),
90 )
91 .pipe(configured);
92 const first = yield* fs.readFileString(target);
93 expect(first).toContain("KEEP=yes\n");
94 expect(first).toContain("T3CODE_RELAY_URL=https://relay.example.com\n");
95 expect(first).toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-v1\n");
96 expect(first).toContain("T3CODE_MOBILE_OTLP_TRACES_TOKEN=mobile-v1\n");
97
98 // Same input: the action is skipped, so a change made by hand survives.
99 yield* fs.writeFileString(target, `${first}MANUAL=1\n`);
100 yield* stack
101 .deploy(
102 Effect.gen(function* () {
103 return yield* PublishClientConfig(clientConfig("v1"));
104 }),
105 )
106 .pipe(configured);
107 expect(yield* fs.readFileString(target)).toContain("MANUAL=1\n");
108
109 // A rotated token changes the input, so it runs again and replaces the line.
110 yield* stack
111 .deploy(
112 Effect.gen(function* () {
113 return yield* PublishClientConfig(clientConfig("v2"));
114 }),
115 )
116 .pipe(configured);
117 const third = yield* fs.readFileString(target);
118 expect(third).toContain("T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN=client-v2\n");
119 expect(third).not.toContain("client-v1");
120 expect(third).toContain("KEEP=yes\n");
121 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
122 );
123
124 test.provider("fails when the worker has no url rather than publishing an empty relay", (stack) =>
125 Effect.gen(function* () {
126 const fs = yield* FileSystem.FileSystem;
127 const path = yield* Path.Path;
128 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" });
129 const target = path.join(dir, "client.env");
130 const exit = yield* stack
131 .deploy(
132 Effect.gen(function* () {
133 return yield* PublishClientConfig({ ...clientConfig("v1"), url: undefined });
134 }),
135 )
136 .pipe(
137 Effect.provide(
138 ConfigProvider.layer(
139 ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }),
140 ),
141 ),
142 Effect.exit,
143 );
144 expect(exit._tag).toBe("Failure");
145 expect(String(exit)).toContain("RelayUrlUnavailableError");
146 expect(yield* fs.exists(target)).toBe(false);
147 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
148 );
149
150 test.provider("refuses a value with a line break rather than corrupting the file", (stack) =>
151 Effect.gen(function* () {
152 const fs = yield* FileSystem.FileSystem;
153 const path = yield* Path.Path;
154 const dir = yield* fs.makeTempDirectoryScoped({ prefix: "t3-relay-client-config-" });
155 const target = path.join(dir, "client.env");
156 const exit = yield* stack
157 .deploy(
158 Effect.gen(function* () {
159 return yield* PublishClientConfig({
160 ...clientConfig("v1"),
161 clientTracingDataset: "relay-traces\nINJECTED=1",
162 });
163 }),
164 )
165 .pipe(
166 Effect.provide(
167 ConfigProvider.layer(
168 ConfigProvider.fromUnknown({ T3CODE_RELAY_CLIENT_CONFIG_ENV: target }),
169 ),
170 ),
171 Effect.exit,
172 );
173 expect(exit._tag).toBe("Failure");
174 expect(yield* fs.exists(target)).toBe(false);
175 }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)),
176 );
177});
infra/relay/src/clientConfig.ts created+144
...@@ -0,0 +1,144 @@
1// @effect-diagnostics nodeBuiltinImport:off - one sha256 over two strings; Effect.Crypto is async and the digest feeds a synchronous Output.map.
2import * as NodeCrypto from "node:crypto";
3
4import * as Alchemy from "alchemy";
5import * as Config from "effect/Config";
6import * as Console from "effect/Console";
7import * as Effect from "effect/Effect";
8import * as FileSystem from "effect/FileSystem";
9import * as Option from "effect/Option";
10import * as Path from "effect/Path";
11import * as Redacted from "effect/Redacted";
12import * as Schema from "effect/Schema";
13
14/** The relay outputs a client (web, desktop, mobile) needs at build time. */
15export interface RelayClientConfig {
16 /** Alchemy types this as optional for workers reachable at no URL; ours always has one. */
17 readonly url: string | undefined;
18 readonly mobileTracingUrl: string;
19 readonly mobileTracingDataset: string;
20 readonly mobileTracingToken: Redacted.Redacted<string>;
21 readonly clientTracingUrl: string;
22 readonly clientTracingDataset: string;
23 readonly clientTracingToken: Redacted.Redacted<string>;
24 /**
25 * Alchemy decides whether an Action runs by hashing `JSON.stringify` of its
26 * input, and a Redacted stringifies as `<redacted>`, so a rotated token
27 * alone would never re-run it. A digest of both tokens makes the input
28 * change with them without persisting the secrets in the hash.
29 */
30 readonly tokenDigest: string;
31}
32
33export class RelayUrlUnavailableError extends Schema.TaggedError<RelayUrlUnavailableError>()(
34 "RelayUrlUnavailableError",
35 {},
36) {
37 override get message(): string {
38 return "The relay worker has no URL yet; deploy again once the worker exists.";
39 }
40}
41
42export const relayClientConfigEnv = (config: RelayClientConfig & { readonly url: string }) =>
43 ({
44 T3CODE_RELAY_URL: config.url,
45 T3CODE_MOBILE_OTLP_TRACES_URL: config.mobileTracingUrl,
46 T3CODE_MOBILE_OTLP_TRACES_DATASET: config.mobileTracingDataset,
47 T3CODE_MOBILE_OTLP_TRACES_TOKEN: Redacted.value(config.mobileTracingToken),
48 T3CODE_RELAY_CLIENT_OTLP_TRACES_URL: config.clientTracingUrl,
49 T3CODE_RELAY_CLIENT_OTLP_TRACES_DATASET: config.clientTracingDataset,
50 T3CODE_RELAY_CLIENT_OTLP_TRACES_TOKEN: Redacted.value(config.clientTracingToken),
51 }) as const;
52
53export class EnvValueNotSingleLineError extends Schema.TaggedError<EnvValueNotSingleLineError>()(
54 "EnvValueNotSingleLineError",
55 { name: Schema.String },
56) {
57 override get message(): string {
58 return `${this.name} contains a line break and cannot be written as one .env assignment.`;
59 }
60}
61
62/**
63 * Replaces or appends each `NAME=value` assignment, leaving unrelated lines
64 * alone. Every existing line for a name is dropped, not just the first: the
65 * file is read with `parseEnv`, where the last duplicate wins, so a stale
66 * second copy would override the value just written.
67 */
68export function reconcileEnvFile(
69 contents: string,
70 entries: Readonly<Record<string, string>>,
71): string {
72 const lines = contents === "" ? [] : contents.replace(/\n$/u, "").split("\n");
73 // The forms `parseEnv` treats as an assignment: leading whitespace, an
74 // optional `export`, and whitespace around `=`.
75 const assignment = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/u;
76 const pending = new Map(Object.entries(entries));
77 const out: string[] = [];
78 for (let index = 0; index < lines.length; index += 1) {
79 const line = lines[index]!;
80 const match = assignment.exec(line);
81 const name = match?.[1];
82 if (match === null || name === undefined || !(name in entries)) {
83 out.push(line);
84 continue;
85 }
86 // A quoted value can span lines; skip to its closing quote so the
87 // continuation lines go with the assignment they belong to. With no
88 // closing quote in the file, parseEnv treats the opening line as the
89 // whole value, so nothing after it is consumed.
90 const rawValue = match[2] ?? "";
91 const quote = /^(['"`])/u.exec(rawValue)?.[1];
92 if (quote !== undefined && !closesQuote(rawValue, quote)) {
93 const closing = lines.findIndex((candidate, at) => at > index && candidate.includes(quote));
94 if (closing !== -1) index = closing;
95 }
96 // The first occurrence keeps its position; later duplicates are dropped.
97 const value = pending.get(name);
98 if (value !== undefined) {
99 out.push(`${name}=${value}`);
100 pending.delete(name);
101 }
102 }
103 for (const [name, value] of pending) out.push(`${name}=${value}`);
104 return out.length === 0 ? "" : `${out.join("\n")}\n`;
105}
106
107/** Whether a value that opens with `quote` also closes on the same line. */
108const closesQuote = (value: string, quote: string): boolean =>
109 value.length > 1 && value.slice(1).includes(quote);
110
111/**
112 * Writes the relay's client configuration into the repo-root `.env` so the
113 * web, desktop, and mobile dev servers build against the stage just deployed.
114 * An Action rather than post-deploy scripting: it takes the stack outputs as
115 * input, so it runs only when one of them changed and is skipped on a no-op
116 * deploy. Set `T3CODE_RELAY_CLIENT_CONFIG_ENV` to write elsewhere (CI does).
117 */
118export const tokenDigest = (tokens: ReadonlyArray<Redacted.Redacted<string>>): string =>
119 NodeCrypto.createHash("sha256").update(tokens.map(Redacted.value).join("\n")).digest("hex");
120
121export const PublishClientConfig = Alchemy.Action(
122 "PublishClientConfig",
123 Effect.gen(function* () {
124 const fs = yield* FileSystem.FileSystem;
125 const path = yield* Path.Path;
126 const override = yield* Config.String("T3CODE_RELAY_CLIENT_CONFIG_ENV").pipe(Config.option);
127 const repoRootEnv = path.fromFileUrl(new URL("../../../.env", import.meta.url));
128 const target = Option.isSome(override) ? override.value : yield* repoRootEnv;
129 return Effect.fn(function* (input: RelayClientConfig) {
130 const url = input.url;
131 if (url === undefined) return yield* new RelayUrlUnavailableError();
132 const entries = relayClientConfigEnv({ ...input, url });
133 // Provider responses are copied in verbatim; a line break in one would
134 // become an extra assignment.
135 for (const [name, value] of Object.entries(entries)) {
136 if (/[\r\n]/u.test(value)) return yield* new EnvValueNotSingleLineError({ name });
137 }
138 const existing = (yield* fs.exists(target)) ? yield* fs.readFileString(target) : "";
139 yield* fs.writeFileString(target, reconcileEnvFile(existing, entries));
140 yield* Console.log(`Wrote relay client configuration to ${target}`);
141 return { path: target };
142 });
143 }),
144);