| 1 | // @effect-diagnostics nodeBuiltinImport:off - one sha256 over two strings; Effect.Crypto is async and the digest feeds a synchronous Output.map. |
| 2 | import * as NodeCrypto from "node:crypto"; |
| 3 | |
| 4 | import * as Alchemy from "alchemy"; |
| 5 | import * as Config from "effect/Config"; |
| 6 | import * as Console from "effect/Console"; |
| 7 | import * as Effect from "effect/Effect"; |
| 8 | import * as FileSystem from "effect/FileSystem"; |
| 9 | import * as Option from "effect/Option"; |
| 10 | import * as Path from "effect/Path"; |
| 11 | import * as Redacted from "effect/Redacted"; |
| 12 | import * as Schema from "effect/Schema"; |
| 13 | |
| 14 | /** The relay outputs a client (web, desktop, mobile) needs at build time. */ |
| 15 | export 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 | |
| 33 | export 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 | |
| 42 | export 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 | |
| 53 | export 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 | */ |
| 68 | export 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. */ |
| 108 | const 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 | */ |
| 118 | export const tokenDigest = (tokens: ReadonlyArray<Redacted.Redacted<string>>): string => |
| 119 | NodeCrypto.createHash("sha256").update(tokens.map(Redacted.value).join("\n")).digest("hex"); |
| 120 | |
| 121 | export 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 | ); |