1/** @effect-diagnostics anyUnknownInErrorContext:off */
2
3/**
4 * The `alchemy dev` provider for `AWS.Lambda.Function`: deploys the function
5 * into the floci emulator and hot-swaps its code on file change.
6 *
7 * Built on the shared dev-watch skeleton
8 * ([DevWatchProvider](../Local/DevWatchProvider.ts)), which owns the
9 * RPC-sidecar hosting, the live-provider delegation into the floci override
10 * context, the instanceId-guarded watch registry, and the never-bundling dev
11 * diff policy. This file supplies only the Lambda-specific parts:
12 *
13 * - **Watch loop** — a forked `Bundle.watch` (same rolldown config the
14 * deploy used, via [FunctionBundle](./FunctionBundle.ts)) rebuilds on file
15 * change and uploads the fresh archive to a stable per-function S3 key in
16 * the emulator's assets bucket. The first swap repoints the function's
17 * code at that key with `UpdateFunctionCode`, which enrolls floci's
18 * reactive S3 sync; every later swap is a bare `PutObject` (measured
19 * 111–138 ms re-extract, warm containers drained, no stale invoke) —
20 * until an engine reconcile re-points the function at a content-addressed
21 * key, after which the next swap re-enrolls it (see `enrolledAttrs`).
22 * `bundle: false` props fs-watch the prebuilt directory instead.
23 * - **Replacement rules** — functionName and durableConfig-presence changes
24 * replace, mirroring the live diff.
25 * - **Precreate pinning** — floci validates the Handler FILE against the
26 * package at CreateFunction (AWS defers that to first invoke); the 503
27 * stub ships a single `index.*` module, so handler-affecting props are
28 * pinned to the stub's own shape.
29 */
30
31import * as Lambda from "@distilled.cloud/aws/lambda";
32import * as s3 from "@distilled.cloud/aws/s3";
33import * as Effect from "effect/Effect";
34import * as FileSystem from "effect/FileSystem";
35import * as Schedule from "effect/Schedule";
36import * as Stream from "effect/Stream";
37import * as Bundle from "../../Bundle/Bundle.ts";
38import * as TempRoot from "../../Bundle/TempRoot.ts";
39import { Assets, AssetsLive } from "../Assets.ts";
40import {
41 flociProvidersUrl,
42 makeDevWatchProvider,
43} from "../Local/DevWatchProvider.ts";
44import {
45 Function,
46 FunctionProvider,
47 layerVersionArnOf,
48 type FunctionImageProps,
49 type FunctionProps,
50} from "./Function.ts";
51import {
52 makeFunctionBundler,
53 type FunctionBundleResult,
54} from "./FunctionBundle.ts";
55
56const isFunctionImageProps = (
57 props: FunctionProps,
58): props is FunctionImageProps => props.image !== undefined;
59
60export const FlociFunctionProvider = () =>
61 makeDevWatchProvider<Function, FunctionProps, Function["Attributes"]>(
62 Function,
63 flociProvidersUrl(),
64 {
65 liveProvider: () => FunctionProvider(),
66 // A FRESH `Assets` instance: its cached bucket lookup must resolve
67 // against the emulator (auto-bootstrapped there) and can never share a
68 // cache with the live arm's instance.
69 services: AssetsLive,
70 // The restart surface of the watch loop: everything that changes WHAT
71 // the watcher builds or WHERE it uploads. `build` may carry plugin
72 // closures, which degrade to stable placeholders under canonical
73 // hashing.
74 watchConfigOf: (news, attrs) =>
75 isFunctionImageProps(news)
76 ? {
77 functionName: attrs.functionName,
78 image: news.image,
79 architecture: news.architecture,
80 }
81 : {
82 functionName: attrs.functionName,
83 main: news.main,
84 handler: news.handler,
85 bundle: news.bundle,
86 isExternal: news.isExternal,
87 runtime: news.runtime,
88 architecture: news.architecture,
89 uploadSourceMap: news.uploadSourceMap,
90 build: news.build,
91 },
92 // Mirrors the live diff's replacement rules.
93 replaceOn: ({ olds, news, output }) =>
94 Effect.sync(() => {
95 const newFunctionName = news.functionName ?? output.functionName;
96 if (output.functionName !== newFunctionName) {
97 return { action: "replace" as const };
98 }
99 if (isFunctionImageProps(olds) !== isFunctionImageProps(news)) {
100 return {
101 action: "replace" as const,
102 deleteFirst: news.functionName !== undefined,
103 };
104 }
105 if (
106 !isFunctionImageProps(olds) &&
107 !isFunctionImageProps(news) &&
108 !!olds.durableConfig !== !!news.durableConfig
109 ) {
110 return {
111 action: "replace" as const,
112 deleteFirst: news.functionName !== undefined,
113 };
114 }
115 return undefined;
116 }),
117 normalizeProps: (props) =>
118 isFunctionImageProps(props)
119 ? props
120 : {
121 ...props,
122 layers: (props.layers ?? []).map(layerVersionArnOf),
123 },
124 // Floci validates the Handler FILE against the package at
125 // CreateFunction (AWS defers that to first invoke). The precreate 503
126 // stub ships a single `index.*` module, so a `bundle: false` /
127 // custom-handler props set (`handler.handler`) would be rejected — pin
128 // the stub's handler-affecting props to the stub's own shape;
129 // `reconcile` applies the real Handler together with the real package.
130 transformPrecreateNews: (news) =>
131 isFunctionImageProps(news)
132 ? news
133 : {
134 ...news,
135 bundle: undefined,
136 isExternal: undefined,
137 handler: undefined,
138 },
139 startWatch: (ctx) =>
140 Effect.gen(function* () {
141 const props = ctx.news;
142 if (isFunctionImageProps(props)) {
143 // Image functions are converged by the delegated live provider.
144 // There is no ZIP module graph to watch or hot-swap.
145 return;
146 }
147 const functionName = ctx.attrs.functionName;
148 const bundler = yield* makeFunctionBundler;
149 const assets = yield* Assets;
150 let lastHash = ctx.attrs.code?.hash ?? "";
151 // The attributes object that was current when the watch loop
152 // last pointed the function at the stable dev key. Every
153 // engine-driven reconcile (a binding added, an env var changed,
154 // …) re-uploads the bundle to a content-addressed key and
155 // re-points the function THERE, silently detaching it from the
156 // dev key: floci's reactive S3 sync only follows the key the
157 // function is enrolled at, so every later PutObject would be
158 // ignored and hot swap would be dead until the next engine
159 // update. The skeleton hands the watcher a fresh attrs object per
160 // reconcile, so identity (not the code hash — a source edited
161 // back to its original content hashes the same) is the signal
162 // that a re-point happened and the function must be re-enrolled.
163 let enrolledAttrs: Function["Attributes"] | undefined;
164 let startedAt = Date.now();
165
166 const swap = Effect.fn(function* (result: FunctionBundleResult) {
167 if (result.identityHash === lastHash) return;
168 const { archive } = yield* result.buildArchive;
169 const bucket = yield* assets.bucketName;
170 const key = `lambda/dev/${functionName}.zip`;
171 yield* s3.putObject({
172 Bucket: bucket,
173 Key: key,
174 Body: archive,
175 ContentType: "application/zip",
176 });
177 const current = yield* ctx.currentAttrs;
178 if (enrolledAttrs !== current) {
179 // First swap, or the engine re-pointed the function since the
180 // last one: (re)point its code at the stable dev key — floci's
181 // reactive S3 sync then re-extracts on every subsequent
182 // PutObject with no further Lambda API calls.
183 yield* Lambda.updateFunctionCode({
184 FunctionName: functionName,
185 S3Bucket: bucket,
186 S3Key: key,
187 }).pipe(
188 Effect.retry({
189 while: (e): boolean => e._tag === "ResourceConflictException",
190 schedule: Schedule.exponential(50),
191 times: 8,
192 }),
193 );
194 enrolledAttrs = current;
195 }
196 lastHash = result.identityHash;
197 yield* Effect.logInfo(
198 `[alchemy dev] ${functionName}: code swapped in ${Date.now() - startedAt}ms`,
199 );
200 });
201
202 if (props.bundle === false) {
203 // Prebuilt directory: no module graph to watch — fs-watch the
204 // directory and re-zip it as-is (exactly like `prebuiltCode`).
205 const fs = yield* FileSystem.FileSystem;
206 const realMain = yield* TempRoot.resolveMainPath(props.main);
207 const dir = realMain.slice(0, realMain.lastIndexOf("/"));
208 yield* fs.watch(dir, { recursive: true }).pipe(
209 Stream.debounce("200 millis"),
210 Stream.runForEach(() =>
211 Effect.gen(function* () {
212 startedAt = Date.now();
213 const result = yield* bundler.prebuiltCode(realMain);
214 yield* swap(result);
215 }).pipe(
216 Effect.catchCause((cause) =>
217 Effect.logWarning(
218 `[alchemy dev] ${functionName}: code swap failed`,
219 cause,
220 ),
221 ),
222 ),
223 ),
224 );
225 } else {
226 // The exact rolldown config the deploy used — incremental
227 // rebuilds produce the identical artifact shape.
228 const plan = yield* bundler.resolveBundlePlan(props);
229 yield* Bundle.watch(
230 plan.inputOptions,
231 plan.outputOptions,
232 plan.extra,
233 ).pipe(
234 Stream.runForEach((event) =>
235 Effect.gen(function* () {
236 switch (event._tag) {
237 case "Start": {
238 startedAt = Date.now();
239 return;
240 }
241 case "Error": {
242 yield* Effect.logWarning(
243 `[alchemy dev] ${functionName}: rebuild failed: ${event.error.message}`,
244 );
245 return;
246 }
247 case "Success": {
248 const result = yield* bundler.finishBundle(
249 plan,
250 event.output,
251 );
252 yield* swap(result);
253 }
254 }
255 }).pipe(
256 Effect.catchCause((cause) =>
257 Effect.logWarning(
258 `[alchemy dev] ${functionName}: code swap failed`,
259 cause,
260 ),
261 ),
262 ),
263 ),
264 );
265 }
266 }),
267 },
268 );