alchemy 2.0.0-beta.28 → 2.0.0-beta.29

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 (91) hide show
  1. package/bin/exec.js +98 -6
  2. package/bin/exec.js.map +1 -1
  3. package/lib/AWS/IAM/SAMLProvider.d.ts +2 -1
  4. package/lib/AWS/IAM/SAMLProvider.d.ts.map +1 -1
  5. package/lib/AWS/IAM/SAMLProvider.js +30 -9
  6. package/lib/AWS/IAM/SAMLProvider.js.map +1 -1
  7. package/lib/Apply.d.ts.map +1 -1
  8. package/lib/Apply.js +55 -11
  9. package/lib/Apply.js.map +1 -1
  10. package/lib/Cli/commands/bootstrap.d.ts +1 -1
  11. package/lib/Cli/commands/bootstrap.d.ts.map +1 -1
  12. package/lib/Cli/commands/bootstrap.js +2 -1
  13. package/lib/Cli/commands/bootstrap.js.map +1 -1
  14. package/lib/Cloudflare/Access.d.ts +19 -0
  15. package/lib/Cloudflare/Access.d.ts.map +1 -0
  16. package/lib/Cloudflare/Access.js +64 -0
  17. package/lib/Cloudflare/Access.js.map +1 -0
  18. package/lib/Cloudflare/AiGateway/AiGateway.d.ts +244 -0
  19. package/lib/Cloudflare/AiGateway/AiGateway.d.ts.map +1 -0
  20. package/lib/Cloudflare/AiGateway/AiGateway.js +269 -0
  21. package/lib/Cloudflare/AiGateway/AiGateway.js.map +1 -0
  22. package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts +103 -0
  23. package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts.map +1 -0
  24. package/lib/Cloudflare/AiGateway/AiGatewayBinding.js +99 -0
  25. package/lib/Cloudflare/AiGateway/AiGatewayBinding.js.map +1 -0
  26. package/lib/Cloudflare/AiGateway/index.d.ts +3 -0
  27. package/lib/Cloudflare/AiGateway/index.d.ts.map +1 -0
  28. package/lib/Cloudflare/AiGateway/index.js +3 -0
  29. package/lib/Cloudflare/AiGateway/index.js.map +1 -0
  30. package/lib/Cloudflare/EdgeSession.d.ts +2 -1
  31. package/lib/Cloudflare/EdgeSession.d.ts.map +1 -1
  32. package/lib/Cloudflare/EdgeSession.js +8 -4
  33. package/lib/Cloudflare/EdgeSession.js.map +1 -1
  34. package/lib/Cloudflare/Providers.d.ts +2 -1
  35. package/lib/Cloudflare/Providers.d.ts.map +1 -1
  36. package/lib/Cloudflare/Providers.js +5 -1
  37. package/lib/Cloudflare/Providers.js.map +1 -1
  38. package/lib/Cloudflare/StateStore/Api.d.ts +10 -0
  39. package/lib/Cloudflare/StateStore/Api.d.ts.map +1 -1
  40. package/lib/Cloudflare/StateStore/Api.js +12 -1
  41. package/lib/Cloudflare/StateStore/Api.js.map +1 -1
  42. package/lib/Cloudflare/StateStore/State.d.ts +4 -3
  43. package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
  44. package/lib/Cloudflare/StateStore/State.js +103 -13
  45. package/lib/Cloudflare/StateStore/State.js.map +1 -1
  46. package/lib/Cloudflare/Workers/InferEnv.d.ts +1 -1
  47. package/lib/Cloudflare/Workers/InferEnv.d.ts.map +1 -1
  48. package/lib/Cloudflare/Workers/Worker.d.ts +2 -1
  49. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  50. package/lib/Cloudflare/Workers/Worker.js +40 -31
  51. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  52. package/lib/Cloudflare/index.d.ts +1 -0
  53. package/lib/Cloudflare/index.d.ts.map +1 -1
  54. package/lib/Cloudflare/index.js +1 -0
  55. package/lib/Cloudflare/index.js.map +1 -1
  56. package/lib/Plan.d.ts +8 -0
  57. package/lib/Plan.d.ts.map +1 -1
  58. package/lib/Plan.js +12 -5
  59. package/lib/Plan.js.map +1 -1
  60. package/lib/State/HttpStateApi.d.ts +19 -1
  61. package/lib/State/HttpStateApi.d.ts.map +1 -1
  62. package/lib/State/HttpStateApi.js +18 -1
  63. package/lib/State/HttpStateApi.js.map +1 -1
  64. package/lib/Util/cached-function.d.ts +38 -0
  65. package/lib/Util/cached-function.d.ts.map +1 -0
  66. package/lib/Util/cached-function.js +53 -0
  67. package/lib/Util/cached-function.js.map +1 -0
  68. package/lib/Util/scc.d.ts +12 -0
  69. package/lib/Util/scc.d.ts.map +1 -0
  70. package/lib/Util/scc.js +81 -0
  71. package/lib/Util/scc.js.map +1 -0
  72. package/lib/tsconfig.test.tsbuildinfo +1 -1
  73. package/package.json +2 -2
  74. package/src/AWS/IAM/SAMLProvider.ts +47 -18
  75. package/src/Apply.ts +72 -8
  76. package/src/Cli/commands/bootstrap.ts +2 -0
  77. package/src/Cloudflare/Access.ts +110 -0
  78. package/src/Cloudflare/AiGateway/AiGateway.ts +526 -0
  79. package/src/Cloudflare/AiGateway/AiGatewayBinding.ts +190 -0
  80. package/src/Cloudflare/AiGateway/index.ts +2 -0
  81. package/src/Cloudflare/EdgeSession.ts +15 -5
  82. package/src/Cloudflare/Providers.ts +7 -0
  83. package/src/Cloudflare/StateStore/Api.ts +18 -0
  84. package/src/Cloudflare/StateStore/State.ts +130 -12
  85. package/src/Cloudflare/Workers/InferEnv.ts +7 -5
  86. package/src/Cloudflare/Workers/Worker.ts +44 -33
  87. package/src/Cloudflare/index.ts +1 -0
  88. package/src/Plan.ts +21 -5
  89. package/src/State/HttpStateApi.ts +22 -1
  90. package/src/Util/cached-function.ts +75 -0
  91. package/src/Util/scc.ts +89 -0
@@ -0,0 +1,2 @@
1
+ export * from "./AiGateway.ts";
2
+ export * from "./AiGatewayBinding.ts";
@@ -3,6 +3,7 @@ import * as workers from "@distilled.cloud/cloudflare/workers";
3
3
  import * as Data from "effect/Data";
4
4
  import * as Effect from "effect/Effect";
5
5
  import * as HttpClient from "effect/unstable/http/HttpClient";
6
+ import { Access } from "./Access.ts";
6
7
  import { CloudflareEnvironment } from "./CloudflareEnvironment.ts";
7
8
 
8
9
  /**
@@ -127,18 +128,27 @@ export const createEdgeSession = (
127
128
  ): Effect.Effect<
128
129
  EdgeSessionHandle,
129
130
  EdgeSessionError,
130
- CloudflareEnvironment | HttpClient.HttpClient | Credentials
131
+ CloudflareEnvironment | HttpClient.HttpClient | Credentials | Access
131
132
  > =>
132
133
  Effect.gen(function* () {
133
- const [{ previewToken }, host] = yield* Effect.all(
134
+ const [{ previewToken }, { url, headers }] = yield* Effect.all(
134
135
  [
135
136
  createUploadToken.pipe(Effect.flatMap((t) => uploadScript(options, t))),
136
- workerHost(options.scriptName),
137
+ workerHost(options.scriptName).pipe(
138
+ Effect.flatMap(
139
+ Effect.fn(function* (host) {
140
+ const headers = yield* Access.use((access) =>
141
+ access.getAccessHeaders(host),
142
+ );
143
+ return { url: `https://${host}`, headers };
144
+ }),
145
+ ),
146
+ ),
137
147
  ],
138
148
  { concurrency: "unbounded" },
139
149
  );
140
150
  return {
141
- url: `https://${host}`,
142
- headers: { "cf-workers-preview-token": previewToken },
151
+ url,
152
+ headers: { ...headers, "cf-workers-preview-token": previewToken },
143
153
  } satisfies EdgeSessionHandle;
144
154
  }).pipe((e) => wrap(e, "Failed to create edge preview session"));
@@ -3,6 +3,8 @@ import { Command } from "../Build/Command.ts";
3
3
  import * as Build from "../Build/index.ts";
4
4
  import * as Provider from "../Provider.ts";
5
5
  import { Random, RandomProvider } from "../Random.ts";
6
+ import * as Access from "./Access.ts";
7
+ import * as AiGateway from "./AiGateway/index.ts";
6
8
  import * as ApiToken from "./ApiToken/index.ts";
7
9
  import * as Artifacts from "./Artifacts/index.ts";
8
10
  import { CloudflareAuth } from "./Auth/AuthProvider.ts";
@@ -34,6 +36,8 @@ export const providers = () =>
34
36
  Provider.collection([
35
37
  ApiToken.AccountApiToken,
36
38
  ApiToken.UserApiToken,
39
+ AiGateway.AiGateway,
40
+ AiGateway.AiGatewayBindingPolicy,
37
41
  Artifacts.ArtifactsBindingPolicy,
38
42
  Command,
39
43
  Containers.Container,
@@ -60,6 +64,8 @@ export const providers = () =>
60
64
  Layer.mergeAll(
61
65
  ApiToken.AccountApiTokenProvider(),
62
66
  ApiToken.UserApiTokenProvider(),
67
+ AiGateway.AiGatewayProvider(),
68
+ AiGateway.AiGatewayBindingPolicyLive,
63
69
  Artifacts.ArtifactsBindingPolicyLive,
64
70
  Containers.ContainerProvider(),
65
71
  D1.D1ConnectionPolicyLive,
@@ -86,5 +92,6 @@ export const providers = () =>
86
92
  Layer.provideMerge(Credentials.fromAuthProvider()),
87
93
  Layer.provideMerge(CloudflareEnvironment.fromProfile()),
88
94
  Layer.provideMerge(CloudflareAuth),
95
+ Layer.provideMerge(Access.AccessLive),
89
96
  Layer.orDie,
90
97
  );
@@ -20,6 +20,17 @@ import { AuthToken } from "./Token.ts";
20
20
 
21
21
  export const STATE_STORE_SCRIPT_NAME = "alchemy-state-store" as const;
22
22
 
23
+ /**
24
+ * Version of the deployed Cloudflare State Store worker contract.
25
+ *
26
+ * Bump this whenever the wire format or runtime behaviour of the
27
+ * worker changes in a way that an older deployed copy can no longer
28
+ * satisfy. Clients query `/version` on the deployed worker and
29
+ * compare against this constant; a mismatch (or 404) triggers a
30
+ * forced redeploy via the bootstrap flow.
31
+ */
32
+ export const STATE_STORE_VERSION = 1 as const;
33
+
23
34
  /**
24
35
  * Path on disk to *this* file, used as the worker's bundling entry.
25
36
  *
@@ -63,6 +74,12 @@ export default Worker(
63
74
  ),
64
75
  );
65
76
 
77
+ const versionApi = HttpApiBuilder.group(StateApi, "version", (handlers) =>
78
+ handlers.handle("getVersion", () =>
79
+ Effect.succeed({ version: STATE_STORE_VERSION }),
80
+ ),
81
+ );
82
+
66
83
  const stateApi = HttpApiBuilder.group(StateApi, "state", (handlers) =>
67
84
  handlers
68
85
  .handle("listStacks", () =>
@@ -133,6 +150,7 @@ export default Worker(
133
150
  return {
134
151
  fetch: HttpApiBuilder.layer(StateApi).pipe(
135
152
  Layer.provide(stateApi),
153
+ Layer.provide(versionApi),
136
154
  Layer.provide(StateAuthLive),
137
155
  Layer.provide(bearerTokenValidator),
138
156
  // The state-store worker never serves files, so HttpPlatform's
@@ -5,6 +5,7 @@ import * as Effect from "effect/Effect";
5
5
  import * as Layer from "effect/Layer";
6
6
  import * as Redacted from "effect/Redacted";
7
7
  import * as HttpClient from "effect/unstable/http/HttpClient";
8
+ import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
8
9
 
9
10
  import * as Config from "effect/Config";
10
11
  import { adopt } from "../../AdoptPolicy.ts";
@@ -14,6 +15,7 @@ import { ALCHEMY_PROFILE } from "../../Auth/Profile.ts";
14
15
  import * as Cloudflare from "../../Cloudflare/Providers.ts";
15
16
  import { deploy } from "../../Deploy.ts";
16
17
  import * as Alchemy from "../../Stack.ts";
18
+ import { StateApi } from "../../State/HttpStateApi.ts";
17
19
  import {
18
20
  makeHttpStateStore,
19
21
  type HttpStateStoreProps,
@@ -26,9 +28,10 @@ import { makeLocalState } from "../../State/LocalState.ts";
26
28
  import { State, type StateService } from "../../State/State.ts";
27
29
  import { recordStateStoreOp } from "../../Telemetry/Metrics.ts";
28
30
  import * as Clank from "../../Util/Clank.ts";
31
+ import * as Access from "../Access.ts";
29
32
  import { CloudflareAuth } from "../Auth/AuthProvider.ts";
30
33
  import { EdgeSessionError, createEdgeSession } from "../EdgeSession.ts";
31
- import Api, { STATE_STORE_SCRIPT_NAME } from "./Api.ts";
34
+ import Api, { STATE_STORE_SCRIPT_NAME, STATE_STORE_VERSION } from "./Api.ts";
32
35
  import { AuthTokenSecretName, TokenValue } from "./Token.ts";
33
36
 
34
37
  /** Filename used for stored credentials under the profile directory. */
@@ -111,6 +114,13 @@ export const bootstrap = (options: BootstrapOptions = {}) =>
111
114
  credentials,
112
115
  );
113
116
  }
117
+ yield* redeployIfStale({
118
+ scriptName,
119
+ profileName,
120
+ localState,
121
+ isCI,
122
+ url: credentials.url,
123
+ });
114
124
  yield* Clank.success(`Cloudflare State Store '${scriptName}' is ready.`);
115
125
  return;
116
126
  }
@@ -179,9 +189,21 @@ export const state = (props?: {
179
189
  });
180
190
  }
181
191
 
182
- // TODO(sam): support upgrading state store, right now we only deploy once
183
192
  if (credentials) {
184
- // it's in the profile, let's go
193
+ // We have local credentials. Before trusting them, verify
194
+ // the deployed worker is on the version this CLI was built
195
+ // against. If it isn't, `redeployIfStale` runs the idempotent
196
+ // bootstrap flow and short-circuits with the freshly-deployed
197
+ // store.
198
+ const fresh = yield* redeployIfStale({
199
+ scriptName,
200
+ profileName,
201
+ localState,
202
+ isCI,
203
+ url: credentials.url,
204
+ });
205
+ if (fresh) return fresh;
206
+
185
207
  const httpState = yield* makeHttpStateStore(credentials);
186
208
 
187
209
  if (alchemyContext.updateStateStore) {
@@ -213,6 +235,16 @@ export const state = (props?: {
213
235
  credentials,
214
236
  );
215
237
  }
238
+
239
+ const fresh = yield* redeployIfStale({
240
+ scriptName,
241
+ profileName,
242
+ localState,
243
+ isCI,
244
+ url: credentials.url,
245
+ });
246
+ if (fresh) return fresh;
247
+
216
248
  const httpState = yield* makeHttpStateStore(credentials);
217
249
 
218
250
  if (alchemyContext.updateStateStore) {
@@ -251,6 +283,7 @@ export const state = (props?: {
251
283
  Layer.provideMerge(Credentials.fromAuthProvider()),
252
284
  Layer.provideMerge(CloudflareEnvironment.fromProfile()),
253
285
  Layer.provideMerge(CloudflareAuth),
286
+ Layer.provideMerge(Access.AccessLive),
254
287
  Layer.orDie,
255
288
  );
256
289
 
@@ -424,7 +457,19 @@ export const loginWithCloudflare = () =>
424
457
  }
425
458
 
426
459
  // 2. Fetch the auth-token value via an edge-preview worker.
427
- const authToken = yield* readSecretViaEdge(store.id, AuthTokenSecretName);
460
+ // We piggy-back on the already-deployed state-store script so the
461
+ // `cf-workers-preview-token` header has a real workers.dev route to
462
+ // swap onto. Uploading the probe under a brand-new script name fails
463
+ // on accounts where that script has never been deployed (or where
464
+ // workers.dev preview URLs are off by default — the post-2024
465
+ // Cloudflare default for new accounts) because the host doesn't
466
+ // resolve and Cloudflare's edge serves a 400 HTML error page
467
+ // instead of routing to the preview.
468
+ const authToken = yield* readSecretViaEdge(
469
+ STATE_STORE_SCRIPT_NAME,
470
+ store.id,
471
+ AuthTokenSecretName,
472
+ );
428
473
 
429
474
  // 3. Derive the deployed worker URL.
430
475
  const { subdomain } = yield* workers.getSubdomain({ accountId });
@@ -468,8 +513,61 @@ export const loginWithCloudflare = () =>
468
513
  ),
469
514
  );
470
515
 
471
- /** Preview script name used by the edge-probe worker. */
472
- const PROBE_SCRIPT_NAME = "alchemy-state-store-probe";
516
+ /**
517
+ * Run {@link finishBootstrap} iff the worker at `url` fails the
518
+ * version probe; otherwise resolve to `undefined`. Centralises the
519
+ * "is the deployed worker still compatible?" guard used by every
520
+ * code path that already has a presumably-valid worker URL.
521
+ */
522
+ const redeployIfStale = ({
523
+ url,
524
+ scriptName,
525
+ profileName,
526
+ localState,
527
+ isCI,
528
+ }: {
529
+ url: string;
530
+ scriptName: string;
531
+ profileName: string;
532
+ localState: StateService;
533
+ isCI: boolean;
534
+ }) =>
535
+ Effect.gen(function* () {
536
+ if (yield* checkStateStoreVersion(url)) return undefined;
537
+ yield* Clank.info(
538
+ `Cloudflare State Store '${scriptName}' is out of date; redeploying...`,
539
+ );
540
+ return yield* finishBootstrap({
541
+ scriptName,
542
+ profileName,
543
+ localState,
544
+ isCI,
545
+ });
546
+ });
547
+
548
+ /**
549
+ * Probe the deployed worker's `/version` endpoint and decide whether
550
+ * it satisfies the current {@link STATE_STORE_VERSION} contract.
551
+ *
552
+ * Reuses the same typed {@link StateApi} client the rest of the store
553
+ * speaks, just without the bearer-token transform — the `/version`
554
+ * route is intentionally unauthenticated so we can probe it before
555
+ * trusting any locally-cached credentials.
556
+ *
557
+ * Returns `true` only on a successful response carrying the matching
558
+ * version. Any failure (404 from a pre-`/version` deploy, transport
559
+ * error, schema mismatch, version mismatch) collapses to `false`,
560
+ * since the caller's response in every case is the same: fall
561
+ * through to the idempotent bootstrap flow.
562
+ */
563
+ const checkStateStoreVersion = (url: string) =>
564
+ Effect.gen(function* () {
565
+ const client = yield* HttpApiClient.make(StateApi, { baseUrl: url });
566
+ const result = yield* client.version
567
+ .getVersion()
568
+ .pipe(Effect.catch(() => Effect.succeed(undefined)));
569
+ return result?.version === STATE_STORE_VERSION;
570
+ });
473
571
 
474
572
  /**
475
573
  * Tiny ES-module worker that reads `env.SECRET.get()` and echoes it
@@ -488,19 +586,32 @@ const SECRET_PROBE_SOURCE = `export default {
488
586
  };`;
489
587
 
490
588
  /**
491
- * Upload a tiny edge-preview worker that binds the given Secrets
492
- * Store secret, call it once, and return the decoded value. The
493
- * Cloudflare REST API deliberately hides secret values; only worker
494
- * bindings can resolve them, so this is the out-of-band path.
589
+ * Upload an ephemeral edge-preview build of the given (already
590
+ * deployed) script that binds the requested Secrets Store secret,
591
+ * call it once with the preview token, and return the decoded value.
592
+ * The Cloudflare REST API deliberately hides secret values; only
593
+ * worker bindings can resolve them, so this is the out-of-band path.
594
+ *
595
+ * `scriptName` MUST be a script that is already deployed on the
596
+ * account with workers.dev enabled — the `cf-workers-preview-token`
597
+ * header swaps our probe code in for an existing route, it does not
598
+ * create one. Using an undeployed name (or a deployed script that
599
+ * doesn't have workers.dev enabled) makes the workers.dev edge serve
600
+ * a generic Cloudflare 400 HTML error page instead of routing to the
601
+ * preview. The state-store script itself satisfies both conditions.
495
602
  */
496
- const readSecretViaEdge = (storeId: string, secretName: string) =>
603
+ const readSecretViaEdge = (
604
+ scriptName: string,
605
+ storeId: string,
606
+ secretName: string,
607
+ ) =>
497
608
  Effect.gen(function* () {
498
609
  const http = yield* HttpClient.HttpClient;
499
610
  const file = new File([SECRET_PROBE_SOURCE], "worker.js", {
500
611
  type: "application/javascript+module",
501
612
  });
502
613
  const session = yield* createEdgeSession({
503
- scriptName: PROBE_SCRIPT_NAME,
614
+ scriptName,
504
615
  files: [file],
505
616
  bindings: [
506
617
  { type: "secrets_store_secret", name: "SECRET", secretName, storeId },
@@ -513,6 +624,13 @@ const readSecretViaEdge = (storeId: string, secretName: string) =>
513
624
  const body = yield* response.text.pipe(
514
625
  Effect.catch(() => Effect.succeed("")),
515
626
  );
627
+ // TEMP(sam): dump the full body so we can capture the exact
628
+ // Cloudflare error page when the probe fails in the wild. Drop
629
+ // this once we've confirmed the routing fix covers all the
630
+ // observed failure modes.
631
+ yield* Effect.logWarning(
632
+ `Secret probe failed (${response.status}) at ${session.url}\n${body}`,
633
+ );
516
634
  return yield* Effect.fail(
517
635
  new EdgeSessionError({
518
636
  message: `Secret probe returned ${response.status}: ${body.slice(0, 200)}`,
@@ -23,8 +23,10 @@ type GetBindingType<T> = T extends Cloudflare.Assets
23
23
  ? KVNamespace
24
24
  : T extends Cloudflare.Queue
25
25
  ? Queue<unknown>
26
- : T extends Cloudflare.Artifacts
27
- ? Artifacts
28
- : T extends Cloudflare.DurableObjectNamespaceLike
29
- ? DurableObjectNamespace<Exclude<T["Shape"], undefined>>
30
- : never;
26
+ : T extends Cloudflare.AiGateway
27
+ ? Ai
28
+ : T extends Cloudflare.Artifacts
29
+ ? Artifacts
30
+ : T extends Cloudflare.DurableObjectNamespaceLike
31
+ ? DurableObjectNamespace<Exclude<T["Shape"], undefined>>
32
+ : never;
@@ -51,6 +51,7 @@ import { Self } from "../../Self.ts";
51
51
  import * as Serverless from "../../Serverless/index.ts";
52
52
  import { Stack } from "../../Stack.ts";
53
53
  import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
54
+ import type { AiGateway } from "../AiGateway/AiGateway.ts";
54
55
  import { D1Database } from "../D1/D1Database.ts";
55
56
  import { fromCloudflareFetcher } from "../Fetcher.ts";
56
57
  import type { KVNamespace } from "../KV/KVNamespace.ts";
@@ -190,6 +191,7 @@ export type WorkerBindingResource =
190
191
  | D1Database
191
192
  | KVNamespace
192
193
  | CloudflareQueue
194
+ | AiGateway
193
195
  | ArtifactsBinding
194
196
  | DurableObjectNamespaceLike<any>;
195
197
 
@@ -727,8 +729,13 @@ export const Worker: Platform<
727
729
  name: bindingName,
728
730
  queueName: binding.queueName,
729
731
  }
730
- : // TODO(sam): handle others
731
- undefined;
732
+ : binding.Type === "Cloudflare.AiGateway"
733
+ ? {
734
+ type: "ai",
735
+ name: bindingName,
736
+ }
737
+ : // TODO(sam): handle others
738
+ undefined;
732
739
 
733
740
  if (bindingMeta) {
734
741
  yield* resource.bind`${bindingName}`({
@@ -1499,7 +1506,7 @@ ${[
1499
1506
  getCompatibility(props);
1500
1507
 
1501
1508
  yield* Effect.promise(async () => {
1502
- const vite = await loadVite();
1509
+ const vite = await loadVite(props.vite?.rootDir);
1503
1510
  const builder = await vite.createBuilder(
1504
1511
  {
1505
1512
  root: props.vite?.rootDir,
@@ -1579,7 +1586,20 @@ ${[
1579
1586
  Effect.gen(function* () {
1580
1587
  if (props.vite) {
1581
1588
  const [{ assets, bundle }, input] = yield* Effect.all(
1582
- [viteBuild(props), hashDirectory(props.vite)],
1589
+ [
1590
+ viteBuild(props),
1591
+ // hashDirectory expects `{ cwd, memo }`. The vite props
1592
+ // store the project root under `rootDir`, so map it
1593
+ // here. Without this, `cwd` falls back to
1594
+ // `process.cwd()` and the input hash is computed over
1595
+ // the wrong directory tree (often the entire monorepo
1596
+ // root), making it both slow and unable to detect
1597
+ // changes scoped to the actual Vite project.
1598
+ hashDirectory({
1599
+ cwd: props.vite.rootDir,
1600
+ memo: props.vite.memo,
1601
+ }),
1602
+ ],
1583
1603
  { concurrency: "unbounded" },
1584
1604
  );
1585
1605
  return { assets, bundle, input };
@@ -1944,19 +1964,18 @@ ${[
1944
1964
  output: Worker["Attributes"],
1945
1965
  ) {
1946
1966
  if (props.vite) {
1947
- const input = yield* hashDirectory(props.vite);
1967
+ const input = yield* hashDirectory({
1968
+ cwd: props.vite.rootDir,
1969
+ memo: props.vite.memo,
1970
+ });
1948
1971
  return input !== output.hash?.input;
1949
1972
  }
1950
- // Always recompute both hashes by walking `dist/` (assets) and
1951
- // re-bundling the worker. The previous short-circuit
1952
- // (`Effect.succeed(output.hash.assets)`) compared the cached
1953
- // hash to itself and could never detect drift, and the
1954
- // `props.assets.hash` form is the *input* (source) hash which
1955
- // misses non-deterministic build output (e.g. Astro/Vite
1956
- // shuffling content-hashed filenames between builds). The
1957
- // result of either shortcut was deploying a new bundle on top
1958
- // of stale assets and 404'ing in production. Re-walking is the
1959
- // only correct comparison.
1973
+ // The asset hash comes from walking the actual `outdir` —
1974
+ // whatever bytes are on disk are what we'd be deploying, so
1975
+ // that's what we diff against. Non-deterministic build outputs
1976
+ // (e.g. Astro/Vite shuffling content-hashed chunk filenames)
1977
+ // are a property of the build, not something we should paper
1978
+ // over here. The bundle hash is similarly recomputed.
1960
1979
  const [assetsHash, bundleHash] = yield* Effect.all(
1961
1980
  [
1962
1981
  prepareAssets(props.assets).pipe(Effect.map((a) => a?.hash)),
@@ -2432,31 +2451,23 @@ const contentTypeFromExtension = (extension: string) => {
2432
2451
  };
2433
2452
 
2434
2453
  type ViteModule = typeof import("vite");
2435
- let _viteModule: ViteModule | null = null;
2436
2454
 
2437
2455
  /**
2438
2456
  * Dynamically load Vite from the project root. Falls back to the bundled
2439
2457
  * copy if the project doesn't have its own Vite installation.
2440
2458
  */
2441
- async function loadVite(): Promise<ViteModule> {
2442
- if (_viteModule) return _viteModule;
2443
-
2444
- const projectRoot = process.cwd();
2445
- let vitePath: string;
2446
-
2459
+ async function loadVite(
2460
+ projectRoot: string = process.cwd(),
2461
+ ): Promise<ViteModule> {
2447
2462
  try {
2448
- // Resolve "vite" from the project root, not from vinext's location
2449
2463
  const require = createRequire(path.join(projectRoot, "package.json"));
2450
- vitePath = require.resolve("vite");
2464
+ const vitePath = require.resolve("vite");
2465
+ // On Windows, absolute paths must be file:// URLs for ESM import().
2466
+ const viteUrl = pathToFileURL(vitePath);
2467
+ return await import(/* @vite-ignore */ viteUrl.href);
2451
2468
  } catch {
2452
- // Fallback: use the Vite that ships with vinext (works for non-linked installs)
2453
- vitePath = "vite";
2469
+ // Fallback: try to import vite from the global node_modules (works for non-linked installs)
2470
+ // The fallback is a bare specifier and works as-is.
2471
+ return await import("vite");
2454
2472
  }
2455
-
2456
- // On Windows, absolute paths must be file:// URLs for ESM import().
2457
- // The fallback ("vite") is a bare specifier and works as-is.
2458
- const viteUrl = vitePath === "vite" ? vitePath : pathToFileURL(vitePath).href;
2459
- const vite = (await import(/* @vite-ignore */ viteUrl)) as ViteModule;
2460
- _viteModule = vite;
2461
- return vite;
2462
2473
  }
@@ -1,5 +1,6 @@
1
1
  export * from "./ApiToken/index.ts";
2
2
  export * from "./Artifacts/index.ts";
3
+ export * from "./AiGateway/index.ts";
3
4
  export * from "./CloudflareEnvironment.ts";
4
5
  export * from "./Container/index.ts";
5
6
  export * from "./D1/index.ts";
package/src/Plan.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  type ResourceLike,
31
31
  } from "./Resource.ts";
32
32
  import { type StackSpec } from "./Stack.ts";
33
+ import { findCycleMembers } from "./Util/scc.ts";
33
34
  import {
34
35
  State,
35
36
  type CreatedResourceState,
@@ -147,6 +148,14 @@ export type Plan<Output = any> = {
147
148
  [id in string]?: Delete<ResourceLike>;
148
149
  };
149
150
  output: Output;
151
+ /**
152
+ * FQNs of resources that participate in a strongly-connected component
153
+ * of the upstream dependency graph (or have a self-edge). The scheduler
154
+ * uses this to decide whether an `update` node must publish its prior
155
+ * attr early to break the cycle, or can simply wait for upstreams and
156
+ * publish a fresh attr (the common, linear case).
157
+ */
158
+ cycleMembers: ReadonlySet<string>;
150
159
  };
151
160
 
152
161
  export interface MakePlanOptions {
@@ -660,12 +669,18 @@ export const make = <A>(
660
669
  )).map((update) => [update.resource.FQN, update]),
661
670
  ) as Plan["resources"];
662
671
 
672
+ // Compute SCC membership once. Apply uses it to decide whether an
673
+ // update node must publish its prior attr early to break a cycle, or
674
+ // can simply wait for upstreams like a DAG node (the common case).
675
+ const cycleMembers = findCycleMembers(allUpstreamDependencies);
676
+
663
677
  // Detect unsatisfiable dependency cycles among create/replace nodes.
664
- // Update/noop nodes signal their Deferred before waitForDeps so they
665
- // cannot deadlock. Create/replace nodes only signal early when they
666
- // have a precreate handler. Simulate the concurrent execution:
667
- // precreate nodes are immediately "resolved", then iteratively resolve
668
- // any node whose deps are all resolved. Remaining nodes would deadlock.
678
+ // Update/noop nodes signal their Deferred before waitForDeps when in a
679
+ // cycle so they cannot deadlock. Create/replace nodes only signal
680
+ // early when they have a precreate handler. Simulate the concurrent
681
+ // execution: precreate nodes are immediately "resolved", then
682
+ // iteratively resolve any node whose deps are all resolved. Remaining
683
+ // nodes would deadlock.
669
684
  {
670
685
  const createReplaceNodes = new Set(
671
686
  Object.entries(resourceGraph)
@@ -803,6 +818,7 @@ export const make = <A>(
803
818
  resources: resourceGraph,
804
819
  deletions,
805
820
  output: stack.output,
821
+ cycleMembers,
806
822
  } satisfies Plan<A> as Plan<A>;
807
823
  }).pipe(
808
824
  ensureArtifactStore,
@@ -141,6 +141,21 @@ export const GetReplacedResources = HttpApiEndpoint.get(
141
141
  },
142
142
  );
143
143
 
144
+ /** Response shape for the unauthenticated `/version` probe. */
145
+ export const VersionResponse = Schema.Struct({
146
+ version: Schema.Number,
147
+ });
148
+
149
+ /**
150
+ * Unauthenticated probe so clients can detect a stale (or absent)
151
+ * deployed worker without holding a valid bearer token. The returned
152
+ * version is bumped whenever the wire / behavioural contract changes
153
+ * in a way that requires a redeploy.
154
+ */
155
+ export const GetVersion = HttpApiEndpoint.get("getVersion", "/version", {
156
+ success: VersionResponse,
157
+ });
158
+
144
159
  export class StateGroup extends HttpApiGroup.make("state")
145
160
  .add(ListStacks)
146
161
  .add(ListStages)
@@ -152,4 +167,10 @@ export class StateGroup extends HttpApiGroup.make("state")
152
167
  .add(DeleteStack)
153
168
  .middleware(StateAuth) {}
154
169
 
155
- export class StateApi extends HttpApi.make("alchemy-state").add(StateGroup) {}
170
+ export class VersionGroup extends HttpApiGroup.make("version").add(
171
+ GetVersion,
172
+ ) {}
173
+
174
+ export class StateApi extends HttpApi.make("alchemy-state")
175
+ .add(StateGroup)
176
+ .add(VersionGroup) {}
@@ -0,0 +1,75 @@
1
+ import * as Deferred from "effect/Deferred";
2
+ import * as Effect from "effect/Effect";
3
+
4
+ /**
5
+ * Options for creating a cached function.
6
+ */
7
+ export interface CachedFunctionOptions<A> {
8
+ /**
9
+ * Function to convert arguments to a cache key string.
10
+ * Defaults to `JSON.stringify`.
11
+ */
12
+ readonly key?: (args: A) => string;
13
+ }
14
+
15
+ /**
16
+ * Creates a memoized version of a function that returns an Effect.
17
+ *
18
+ * The key feature is deduplication of concurrent calls with the same inputs -
19
+ * only one execution happens while other callers wait for and receive the same result.
20
+ *
21
+ * @example
22
+ * ```ts
23
+ * import * as Effect from "effect/Effect";
24
+ * import { cachedFunction } from "~/lib/cached-function";
25
+ *
26
+ * const fetchUser = (id: string) =>
27
+ * Effect.promise(() => fetch(`/users/${id}`).then(r => r.json()));
28
+ *
29
+ * const program = Effect.gen(function* () {
30
+ * const cachedFetchUser = yield* cachedFunction(fetchUser);
31
+ *
32
+ * // These concurrent calls will only trigger one fetch
33
+ * const [user1, user2] = yield* Effect.all([
34
+ * cachedFetchUser("123"),
35
+ * cachedFetchUser("123"),
36
+ * ]);
37
+ * });
38
+ * ```
39
+ */
40
+ export const cachedFunction = <A, B, E, R>(
41
+ fn: (args: A) => Effect.Effect<B, E, R>,
42
+ options?: CachedFunctionOptions<A>,
43
+ ): Effect.Effect<(args: A) => Effect.Effect<B, E, R>> =>
44
+ Effect.sync(() => {
45
+ const keyFn = options?.key ?? JSON.stringify;
46
+ const cache = new Map<string, Deferred.Deferred<B, E>>();
47
+
48
+ return (args: A): Effect.Effect<B, E, R> =>
49
+ Effect.suspend(() => {
50
+ const cacheKey = keyFn(args);
51
+ const existing = cache.get(cacheKey);
52
+
53
+ // If there's already a deferred for this key, wait on it
54
+ if (existing) {
55
+ return Deferred.await(existing);
56
+ }
57
+
58
+ // Create a new deferred and store it
59
+ return Effect.gen(function* () {
60
+ const deferred = yield* Deferred.make<B, E>();
61
+ cache.set(cacheKey, deferred);
62
+
63
+ // Execute the effect and complete the deferred
64
+ const exit = yield* Effect.exit(fn(args));
65
+ yield* Deferred.done(deferred, exit);
66
+
67
+ if (exit._tag === "Failure") {
68
+ cache.delete(cacheKey);
69
+ }
70
+
71
+ // Return the result
72
+ return yield* exit;
73
+ });
74
+ });
75
+ });