1import * as appconfigdata from "@distilled.cloud/aws/appconfigdata";
2import * as Effect from "effect/Effect";
3import * as Layer from "effect/Layer";
4import * as Ref from "effect/Ref";
5import * as Stream from "effect/Stream";
6import * as Binding from "../../Binding.ts";
7import * as Output from "../../Output.ts";
8import { toWireSeconds } from "../../Util/Duration.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import { isBindingHost } from "../Lambda/Function.ts";
11import type { Application } from "./Application.ts";
12import type { ConfigurationProfile } from "./ConfigurationProfile.ts";
13import type { Environment } from "./Environment.ts";
14import {
15 GetConfiguration,
16 type GetConfigurationOptions,
17} from "./GetConfiguration.ts";
18
19interface 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 */
51export 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);