alchemy 2.0.0-beta.35 → 2.0.0-beta.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/bin/exec.js +176 -26
  2. package/bin/exec.js.map +1 -1
  3. package/lib/AWS/Lambda/Function.d.ts.map +1 -1
  4. package/lib/AWS/Lambda/Function.js +22 -2
  5. package/lib/AWS/Lambda/Function.js.map +1 -1
  6. package/lib/Apply.d.ts.map +1 -1
  7. package/lib/Apply.js +6 -1
  8. package/lib/Apply.js.map +1 -1
  9. package/lib/Cli/main.d.ts.map +1 -1
  10. package/lib/Cli/main.js +2 -0
  11. package/lib/Cli/main.js.map +1 -1
  12. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.d.ts +54 -0
  13. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.d.ts.map +1 -0
  14. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.js +17 -0
  15. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.js.map +1 -0
  16. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.d.ts +37 -0
  17. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.d.ts.map +1 -0
  18. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.js +45 -0
  19. package/lib/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.js.map +1 -0
  20. package/lib/Cloudflare/AnalyticsEngine/index.d.ts +3 -0
  21. package/lib/Cloudflare/AnalyticsEngine/index.d.ts.map +1 -0
  22. package/lib/Cloudflare/AnalyticsEngine/index.js +3 -0
  23. package/lib/Cloudflare/AnalyticsEngine/index.js.map +1 -0
  24. package/lib/Cloudflare/D1/D1Connection.d.ts +47 -3
  25. package/lib/Cloudflare/D1/D1Connection.d.ts.map +1 -1
  26. package/lib/Cloudflare/D1/D1Connection.js +62 -5
  27. package/lib/Cloudflare/D1/D1Connection.js.map +1 -1
  28. package/lib/Cloudflare/Images/Images.d.ts +103 -0
  29. package/lib/Cloudflare/Images/Images.d.ts.map +1 -0
  30. package/lib/Cloudflare/Images/Images.js +82 -0
  31. package/lib/Cloudflare/Images/Images.js.map +1 -0
  32. package/lib/Cloudflare/Images/ImagesBinding.d.ts +73 -0
  33. package/lib/Cloudflare/Images/ImagesBinding.d.ts.map +1 -0
  34. package/lib/Cloudflare/Images/ImagesBinding.js +83 -0
  35. package/lib/Cloudflare/Images/ImagesBinding.js.map +1 -0
  36. package/lib/Cloudflare/Images/index.d.ts +3 -0
  37. package/lib/Cloudflare/Images/index.d.ts.map +1 -0
  38. package/lib/Cloudflare/Images/index.js +3 -0
  39. package/lib/Cloudflare/Images/index.js.map +1 -0
  40. package/lib/Cloudflare/Local/SidecarHandlers.js +9 -1
  41. package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -1
  42. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  43. package/lib/Cloudflare/Providers.js +6 -1
  44. package/lib/Cloudflare/Providers.js.map +1 -1
  45. package/lib/Cloudflare/R2/R2Bucket.d.ts +135 -0
  46. package/lib/Cloudflare/R2/R2Bucket.d.ts.map +1 -1
  47. package/lib/Cloudflare/R2/R2Bucket.js +146 -7
  48. package/lib/Cloudflare/R2/R2Bucket.js.map +1 -1
  49. package/lib/Cloudflare/StateStore/Api.d.ts +6 -1
  50. package/lib/Cloudflare/StateStore/Api.d.ts.map +1 -1
  51. package/lib/Cloudflare/StateStore/Api.js +23 -1
  52. package/lib/Cloudflare/StateStore/Api.js.map +1 -1
  53. package/lib/Cloudflare/StateStore/Store.d.ts +16 -1
  54. package/lib/Cloudflare/StateStore/Store.d.ts.map +1 -1
  55. package/lib/Cloudflare/StateStore/Store.js +22 -5
  56. package/lib/Cloudflare/StateStore/Store.js.map +1 -1
  57. package/lib/Cloudflare/Workers/CronEventSource.d.ts +35 -0
  58. package/lib/Cloudflare/Workers/CronEventSource.d.ts.map +1 -0
  59. package/lib/Cloudflare/Workers/CronEventSource.js +55 -0
  60. package/lib/Cloudflare/Workers/CronEventSource.js.map +1 -0
  61. package/lib/Cloudflare/Workers/InferEnv.d.ts +37 -2
  62. package/lib/Cloudflare/Workers/InferEnv.d.ts.map +1 -1
  63. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -1
  64. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +2 -1
  65. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -1
  66. package/lib/Cloudflare/Workers/Rpc.d.ts +76 -3
  67. package/lib/Cloudflare/Workers/Rpc.d.ts.map +1 -1
  68. package/lib/Cloudflare/Workers/Rpc.js +197 -13
  69. package/lib/Cloudflare/Workers/Rpc.js.map +1 -1
  70. package/lib/Cloudflare/Workers/Worker.d.ts +26 -6
  71. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  72. package/lib/Cloudflare/Workers/Worker.js +183 -41
  73. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  74. package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -1
  75. package/lib/Cloudflare/Workers/WorkerBundle.js +15 -5
  76. package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -1
  77. package/lib/Cloudflare/Workers/Workflow.d.ts +50 -47
  78. package/lib/Cloudflare/Workers/Workflow.d.ts.map +1 -1
  79. package/lib/Cloudflare/Workers/Workflow.js +41 -35
  80. package/lib/Cloudflare/Workers/Workflow.js.map +1 -1
  81. package/lib/Cloudflare/Workers/index.d.ts +1 -0
  82. package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
  83. package/lib/Cloudflare/Workers/index.js +1 -0
  84. package/lib/Cloudflare/Workers/index.js.map +1 -1
  85. package/lib/Cloudflare/index.d.ts +2 -0
  86. package/lib/Cloudflare/index.d.ts.map +1 -1
  87. package/lib/Cloudflare/index.js +2 -0
  88. package/lib/Cloudflare/index.js.map +1 -1
  89. package/lib/Deploy.d.ts +4 -1
  90. package/lib/Deploy.d.ts.map +1 -1
  91. package/lib/Deploy.js +2 -2
  92. package/lib/Deploy.js.map +1 -1
  93. package/lib/Destroy.d.ts +4 -1
  94. package/lib/Destroy.d.ts.map +1 -1
  95. package/lib/Destroy.js +2 -2
  96. package/lib/Destroy.js.map +1 -1
  97. package/lib/Output.d.ts +45 -5
  98. package/lib/Output.d.ts.map +1 -1
  99. package/lib/Output.js +100 -4
  100. package/lib/Output.js.map +1 -1
  101. package/lib/Plan.d.ts.map +1 -1
  102. package/lib/Plan.js +93 -12
  103. package/lib/Plan.js.map +1 -1
  104. package/lib/Platform.d.ts.map +1 -1
  105. package/lib/Platform.js +16 -3
  106. package/lib/Platform.js.map +1 -1
  107. package/lib/Ref.d.ts +4 -1
  108. package/lib/Ref.d.ts.map +1 -1
  109. package/lib/Ref.js +1 -1
  110. package/lib/Ref.js.map +1 -1
  111. package/lib/Resource.d.ts +8 -0
  112. package/lib/Resource.d.ts.map +1 -1
  113. package/lib/Resource.js +10 -0
  114. package/lib/Resource.js.map +1 -1
  115. package/lib/RuntimeContext.d.ts +3 -1
  116. package/lib/RuntimeContext.d.ts.map +1 -1
  117. package/lib/RuntimeContext.js.map +1 -1
  118. package/lib/Secret.d.ts +50 -0
  119. package/lib/Secret.d.ts.map +1 -0
  120. package/lib/Secret.js +23 -0
  121. package/lib/Secret.js.map +1 -0
  122. package/lib/Serverless/Function.d.ts +9 -1
  123. package/lib/Serverless/Function.d.ts.map +1 -1
  124. package/lib/Stack.d.ts +14 -5
  125. package/lib/Stack.d.ts.map +1 -1
  126. package/lib/Stack.js +31 -19
  127. package/lib/Stack.js.map +1 -1
  128. package/lib/State/HttpStateApi.d.ts +15 -1
  129. package/lib/State/HttpStateApi.d.ts.map +1 -1
  130. package/lib/State/HttpStateApi.js +11 -0
  131. package/lib/State/HttpStateApi.js.map +1 -1
  132. package/lib/State/HttpStateStore.d.ts.map +1 -1
  133. package/lib/State/HttpStateStore.js +11 -0
  134. package/lib/State/HttpStateStore.js.map +1 -1
  135. package/lib/State/InMemoryState.d.ts +2 -2
  136. package/lib/State/InMemoryState.d.ts.map +1 -1
  137. package/lib/State/InMemoryState.js +12 -6
  138. package/lib/State/InMemoryState.js.map +1 -1
  139. package/lib/State/LocalState.d.ts.map +1 -1
  140. package/lib/State/LocalState.js +11 -2
  141. package/lib/State/LocalState.js.map +1 -1
  142. package/lib/State/State.d.ts +21 -0
  143. package/lib/State/State.d.ts.map +1 -1
  144. package/lib/Test/Bun.d.ts.map +1 -1
  145. package/lib/Test/Bun.js +30 -7
  146. package/lib/Test/Bun.js.map +1 -1
  147. package/lib/Test/Core.d.ts +17 -2
  148. package/lib/Test/Core.d.ts.map +1 -1
  149. package/lib/Test/Core.js +29 -11
  150. package/lib/Test/Core.js.map +1 -1
  151. package/lib/Test/Vitest.d.ts.map +1 -1
  152. package/lib/Test/Vitest.js +26 -5
  153. package/lib/Test/Vitest.js.map +1 -1
  154. package/lib/Util/LocalhostDns.d.ts +23 -0
  155. package/lib/Util/LocalhostDns.d.ts.map +1 -0
  156. package/lib/Util/LocalhostDns.js +46 -0
  157. package/lib/Util/LocalhostDns.js.map +1 -0
  158. package/lib/Util/PlatformServices.d.ts.map +1 -1
  159. package/lib/Util/PlatformServices.js +2 -0
  160. package/lib/Util/PlatformServices.js.map +1 -1
  161. package/lib/Util/effect.d.ts +7 -0
  162. package/lib/Util/effect.d.ts.map +1 -1
  163. package/lib/Util/effect.js +6 -0
  164. package/lib/Util/effect.js.map +1 -1
  165. package/lib/Variable.d.ts +29 -0
  166. package/lib/Variable.d.ts.map +1 -0
  167. package/lib/Variable.js +9 -0
  168. package/lib/Variable.js.map +1 -0
  169. package/lib/index.d.ts +3 -0
  170. package/lib/index.d.ts.map +1 -1
  171. package/lib/index.js +3 -0
  172. package/lib/index.js.map +1 -1
  173. package/lib/tsconfig.test.tsbuildinfo +1 -1
  174. package/package.json +7 -7
  175. package/src/AWS/Lambda/Function.ts +30 -2
  176. package/src/Apply.ts +8 -1
  177. package/src/Cli/main.ts +3 -0
  178. package/src/Cloudflare/AnalyticsEngine/AnalyticsEngineDataset.ts +82 -0
  179. package/src/Cloudflare/AnalyticsEngine/AnalyticsEngineDatasetBinding.ts +106 -0
  180. package/src/Cloudflare/AnalyticsEngine/index.ts +2 -0
  181. package/src/Cloudflare/D1/D1Connection.ts +95 -12
  182. package/src/Cloudflare/Images/Images.ts +121 -0
  183. package/src/Cloudflare/Images/ImagesBinding.ts +192 -0
  184. package/src/Cloudflare/Images/index.ts +2 -0
  185. package/src/Cloudflare/Local/SidecarHandlers.ts +9 -1
  186. package/src/Cloudflare/Providers.ts +8 -0
  187. package/src/Cloudflare/R2/R2Bucket.ts +271 -7
  188. package/src/Cloudflare/StateStore/Api.ts +34 -1
  189. package/src/Cloudflare/StateStore/Store.ts +40 -10
  190. package/src/Cloudflare/Workers/CronEventSource.ts +89 -0
  191. package/src/Cloudflare/Workers/InferEnv.ts +94 -16
  192. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +9 -1
  193. package/src/Cloudflare/Workers/Rpc.ts +227 -18
  194. package/src/Cloudflare/Workers/Worker.ts +295 -62
  195. package/src/Cloudflare/Workers/WorkerBundle.ts +15 -5
  196. package/src/Cloudflare/Workers/Workflow.ts +74 -70
  197. package/src/Cloudflare/Workers/index.ts +1 -0
  198. package/src/Cloudflare/index.ts +2 -0
  199. package/src/Deploy.ts +5 -1
  200. package/src/Destroy.ts +5 -1
  201. package/src/Output.ts +121 -12
  202. package/src/Plan.ts +101 -12
  203. package/src/Platform.ts +19 -4
  204. package/src/Ref.ts +10 -5
  205. package/src/Resource.ts +22 -0
  206. package/src/RuntimeContext.ts +1 -0
  207. package/src/Secret.ts +87 -0
  208. package/src/Serverless/Function.ts +10 -1
  209. package/src/Stack.ts +52 -29
  210. package/src/State/HttpStateApi.ts +21 -0
  211. package/src/State/HttpStateStore.ts +21 -0
  212. package/src/State/InMemoryState.ts +29 -8
  213. package/src/State/LocalState.ts +28 -4
  214. package/src/State/State.ts +21 -0
  215. package/src/Test/Bun.ts +43 -7
  216. package/src/Test/Core.ts +29 -8
  217. package/src/Test/Vitest.ts +36 -4
  218. package/src/Util/LocalhostDns.ts +52 -0
  219. package/src/Util/PlatformServices.ts +2 -0
  220. package/src/Util/effect.ts +25 -0
  221. package/src/Variable.ts +44 -0
  222. package/src/index.ts +3 -0
@@ -46,6 +46,9 @@ export const makeLocalState = () =>
46
46
  fqn: string;
47
47
  }) => path.join(stateDir, stack, stage, `${encodeFqn(fqn)}.json`);
48
48
 
49
+ const outputFile = ({ stack, stage }: { stack: string; stage: string }) =>
50
+ path.join(stateDir, stack, stage, `__stack_output__.json`);
51
+
49
52
  const created = new Set<string>();
50
53
 
51
54
  const ensure = (dir: string) =>
@@ -107,11 +110,32 @@ export const makeLocalState = () =>
107
110
  list: (request) =>
108
111
  fs.readDirectory(stageDir(request)).pipe(
109
112
  recover,
110
- Effect.map(
111
- (files) =>
112
- files?.map((file) => decodeFqn(file.replace(/\.json$/, ""))) ??
113
- [],
113
+ Effect.map((files) =>
114
+ (files ?? [])
115
+ // Filter the bookkeeping file before decoding — `decodeFqn`
116
+ // replaces `__` with `/`, which would turn the literal name
117
+ // `__stack_output__` into `/stack_output/` and slip past the
118
+ // filter, leaving the engine to look up a non-existent
119
+ // resource.
120
+ .filter((file) => file !== "__stack_output__.json")
121
+ .map((file) => decodeFqn(file.replace(/\.json$/, ""))),
122
+ ),
123
+ ),
124
+ getOutput: (request) =>
125
+ fs.readFile(outputFile(request)).pipe(
126
+ Effect.map((file) => JSON.parse(file.toString(), reviveState)),
127
+ recover,
128
+ ),
129
+ setOutput: (request) =>
130
+ ensure(stageDir(request)).pipe(
131
+ Effect.flatMap(() =>
132
+ fs.writeFileString(
133
+ outputFile(request),
134
+ JSON.stringify(encodeState(request.value as any), null, 2),
135
+ ),
114
136
  ),
137
+ recover,
138
+ Effect.map(() => request.value),
115
139
  ),
116
140
  };
117
141
  return state;
@@ -87,4 +87,25 @@ export interface StateService {
87
87
  stack: string;
88
88
  stage: string;
89
89
  }): Effect.Effect<readonly string[], StateStoreError, never>;
90
+ /**
91
+ * Read the persisted stack output for `(stack, stage)`. Returns
92
+ * `undefined` when the stack has not been deployed (or has been
93
+ * destroyed) at this stage.
94
+ *
95
+ * Stack outputs are written by `apply` once the deploy succeeds and
96
+ * read by cross-stack references (`yield* OtherStack` /
97
+ * `OtherStack.stage.<name>` / `Output.stackRef(...)`).
98
+ */
99
+ getOutput(request: {
100
+ stack: string;
101
+ stage: string;
102
+ }): Effect.Effect<unknown, StateStoreError, never>;
103
+ /**
104
+ * Persist the resolved stack output for `(stack, stage)`.
105
+ */
106
+ setOutput(request: {
107
+ stack: string;
108
+ stage: string;
109
+ value: unknown;
110
+ }): Effect.Effect<unknown, StateStoreError, never>;
90
111
  }
package/src/Test/Bun.ts CHANGED
@@ -1,12 +1,17 @@
1
1
  import bun from "bun:test";
2
2
  import * as Effect from "effect/Effect";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Scope from "effect/Scope";
3
5
  import type { HookOptions } from "node:test";
4
6
 
5
7
  import type { AlchemyContext } from "../AlchemyContext.ts";
6
8
  import type { CompiledStack } from "../Stack.ts";
7
9
  import type { Stage } from "../Stage.ts";
10
+ import { installLocalhostDns } from "../Util/LocalhostDns.ts";
8
11
  import * as Core from "./Core.ts";
9
12
 
13
+ installLocalhostDns();
14
+
10
15
  export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
11
16
  export type ScratchStack = Core.ScratchStack;
12
17
  export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
@@ -106,7 +111,15 @@ const DEFAULT_HOOK_TIMEOUT: HookOptions = { timeout: 120_000 };
106
111
  * ```
107
112
  */
108
113
  export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
109
- const runEff = <A>(eff: TestEffect<A>) => Core.run(eff, options);
114
+ // Single scope shared across `beforeAll`, every `test`, and `afterAll`.
115
+ // Scoped resources in dev mode (the Cloudflare sidecar process and its
116
+ // workerd children) must outlive a single `Effect.runPromise` boundary,
117
+ // otherwise the proxy is killed the moment `beforeAll(deploy(Stack))`
118
+ // resolves and every later `HttpClient.get(workerUrl)` hits a dead port.
119
+ // The scope is closed by `destroy(...)` (or never — the next test run
120
+ // reclaims any leaked sidecar via the lock file).
121
+ const sharedScope = Scope.makeUnsafe("sequential");
122
+ const runEff = <A>(eff: TestEffect<A>) => Core.run(eff, options, sharedScope);
110
123
 
111
124
  const test = ((name, eff, opts) => {
112
125
  bun.test(name, () => runEff(eff), opts);
@@ -130,10 +143,11 @@ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
130
143
  fn: (stack: ScratchStack) => Effect.Effect<void, any, any>,
131
144
  ) => {
132
145
  const scratch = Core.scratchStack(options, name);
133
- return Core.run(Core.withProviders(fn(scratch), options, scratch.name), {
134
- ...options,
135
- state: scratch.state,
136
- });
146
+ return Core.run(
147
+ Core.withProviders(fn(scratch), options, scratch.name),
148
+ { ...options, state: scratch.state },
149
+ sharedScope,
150
+ );
137
151
  };
138
152
 
139
153
  const provider = ((name, fn, opts) => {
@@ -175,13 +189,35 @@ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
175
189
  bun.afterEach(() => runEff(eff), hookOptions);
176
190
  };
177
191
 
192
+ // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`
193
+ // for each worker. We close the shared scope only AFTER destroy completes.
194
+ // `Scope.close` on an already-closed scope is a no-op, so it's safe for both
195
+ // the destroy wrapper AND the fallback cleanup hook below to call it.
196
+ const closeScope = Effect.suspend(() =>
197
+ Scope.close(sharedScope, Exit.void),
198
+ ).pipe(Effect.ignore);
199
+
200
+ // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g.
201
+ // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar
202
+ // child process leaks past the test process. Register an `afterAll` that
203
+ // closes it. We defer registration to a microtask so it runs AFTER any
204
+ // user-registered `afterAll` (including `destroy(Stack)`); bun runs
205
+ // afterAll hooks in registration order.
206
+ queueMicrotask(() => {
207
+ bun.afterAll(() => Effect.runPromise(closeScope), DEFAULT_HOOK_TIMEOUT);
208
+ });
209
+
178
210
  return {
179
211
  test,
180
212
  beforeAll,
181
213
  beforeEach,
182
214
  afterAll,
183
215
  afterEach,
184
- deploy: (stack, callOpts) => Core.deploy(options, stack, callOpts),
185
- destroy: (stack, callOpts) => Core.destroy(options, stack, callOpts),
216
+ deploy: (stack, callOpts) =>
217
+ Core.deploy(options, stack, { ...callOpts, scope: sharedScope }),
218
+ destroy: (stack, callOpts) =>
219
+ Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
220
+ Effect.ensuring(closeScope),
221
+ ),
186
222
  };
187
223
  };
package/src/Test/Core.ts CHANGED
@@ -2,6 +2,7 @@ import { ConfigProvider } from "effect/ConfigProvider";
2
2
  import * as Effect from "effect/Effect";
3
3
  import * as Layer from "effect/Layer";
4
4
  import * as Option from "effect/Option";
5
+ import * as Scope from "effect/Scope";
5
6
  import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
6
7
 
7
8
  import { AdoptPolicy } from "../AdoptPolicy.ts";
@@ -90,14 +91,22 @@ const alchemyLayer = Layer.mergeAll(LoggingCli, AlchemyContextLive);
90
91
  * registry that the user's `providers` layer populates, the platform layers,
91
92
  * and the configured state store. Adapters wrap this into runner-specific
92
93
  * thunks (`bun.test` -> `runPromise`, `it.live` -> as-is).
94
+ *
95
+ * When `scope` is provided, scoped resources (like the Cloudflare dev
96
+ * sidecar) survive past this effect and are tied to the lifetime of the
97
+ * provided scope instead. The runner is responsible for closing it.
98
+ *
99
+ * When `scope` is omitted, the effect runs with `Effect.scoped` and any
100
+ * scoped resources are torn down as soon as it resolves.
93
101
  */
94
102
  export const toEffect = <A>(
95
103
  effect: TestEffect<A>,
96
104
  options: MakeOptions,
97
- ): Effect.Effect<A, any, never> =>
98
- Effect.gen(function* () {
99
- const base = yield* loadConfigProvider(Option.none());
100
- const configProvider = withProfileOverride(base, options.profile);
105
+ scope?: Scope.Scope,
106
+ ): Effect.Effect<A, any, never> => {
107
+ const base = Effect.gen(function* () {
108
+ const cfg = yield* loadConfigProvider(Option.none());
109
+ const configProvider = withProfileOverride(cfg, options.profile);
101
110
  return yield* effect.pipe(
102
111
  provideFreshArtifactStore,
103
112
  Effect.provide(Layer.succeed(ConfigProvider, configProvider)),
@@ -112,14 +121,19 @@ export const toEffect = <A>(
112
121
  Effect.provide(options.state ?? State.localState()),
113
122
  Effect.provideService(AuthProviders, {}),
114
123
  Effect.provide(Layer.provideMerge(alchemyLayer, platformLayer)),
115
- Effect.scoped,
124
+ );
125
+
126
+ return (
127
+ scope === undefined ? Effect.scoped(base) : Scope.provide(base, scope)
116
128
  ) as Effect.Effect<A, any, never>;
129
+ };
117
130
 
118
131
  /** Promise wrapper around {@link toEffect} for `bun.test`-style runners. */
119
132
  export const run = <A>(
120
133
  effect: TestEffect<A>,
121
134
  options: MakeOptions,
122
- ): Promise<A> => Effect.runPromise(toEffect(effect, options));
135
+ scope?: Scope.Scope,
136
+ ): Promise<A> => Effect.runPromise(toEffect(effect, options, scope));
123
137
 
124
138
  /**
125
139
  * Wrap an effect so it runs with `options.providers` + a placeholder Stack +
@@ -148,27 +162,34 @@ export const withProviders = <A, E, R, ROut>(
148
162
  * Curried `deploy` for the test factory: bakes in the configured stage and
149
163
  * adds the telemetry layer the CLI uses, so `beforeAll(deploy(Stack))` works
150
164
  * the same way as `alchemy deploy`.
165
+ *
166
+ * `scope`, when supplied, is forwarded down so the dev sidecar (and other
167
+ * scoped resources) lives until the caller closes it instead of dying as
168
+ * soon as `deploy` resolves. The test harness uses this to keep workerd
169
+ * alive across `beforeAll` → tests → `afterAll`.
151
170
  */
152
171
  export const deploy = <A>(
153
172
  options: MakeOptions,
154
173
  stack: TestEffect<CompiledStack<A>, Stage | AlchemyContext>,
155
- callOptions?: { stage?: string },
174
+ callOptions?: { stage?: string; scope?: Scope.Scope },
156
175
  ) =>
157
176
  _deploy({
158
177
  stack: stack as Effect.Effect<CompiledStack<A>, never, any>,
159
178
  stage: callOptions?.stage ?? options.stage ?? "test",
160
179
  dev: resolveDev(options),
180
+ scope: callOptions?.scope,
161
181
  }).pipe(Effect.provide(TelemetryLive));
162
182
 
163
183
  export const destroy = (
164
184
  options: MakeOptions,
165
185
  stack: TestEffect<CompiledStack, Stage | AlchemyContext>,
166
- callOptions?: { stage?: string },
186
+ callOptions?: { stage?: string; scope?: Scope.Scope },
167
187
  ) =>
168
188
  _destroy({
169
189
  stack: stack as Effect.Effect<CompiledStack, never, any>,
170
190
  stage: callOptions?.stage ?? options.stage ?? "test",
171
191
  dev: resolveDev(options),
192
+ scope: callOptions?.scope,
172
193
  }).pipe(Effect.provide(TelemetryLive));
173
194
 
174
195
  /**
@@ -1,5 +1,7 @@
1
1
  import { it } from "@effect/vitest";
2
2
  import * as Effect from "effect/Effect";
3
+ import * as Exit from "effect/Exit";
4
+ import * as Scope from "effect/Scope";
3
5
  import {
4
6
  afterAll as vitestAfterAll,
5
7
  afterEach as vitestAfterEach,
@@ -10,8 +12,11 @@ import {
10
12
  import type { AlchemyContext } from "../AlchemyContext.ts";
11
13
  import type { CompiledStack } from "../Stack.ts";
12
14
  import type { Stage } from "../Stage.ts";
15
+ import { installLocalhostDns } from "../Util/LocalhostDns.ts";
13
16
  import * as Core from "./Core.ts";
14
17
 
18
+ installLocalhostDns();
19
+
15
20
  export type MakeOptions<ROut = any> = Core.MakeOptions<ROut>;
16
21
  export type ScratchStack = Core.ScratchStack;
17
22
  export type TestEffect<A, R = never> = Core.TestEffect<A, R>;
@@ -95,8 +100,13 @@ const DEFAULT_TIMEOUT = 120_000;
95
100
  * Effect-aware tests stay first-class.
96
101
  */
97
102
  export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
98
- const wrap = <A>(eff: TestEffect<A>) => Core.toEffect(eff, options);
99
- const runEff = <A>(eff: TestEffect<A>) => Core.run(eff, options);
103
+ // See `Test/Bun.ts` for the rationale: the dev sidecar must outlive a
104
+ // single `runPromise` boundary, so all hooks share one scope that's only
105
+ // closed after `destroy(...)` runs.
106
+ const sharedScope = Scope.makeUnsafe("sequential");
107
+ const wrap = <A>(eff: TestEffect<A>) =>
108
+ Core.toEffect(eff, options, sharedScope);
109
+ const runEff = <A>(eff: TestEffect<A>) => Core.run(eff, options, sharedScope);
100
110
 
101
111
  const test = ((name, eff, opts) => {
102
112
  it.live(name, () => wrap(eff), timeoutOf(opts));
@@ -127,6 +137,7 @@ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
127
137
  return Core.toEffect(
128
138
  Core.withProviders(fn(scratch), options, scratch.name),
129
139
  { ...options, state: scratch.state },
140
+ sharedScope,
130
141
  );
131
142
  };
132
143
 
@@ -179,13 +190,34 @@ export const make = <ROut = any>(options: MakeOptions<ROut>): TestApi => {
179
190
  vitestAfterEach(() => runEff(eff), timeoutOf(hookOptions));
180
191
  };
181
192
 
193
+ // `destroy(Stack)` needs the dev sidecar alive so it can call `sidecar.stop`.
194
+ // `Scope.close` on an already-closed scope is a no-op, so the destroy
195
+ // wrapper AND the fallback cleanup hook below can both call it safely.
196
+ const closeScope = Effect.suspend(() =>
197
+ Scope.close(sharedScope, Exit.void),
198
+ ).pipe(Effect.ignore);
199
+
200
+ // Fallback cleanup: if the user never calls `destroy(Stack)` (e.g.
201
+ // `NO_DESTROY=1`), nothing else closes the shared scope and the sidecar
202
+ // child process leaks past the test process. Register an `afterAll` that
203
+ // closes it. We defer registration to a microtask so it runs AFTER any
204
+ // user-registered `afterAll` (including `destroy(Stack)`); vitest runs
205
+ // afterAll hooks in registration order.
206
+ queueMicrotask(() => {
207
+ vitestAfterAll(() => Effect.runPromise(closeScope), DEFAULT_TIMEOUT);
208
+ });
209
+
182
210
  return {
183
211
  test,
184
212
  beforeAll,
185
213
  beforeEach,
186
214
  afterAll,
187
215
  afterEach,
188
- deploy: (stack, callOpts) => Core.deploy(options, stack, callOpts),
189
- destroy: (stack, callOpts) => Core.destroy(options, stack, callOpts),
216
+ deploy: (stack, callOpts) =>
217
+ Core.deploy(options, stack, { ...callOpts, scope: sharedScope }),
218
+ destroy: (stack, callOpts) =>
219
+ Core.destroy(options, stack, { ...callOpts, scope: sharedScope }).pipe(
220
+ Effect.ensuring(closeScope),
221
+ ),
190
222
  };
191
223
  };
@@ -0,0 +1,52 @@
1
+ /**
2
+ * Process-level DNS shim that makes `*.localhost` resolve to 127.0.0.1
3
+ * for any HTTP client that goes through undici (Node's global `fetch`,
4
+ * undici-based clients, etc.).
5
+ *
6
+ * Background: macOS's `getaddrinfo` does not honor RFC 6761 for arbitrary
7
+ * `*.localhost` names — only the bare `localhost` is mapped to loopback.
8
+ * The Cloudflare local proxy returns URLs like
9
+ * `http://my-worker.localhost:1337` and dispatches by the URL hostname,
10
+ * so we cannot rewrite the URL client-side to `127.0.0.1` (and `Host` is
11
+ * a forbidden fetch header). The fix is to make the host resolvable at
12
+ * the network layer.
13
+ *
14
+ * On Bun, `globalThis.fetch` is the native (Zig) implementation and does
15
+ * not consult undici's global dispatcher; Bun resolves `*.localhost`
16
+ * itself on Darwin/Linux, so this shim is effectively a no-op there for
17
+ * the global `fetch` and only matters if user code reaches for `undici`
18
+ * directly.
19
+ *
20
+ * Calling `installLocalhostDns()` is idempotent.
21
+ */
22
+
23
+ import * as dns from "node:dns";
24
+ import * as undici from "undici";
25
+
26
+ let installed = false;
27
+
28
+ const isLocalhost = (hostname: string): boolean =>
29
+ hostname === "localhost" ||
30
+ hostname === "[::1]" ||
31
+ hostname.endsWith(".localhost");
32
+
33
+ export const installLocalhostDns = (): void => {
34
+ if (installed) return;
35
+ installed = true;
36
+
37
+ undici.setGlobalDispatcher(
38
+ new undici.Agent({
39
+ connect: {
40
+ lookup: (hostname, options, cb) => {
41
+ if (isLocalhost(hostname)) {
42
+ if (options && (options as { all?: boolean }).all) {
43
+ return cb(null, [{ address: "127.0.0.1", family: 4 }]);
44
+ }
45
+ return cb(null, "127.0.0.1", 4);
46
+ }
47
+ return dns.lookup(hostname, options, cb);
48
+ },
49
+ },
50
+ }),
51
+ );
52
+ };
@@ -9,6 +9,7 @@ import type { HttpServer } from "effect/unstable/http/HttpServer";
9
9
  import type { ServeError } from "effect/unstable/http/HttpServerError";
10
10
  import type { ChildProcessSpawner } from "effect/unstable/process/ChildProcessSpawner";
11
11
  import type { WebSocketConstructor } from "effect/unstable/socket/Socket";
12
+ import { installLocalhostDns } from "./LocalhostDns.ts";
12
13
 
13
14
  const isBun = typeof Bun !== "undefined";
14
15
 
@@ -52,6 +53,7 @@ export const runMain = <E, A>(
52
53
  readonly teardown?: Teardown | undefined;
53
54
  },
54
55
  ): void => {
56
+ installLocalhostDns();
55
57
  if (isBun) {
56
58
  void import("@effect/platform-bun/BunRuntime").then((BunRuntime) =>
57
59
  BunRuntime.runMain(effect, options),
@@ -64,6 +64,31 @@ export const taggedFunction = <
64
64
  }) as Tag & Fn;
65
65
  };
66
66
 
67
+ export const isYieldableEffect = (
68
+ value: unknown,
69
+ ): value is Effect.Effect<unknown, unknown, unknown> =>
70
+ Effect.isEffect(value) &&
71
+ typeof (value as { [Symbol.iterator]?: unknown })[Symbol.iterator] ===
72
+ "function";
73
+
74
+ export type YieldableEffectLike<A = unknown, E = unknown, R = unknown> =
75
+ | Effect.Effect<A, E, R>
76
+ | {
77
+ asEffect: () => Effect.Effect<A, E, R>;
78
+ [Symbol.iterator]: () => Iterator<unknown>;
79
+ };
80
+
81
+ export const isEffectClassLike = (
82
+ value: unknown,
83
+ ): value is YieldableEffectLike =>
84
+ typeof value === "function" &&
85
+ typeof (value as { asEffect?: unknown }).asEffect === "function";
86
+
87
+ export const isYieldableEffectLike = (
88
+ value: unknown,
89
+ ): value is YieldableEffectLike =>
90
+ isYieldableEffect(value) || isEffectClassLike(value);
91
+
67
92
  export type UnwrapEffect<T> =
68
93
  T extends Effect.Effect<infer A, any, any> ? A : T;
69
94
 
@@ -0,0 +1,44 @@
1
+ import * as Config from "effect/Config";
2
+ import * as Effect from "effect/Effect";
3
+ import * as Output from "./Output.ts";
4
+ import { toOutput } from "./Secret.ts";
5
+
6
+ /**
7
+ * The shapes a {@link variable} can be derived from.
8
+ */
9
+ export type VariableInput<T, R = never> =
10
+ | T
11
+ | Effect.Effect<T, any, R>
12
+ | Config.Config<T>;
13
+
14
+ /**
15
+ * Bind a non-secret value into the active {@link RuntimeContext} (e.g. a
16
+ * Cloudflare Worker's `env`) under the given `name`. Counterpart to
17
+ * {@link Secret} for plain (non-redacted) values — the resource provider
18
+ * deploys it as `plain_text` / `json` rather than `secret_text`.
19
+ *
20
+ * The result is an `Output<T>` — `yield*`-ing it returns an `Accessor<T>`
21
+ * that resolves the value at runtime.
22
+ *
23
+ * @example One-arg shortcut — reads `Config.string("DATABASE_URL")`
24
+ * ```ts
25
+ * const url = yield* Alchemy.Variable("DATABASE_URL");
26
+ * ```
27
+ *
28
+ * @example From a literal
29
+ * ```ts
30
+ * const port = yield* Alchemy.Variable("PORT", 3000);
31
+ * ```
32
+ */
33
+ export function Variable(name: string): Output.Output<string, never>;
34
+ export function Variable<T, R = never>(
35
+ name: string,
36
+ value: VariableInput<T, R>,
37
+ ): Output.Output<T, R>;
38
+ export function Variable<T = string, R = never>(
39
+ name: string,
40
+ value?: VariableInput<T, R>,
41
+ ): Output.Output<T, R> {
42
+ const source: any = value ?? Config.string(name);
43
+ return Output.named(toOutput<T, R>(source), name);
44
+ }
package/src/index.ts CHANGED
@@ -14,6 +14,7 @@ export * from "./Diff.ts";
14
14
  export * from "./Input.ts";
15
15
  export * from "./InstanceId.ts";
16
16
  export * from "./Namespace.ts";
17
+ export { stackRef } from "./Output.ts";
17
18
  export type { Output } from "./Output.ts";
18
19
  export * from "./PhysicalName.ts";
19
20
  export * as Plan from "./Plan.ts";
@@ -23,12 +24,14 @@ export * from "./Ref.ts";
23
24
  export * as RemovalPolicy from "./RemovalPolicy.ts";
24
25
  export * from "./Resource.ts";
25
26
  export * as Schema from "./Schema.ts";
27
+ export { Secret } from "./Secret.ts";
26
28
  export * as Server from "./Server/index.ts";
27
29
  export * as Serverless from "./Serverless/index.ts";
28
30
  export { Stack } from "./Stack.ts";
29
31
  export * from "./Stage.ts";
30
32
  export { inMemoryState, localState } from "./State/index.ts";
31
33
  export * from "./Trait.ts";
34
+ export { Variable } from "./Variable.ts";
32
35
 
33
36
  export * as Construct from "./Construct.ts";
34
37