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.
- package/bin/exec.js +98 -6
- package/bin/exec.js.map +1 -1
- package/lib/AWS/IAM/SAMLProvider.d.ts +2 -1
- package/lib/AWS/IAM/SAMLProvider.d.ts.map +1 -1
- package/lib/AWS/IAM/SAMLProvider.js +30 -9
- package/lib/AWS/IAM/SAMLProvider.js.map +1 -1
- package/lib/Apply.d.ts.map +1 -1
- package/lib/Apply.js +55 -11
- package/lib/Apply.js.map +1 -1
- package/lib/Cli/commands/bootstrap.d.ts +1 -1
- package/lib/Cli/commands/bootstrap.d.ts.map +1 -1
- package/lib/Cli/commands/bootstrap.js +2 -1
- package/lib/Cli/commands/bootstrap.js.map +1 -1
- package/lib/Cloudflare/Access.d.ts +19 -0
- package/lib/Cloudflare/Access.d.ts.map +1 -0
- package/lib/Cloudflare/Access.js +64 -0
- package/lib/Cloudflare/Access.js.map +1 -0
- package/lib/Cloudflare/AiGateway/AiGateway.d.ts +244 -0
- package/lib/Cloudflare/AiGateway/AiGateway.d.ts.map +1 -0
- package/lib/Cloudflare/AiGateway/AiGateway.js +269 -0
- package/lib/Cloudflare/AiGateway/AiGateway.js.map +1 -0
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts +103 -0
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.d.ts.map +1 -0
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.js +99 -0
- package/lib/Cloudflare/AiGateway/AiGatewayBinding.js.map +1 -0
- package/lib/Cloudflare/AiGateway/index.d.ts +3 -0
- package/lib/Cloudflare/AiGateway/index.d.ts.map +1 -0
- package/lib/Cloudflare/AiGateway/index.js +3 -0
- package/lib/Cloudflare/AiGateway/index.js.map +1 -0
- package/lib/Cloudflare/EdgeSession.d.ts +2 -1
- package/lib/Cloudflare/EdgeSession.d.ts.map +1 -1
- package/lib/Cloudflare/EdgeSession.js +8 -4
- package/lib/Cloudflare/EdgeSession.js.map +1 -1
- package/lib/Cloudflare/Providers.d.ts +2 -1
- package/lib/Cloudflare/Providers.d.ts.map +1 -1
- package/lib/Cloudflare/Providers.js +5 -1
- package/lib/Cloudflare/Providers.js.map +1 -1
- package/lib/Cloudflare/StateStore/Api.d.ts +10 -0
- package/lib/Cloudflare/StateStore/Api.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/Api.js +12 -1
- package/lib/Cloudflare/StateStore/Api.js.map +1 -1
- package/lib/Cloudflare/StateStore/State.d.ts +4 -3
- package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/State.js +103 -13
- package/lib/Cloudflare/StateStore/State.js.map +1 -1
- package/lib/Cloudflare/Workers/InferEnv.d.ts +1 -1
- package/lib/Cloudflare/Workers/InferEnv.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.d.ts +2 -1
- package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.js +40 -31
- package/lib/Cloudflare/Workers/Worker.js.map +1 -1
- package/lib/Cloudflare/index.d.ts +1 -0
- package/lib/Cloudflare/index.d.ts.map +1 -1
- package/lib/Cloudflare/index.js +1 -0
- package/lib/Cloudflare/index.js.map +1 -1
- package/lib/Plan.d.ts +8 -0
- package/lib/Plan.d.ts.map +1 -1
- package/lib/Plan.js +12 -5
- package/lib/Plan.js.map +1 -1
- package/lib/State/HttpStateApi.d.ts +19 -1
- package/lib/State/HttpStateApi.d.ts.map +1 -1
- package/lib/State/HttpStateApi.js +18 -1
- package/lib/State/HttpStateApi.js.map +1 -1
- package/lib/Util/cached-function.d.ts +38 -0
- package/lib/Util/cached-function.d.ts.map +1 -0
- package/lib/Util/cached-function.js +53 -0
- package/lib/Util/cached-function.js.map +1 -0
- package/lib/Util/scc.d.ts +12 -0
- package/lib/Util/scc.d.ts.map +1 -0
- package/lib/Util/scc.js +81 -0
- package/lib/Util/scc.js.map +1 -0
- package/lib/tsconfig.test.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/AWS/IAM/SAMLProvider.ts +47 -18
- package/src/Apply.ts +72 -8
- package/src/Cli/commands/bootstrap.ts +2 -0
- package/src/Cloudflare/Access.ts +110 -0
- package/src/Cloudflare/AiGateway/AiGateway.ts +526 -0
- package/src/Cloudflare/AiGateway/AiGatewayBinding.ts +190 -0
- package/src/Cloudflare/AiGateway/index.ts +2 -0
- package/src/Cloudflare/EdgeSession.ts +15 -5
- package/src/Cloudflare/Providers.ts +7 -0
- package/src/Cloudflare/StateStore/Api.ts +18 -0
- package/src/Cloudflare/StateStore/State.ts +130 -12
- package/src/Cloudflare/Workers/InferEnv.ts +7 -5
- package/src/Cloudflare/Workers/Worker.ts +44 -33
- package/src/Cloudflare/index.ts +1 -0
- package/src/Plan.ts +21 -5
- package/src/State/HttpStateApi.ts +22 -1
- package/src/Util/cached-function.ts +75 -0
- package/src/Util/scc.ts +89 -0
|
@@ -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 },
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
/**
|
|
472
|
-
|
|
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
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
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 = (
|
|
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
|
|
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.
|
|
27
|
-
?
|
|
28
|
-
: T extends Cloudflare.
|
|
29
|
-
?
|
|
30
|
-
:
|
|
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
|
-
:
|
|
731
|
-
|
|
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
|
-
[
|
|
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(
|
|
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
|
-
//
|
|
1951
|
-
//
|
|
1952
|
-
//
|
|
1953
|
-
//
|
|
1954
|
-
//
|
|
1955
|
-
//
|
|
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(
|
|
2442
|
-
|
|
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:
|
|
2453
|
-
|
|
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
|
}
|
package/src/Cloudflare/index.ts
CHANGED
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
|
|
665
|
-
// cannot deadlock. Create/replace nodes only signal
|
|
666
|
-
// have a precreate handler. Simulate the concurrent
|
|
667
|
-
// precreate nodes are immediately "resolved", then
|
|
668
|
-
// any node whose deps are all resolved. Remaining
|
|
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
|
|
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
|
+
});
|