| 1 | import * as appconfigdata from "@distilled.cloud/aws/appconfigdata"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Layer from "effect/Layer"; |
| 4 | import * as Ref from "effect/Ref"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | import * as Binding from "../../Binding.ts"; |
| 7 | import * as Output from "../../Output.ts"; |
| 8 | import { toWireSeconds } from "../../Util/Duration.ts"; |
| 9 | import { AWSEnvironment } from "../Environment.ts"; |
| 10 | import { isBindingHost } from "../Lambda/Function.ts"; |
| 11 | import type { Application } from "./Application.ts"; |
| 12 | import type { ConfigurationProfile } from "./ConfigurationProfile.ts"; |
| 13 | import type { Environment } from "./Environment.ts"; |
| 14 | import { |
| 15 | GetConfiguration, |
| 16 | type GetConfigurationOptions, |
| 17 | } from "./GetConfiguration.ts"; |
| 18 | |
| 19 | interface SessionCache { |
| 20 | token: string | undefined; |
| 21 | lastContent: string | undefined; |
| 22 | lastContentType: string | undefined; |
| 23 | lastVersionLabel: string | undefined; |
| 24 | } |
| 25 | |
| 26 | /** |
| 27 | * HTTP implementation of the {@link GetConfiguration} binding. Calls the |
| 28 | * AppConfig data plane (`StartConfigurationSession` + |
| 29 | * `GetLatestConfiguration`) with the Lambda's IAM role, caching the poll |
| 30 | * token and last-seen content across calls. |
| 31 | * |
| 32 | * Provide it on the hosting Lambda function's Effect so the binding is |
| 33 | * available at runtime: |
| 34 | * |
| 35 | * @example |
| 36 | * ```typescript |
| 37 | * export default MyFunction.make( |
| 38 | * { main: import.meta.url }, |
| 39 | * Effect.gen(function* () { |
| 40 | * const getConfig = yield* AppConfig.GetConfiguration(app, env, profile); |
| 41 | * return { |
| 42 | * fetch: Effect.gen(function* () { |
| 43 | * const { content } = yield* getConfig().pipe(Effect.orDie); |
| 44 | * return HttpServerResponse.text(content ?? ""); |
| 45 | * }), |
| 46 | * }; |
| 47 | * }).pipe(Effect.provide(AppConfig.GetConfigurationHttp)), |
| 48 | * ); |
| 49 | * ``` |
| 50 | */ |
| 51 | export const GetConfigurationHttp = Layer.effect( |
| 52 | GetConfiguration, |
| 53 | Effect.gen(function* () { |
| 54 | const startSession = yield* appconfigdata.startConfigurationSession; |
| 55 | const getLatest = yield* appconfigdata.getLatestConfiguration; |
| 56 | |
| 57 | return Effect.fn(function* ( |
| 58 | application: Application, |
| 59 | environment: Environment, |
| 60 | configurationProfile: ConfigurationProfile, |
| 61 | options?: GetConfigurationOptions, |
| 62 | ) { |
| 63 | // Outputs yield DEFERRED effects — resolve again per invocation below. |
| 64 | const ApplicationId = yield* application.applicationId; |
| 65 | const EnvironmentId = yield* environment.environmentId; |
| 66 | const ConfigurationProfileId = |
| 67 | yield* configurationProfile.configurationProfileId; |
| 68 | |
| 69 | if (!globalThis.__ALCHEMY_RUNTIME__) { |
| 70 | const host = yield* Binding.Host; |
| 71 | if (isBindingHost(host)) { |
| 72 | const { accountId, region } = |
| 73 | yield* AWSEnvironment.current as unknown as Effect.Effect<{ |
| 74 | accountId: string; |
| 75 | region: string; |
| 76 | }>; |
| 77 | yield* host.bind`Allow(${host}, AWS.AppConfig.GetConfiguration(${application}, ${environment}, ${configurationProfile}))`( |
| 78 | { |
| 79 | policyStatements: [ |
| 80 | { |
| 81 | Effect: "Allow", |
| 82 | Action: [ |
| 83 | "appconfig:StartConfigurationSession", |
| 84 | "appconfig:GetLatestConfiguration", |
| 85 | ], |
| 86 | Resource: [ |
| 87 | Output.interpolate`arn:aws:appconfig:${region}:${accountId}:application/${application.applicationId}/environment/${environment.environmentId}/configuration/${configurationProfile.configurationProfileId}`, |
| 88 | ], |
| 89 | }, |
| 90 | ], |
| 91 | }, |
| 92 | ); |
| 93 | } |
| 94 | } |
| 95 | |
| 96 | const cache = yield* Ref.make<SessionCache>({ |
| 97 | token: undefined, |
| 98 | lastContent: undefined, |
| 99 | lastContentType: undefined, |
| 100 | lastVersionLabel: undefined, |
| 101 | }); |
| 102 | |
| 103 | return Effect.fn( |
| 104 | `AWS.AppConfig.GetConfiguration(${application.LogicalId}, ${environment.LogicalId}, ${configurationProfile.LogicalId})`, |
| 105 | )(function* () { |
| 106 | const applicationId = yield* ApplicationId; |
| 107 | const environmentId = yield* EnvironmentId; |
| 108 | const configurationProfileId = yield* ConfigurationProfileId; |
| 109 | |
| 110 | let state = yield* Ref.get(cache); |
| 111 | if (state.token === undefined) { |
| 112 | const session = yield* startSession({ |
| 113 | ApplicationIdentifier: applicationId, |
| 114 | EnvironmentIdentifier: environmentId, |
| 115 | ConfigurationProfileIdentifier: configurationProfileId, |
| 116 | RequiredMinimumPollIntervalInSeconds: toWireSeconds( |
| 117 | options?.requiredMinimumPollInterval, |
| 118 | ), |
| 119 | }); |
| 120 | state = { ...state, token: session.InitialConfigurationToken }; |
| 121 | } |
| 122 | |
| 123 | const response = yield* getLatest({ |
| 124 | ConfigurationToken: state.token!, |
| 125 | }); |
| 126 | |
| 127 | // Each token is single-use; the response hands back the next one. |
| 128 | const nextToken = response.NextPollConfigurationToken ?? state.token; |
| 129 | |
| 130 | // Decoding the (already-buffered) config body only fails on a |
| 131 | // corrupt payload — a defect, not part of the operation's typed union. |
| 132 | const fetched = response.Configuration |
| 133 | ? yield* Stream.mkString( |
| 134 | Stream.decodeText(response.Configuration), |
| 135 | ).pipe(Effect.orDie) |
| 136 | : ""; |
| 137 | |
| 138 | // An empty body means "unchanged" — keep the last-seen content. |
| 139 | const content = fetched.length > 0 ? fetched : state.lastContent; |
| 140 | const contentType = response.ContentType ?? state.lastContentType; |
| 141 | const versionLabel = response.VersionLabel ?? state.lastVersionLabel; |
| 142 | |
| 143 | yield* Ref.set(cache, { |
| 144 | token: nextToken, |
| 145 | lastContent: content, |
| 146 | lastContentType: contentType, |
| 147 | lastVersionLabel: versionLabel, |
| 148 | }); |
| 149 | |
| 150 | return { content, contentType, versionLabel }; |
| 151 | }); |
| 152 | }); |
| 153 | }), |
| 154 | ); |