Open History to see the last commit
1import type * as Context from "effect/Context";
2import * as Effect from "effect/Effect";
3import { identity } from "effect/Function";
4import * as Layer from "effect/Layer";
5import * as Option from "effect/Option";
6import * as Predicate from "effect/Predicate";
7import type { Scope } from "effect/Scope";
8import * as Stream from "effect/Stream";
9import { AlchemyContext } from "../AlchemyContext.ts";
10import { Artifacts, ArtifactStore, makeScopedArtifacts } from "../Artifacts.ts";
11import type { Platform } from "../Platform.ts";
12import * as Provider from "../Provider.ts";
13import type { ResourceClassLike, ResourceLike } from "../Resource.ts";
14import { ResourceContext } from "../ResourceContext.ts";
15import { Stack } from "../Stack.ts";
16import { Stage } from "../Stage.ts";
17import { LogAnnotation } from "../Util/LogAnnotation.ts";
18import { RpcProviderProxy } from "./RpcProviderProxy.ts";
19
20/**
21 * Constructs an RpcProvider for a given resource.
22 *
23 * An RpcProvider is a resource provider that can run in a separate process.
24 * This is used for local development so that the resource provider and its state can outlive the user's code when it is hot-reloaded.
25 * If the {@link RpcProviderProxy} is provided, the provider is run in a separate process, which we communicate with here using a websocket.
26 * Otherwise, the given effect is used to construct the provider directly.
27 *
28 * @note If the provider is running in a separate process, you may not need to construct all of its layers in this process.
29 * You can use {@link providerServices} or {@link providerServicesEffect} to construct such layers only when they are needed.
30 *
31 * @example
32 * ```ts
33 * export const MyProvider = RpcProvider.effect(
34 * MyResource,
35 * import.meta.resolve("./my-rpc-provider-server.ts"),
36 * Effect.gen(function* () {
37 * return {
38 * diff: ...,
39 * reconcile: ...,
40 * delete: ...,
41 * }
42 * })
43 * )
44 * ```
45 *
46 * @param cls - The tag of the resource class to construct a provider for.
47 * @param providersUrl - URL of the module whose default export is the provider group's layer (the layer this provider is registered in). When the provider runs in the dev sidecar, the sidecar imports that module on first use — see `Local/Sidecar.ts`. Typically `import.meta.resolve("./Local.ts")`.
48 * @param eff - The Effect to use to construct the provider.
49 * @returns A layer containing the RpcProvider.
50 */
51/**
52 * The provider shape accepted by {@link effect}. It is identical to
53 * {@link Provider.ProviderService} except that `list` is **optional**: local /
54 * dev RPC providers frequently have no cloud enumeration API, so the factory
55 * supplies a safe `() => Effect.succeed([])` default when one is not given. A
56 * provider that *can* enumerate its in-memory/local runtime state (e.g.
57 * `LocalWorkerProvider` enumerating its running instances) just defines `list`
58 * and the factory threads it straight through.
59 */
60type RpcProviderService<
61 R extends ResourceLike,
62 ReadReq = never,
63 DiffReq = never,
64 PrecreateReq = never,
65 ReconcileReq = never,
66 DeleteReq = never,
67 TailReq = never,
68 LogsReq = never,
69 ListReq = never,
70> = Omit<
71 Provider.ProviderService<
72 R,
73 ReadReq,
74 DiffReq,
75 PrecreateReq,
76 ReconcileReq,
77 DeleteReq,
78 TailReq,
79 LogsReq,
80 ListReq
81 >,
82 "list"
83> &
84 Partial<
85 Pick<
86 Provider.ProviderService<
87 R,
88 ReadReq,
89 DiffReq,
90 PrecreateReq,
91 ReconcileReq,
92 DeleteReq,
93 TailReq,
94 LogsReq,
95 ListReq
96 >,
97 "list"
98 >
99 >;
100
101/**
102 * Ensures a provider satisfies the now-required `list()` contract. If the
103 * provider already implements `list` (enumerating its local runtime state) it
104 * is returned untouched; otherwise a safe default that enumerates nothing
105 * (`() => Effect.succeed([])`) is injected. Local/dev providers have no cloud
106 * enumeration API, so `[]` is the correct default — never throw.
107 */
108const withDefaultList = <
109 R extends ResourceLike,
110 ReadReq,
111 DiffReq,
112 PrecreateReq,
113 ReconcileReq,
114 DeleteReq,
115 TailReq,
116 LogsReq,
117 ListReq,
118>(
119 provider: RpcProviderService<
120 R,
121 ReadReq,
122 DiffReq,
123 PrecreateReq,
124 ReconcileReq,
125 DeleteReq,
126 TailReq,
127 LogsReq,
128 ListReq
129 >,
130): Provider.ProviderService<
131 R,
132 ReadReq,
133 DiffReq,
134 PrecreateReq,
135 ReconcileReq,
136 DeleteReq,
137 TailReq,
138 LogsReq,
139 ListReq
140> =>
141 (provider.list
142 ? provider
143 : {
144 ...provider,
145 list: () => Effect.succeed([]),
146 }) as Provider.ProviderService<
147 R,
148 ReadReq,
149 DiffReq,
150 PrecreateReq,
151 ReconcileReq,
152 DeleteReq,
153 TailReq,
154 LogsReq,
155 ListReq
156 >;
157
158export const effect = <
159 R extends ResourceLike,
160 Req = never,
161 ReadReq = never,
162 DiffReq = never,
163 PrecreateReq = never,
164 ReconcileReq = never,
165 DeleteReq = never,
166 TailReq = never,
167 LogsReq = never,
168 ListReq = never,
169>(
170 cls: ResourceClassLike<R> | Platform<R, any, any, any, any, any>,
171 providersUrl: string,
172 eff: Effect.Effect<
173 RpcProviderService<
174 R,
175 ReadReq,
176 DiffReq,
177 PrecreateReq,
178 ReconcileReq,
179 DeleteReq,
180 TailReq,
181 LogsReq,
182 ListReq
183 >,
184 never,
185 Req
186 >,
187) =>
188 Provider.effect(
189 cls,
190 Effect.gen(function* () {
191 const client = yield* Effect.serviceOption(RpcProviderProxy);
192 const context = yield* Effect.context();
193 const stack = yield* Stack;
194 const store = yield* ArtifactStore;
195
196 if (client._tag === "None") {
197 const provider = withDefaultList(yield* eff);
198 return new Proxy(provider, {
199 get: (target, prop) => {
200 const value = (target as any)[prop];
201 if (!Predicate.isFunction(value)) return value;
202 return (...args: any[]) => {
203 const result = value(...args);
204 const services = Layer.mergeAll(
205 Layer.succeedContext(context),
206 layerFallback(Stack, stack),
207 layerFallback(Stage, stack.stage),
208 Predicate.hasProperty(args[0], "instanceId") &&
209 Predicate.isString(args[0].instanceId)
210 ? Layer.merge(
211 Predicate.hasProperty(args[0], "id") &&
212 Predicate.isString(args[0].id) &&
213 Predicate.hasProperty(args[0], "fqn") &&
214 Predicate.isString(args[0].fqn)
215 ? layerFallback(ResourceContext, {
216 logicalId: args[0].id,
217 fqn: args[0].fqn,
218 instanceId: args[0].instanceId,
219 type: cls.Type,
220 })
221 : Layer.empty,
222 Layer.succeed(Artifacts, makeScopedArtifacts(store, args[0].instanceId)),
223 )
224 : Layer.empty,
225 );
226 if (Stream.isStream(result)) return result.pipe(Stream.provide(services));
227 // In a sidecar the engine's per-resource annotation doesn't
228 // cross the RPC boundary: tag the call's logs here.
229 const fqn =
230 Predicate.hasProperty(args[0], "fqn") && Predicate.isString(args[0].fqn)
231 ? args[0].fqn
232 : undefined;
233 return result.pipe(
234 Effect.provide(services),
235 fqn === undefined ? identity : Effect.annotateLogs(LogAnnotation.fqn, fqn),
236 );
237 };
238 },
239 });
240 }
241 const remote = withDefaultList(yield* client.value.get(providersUrl, cls.Type));
242 // The sidecar session is keyed by the Stack seen when it was opened, and
243 // derives stack-scoped state (e.g. ownership tags) from it. A memoized
244 // provider layer can be reused by a different stack (a test's shared
245 // scratch stack), so resolve the remote provider for the calling stack.
246 const byStack = new Map<string, typeof remote>([[stackKey(stack), remote]]);
247 const forStack = (current: Stack["Service"]) =>
248 Effect.suspend(() => {
249 const cached = byStack.get(stackKey(current));
250 if (cached !== undefined) return Effect.succeed(cached);
251 return client.value.get(providersUrl, cls.Type).pipe(
252 Effect.map((provider) => {
253 const resolved = withDefaultList(provider);
254 byStack.set(stackKey(current), resolved);
255 return resolved;
256 }),
257 // The calling Stack must shadow the one captured in `context`.
258 Effect.provideService(Stack, current),
259 Effect.provide(Layer.succeedContext(context)),
260 );
261 });
262 const callingProvider = Effect.serviceOption(Stack).pipe(
263 Effect.flatMap((current) =>
264 Option.isNone(current) || stackKey(current.value) === stackKey(stack)
265 ? Effect.succeed(remote)
266 : forStack(current.value),
267 ),
268 );
269 return new Proxy(remote, {
270 get: (target, prop) => {
271 const value = (target as any)[prop];
272 if (!Predicate.isFunction(value)) return value;
273 return (...args: any[]) =>
274 prop === "tail"
275 ? Stream.unwrap(
276 Effect.map(callingProvider, (provider) => (provider as any)[prop](...args)),
277 )
278 : Effect.flatMap(callingProvider, (provider) => (provider as any)[prop](...args));
279 },
280 });
281 }),
282 );
283
284const stackKey = (stack: { readonly name: string; readonly stage: string }) =>
285 `${stack.name}\u0000${stack.stage}`;
286
287const layerFallback = <I, S>(service: Context.Key<I, S>, defaultValue: NoInfer<S>) =>
288 Layer.effect(
289 service,
290 Effect.serviceOption(service).pipe(Effect.map(Option.getOrElse(() => defaultValue))),
291 );
292
293/**
294 * Conditionally constructs a layer for use by an RpcProvider.
295 * If the {@link RpcProviderProxy} is present in context, the layer is empty because it will not be used in this process.
296 * Otherwise, the given layer is returned.
297 * @param self - The layer that is used by the RpcProvider.
298 * @returns A layer that is empty if the {@link RpcProviderProxy} is present in context, otherwise the given layer.
299 */
300export const providerServices = <ROut, E, RIn>(
301 self: Layer.Layer<ROut, E, RIn>,
302): Layer.Layer<ROut, E, RIn | AlchemyContext> => self.pipe(Effect.succeed, providerServicesEffect);
303
304/**
305 * Conditionally constructs a layer for use by an RpcProvider.
306 * If the {@link RpcProviderProxy} is present in context, the layer is empty because it will not be used in this process.
307 * Otherwise, the given layer is returned.
308 *
309 * Note that unlike earlier versions this is no longer gated on
310 * `AlchemyContext.dev`: local providers are registered via
311 * `ProviderLayer.dual` and their dependency layers are composed inside the
312 * *lazily built* local variant, so in a live run these services are only
313 * constructed when the local provider is actually demanded (e.g. deleting a
314 * `providerMode: "local"` state row during `alchemy deploy`).
315 * @param self - An effect which returns a layer that is used by the RpcProvider.
316 */
317export const providerServicesEffect = <A, E1, R1, E, R>(
318 self: Effect.Effect<Layer.Layer<A, E1, R1>, E, R>,
319): Layer.Layer<A, E | E1, R1 | Exclude<R, Scope> | AlchemyContext> =>
320 Effect.serviceOption(RpcProviderProxy).pipe(
321 Effect.flatMap((client) =>
322 client._tag === "None" ? self : (Effect.succeed(Layer.empty) as never),
323 ),
324 Layer.unwrap,
325 );