alchemy 2.0.0-beta.23 → 2.0.0-beta.24
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/alchemy.js +12589 -2451
- package/bin/alchemy.js.map +1 -1
- package/bin/alchemy.ts +2 -0
- package/bin/cli.js +18 -0
- package/bin/commands/bootstrap.ts +88 -4
- package/bin/commands/deploy.ts +54 -20
- package/bin/commands/dev.ts +45 -0
- package/bin/exec.ts +22 -0
- package/lib/AdoptPolicy.d.ts +2 -2
- package/lib/AdoptPolicy.d.ts.map +1 -1
- package/lib/AdoptPolicy.js.map +1 -1
- package/lib/AlchemyContext.d.ts +1 -0
- package/lib/AlchemyContext.d.ts.map +1 -1
- package/lib/AlchemyContext.js +1 -0
- package/lib/AlchemyContext.js.map +1 -1
- package/lib/Build/Command.d.ts.map +1 -1
- package/lib/Build/Command.js +8 -1
- package/lib/Build/Command.js.map +1 -1
- package/lib/Bundle/Bundle.d.ts +21 -9
- package/lib/Bundle/Bundle.d.ts.map +1 -1
- package/lib/Bundle/Bundle.js +29 -15
- package/lib/Bundle/Bundle.js.map +1 -1
- package/lib/Cloudflare/Local/Sidecar.d.ts +27 -0
- package/lib/Cloudflare/Local/Sidecar.d.ts.map +1 -0
- package/lib/Cloudflare/Local/Sidecar.js +24 -0
- package/lib/Cloudflare/Local/Sidecar.js.map +1 -0
- package/lib/Cloudflare/Local/SidecarHandlers.d.ts +5 -0
- package/lib/Cloudflare/Local/SidecarHandlers.d.ts.map +1 -0
- package/lib/Cloudflare/Local/SidecarHandlers.js +119 -0
- package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -0
- package/lib/Cloudflare/Local/SidecarServer.d.ts +2 -0
- package/lib/Cloudflare/Local/SidecarServer.d.ts.map +1 -0
- package/lib/Cloudflare/Local/SidecarServer.js +27 -0
- package/lib/Cloudflare/Local/SidecarServer.js.map +1 -0
- package/lib/Cloudflare/SecretsStore/Secret.d.ts.map +1 -1
- package/lib/Cloudflare/SecretsStore/Secret.js +55 -7
- package/lib/Cloudflare/SecretsStore/Secret.js.map +1 -1
- package/lib/Cloudflare/SecretsStore/SecretsStore.d.ts.map +1 -1
- package/lib/Cloudflare/SecretsStore/SecretsStore.js +35 -11
- package/lib/Cloudflare/SecretsStore/SecretsStore.js.map +1 -1
- package/lib/Cloudflare/StateStore/State.d.ts +35 -0
- package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/State.js +187 -45
- package/lib/Cloudflare/StateStore/State.js.map +1 -1
- package/lib/Cloudflare/StateStore/index.d.ts +1 -1
- package/lib/Cloudflare/StateStore/index.d.ts.map +1 -1
- package/lib/Cloudflare/StateStore/index.js +1 -1
- package/lib/Cloudflare/StateStore/index.js.map +1 -1
- package/lib/Cloudflare/Workers/Assets.d.ts +1 -1
- package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Assets.js +6 -1
- package/lib/Cloudflare/Workers/Assets.js.map +1 -1
- package/lib/Cloudflare/Workers/Compatibility.d.ts +6 -0
- package/lib/Cloudflare/Workers/Compatibility.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/Compatibility.js +10 -0
- package/lib/Cloudflare/Workers/Compatibility.js.map +1 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +7 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js +81 -0
- package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -0
- package/lib/Cloudflare/Workers/Worker.d.ts +3 -1
- package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/Worker.js +54 -7
- package/lib/Cloudflare/Workers/Worker.js.map +1 -1
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts +31 -0
- package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/WorkerBundle.js +174 -0
- package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -0
- package/lib/Cloudflare/Workers/WorkerName.d.ts +3 -0
- package/lib/Cloudflare/Workers/WorkerName.d.ts.map +1 -0
- package/lib/Cloudflare/Workers/WorkerName.js +9 -0
- package/lib/Cloudflare/Workers/WorkerName.js.map +1 -0
- package/lib/Cloudflare/Workers/index.d.ts +1 -0
- package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
- package/lib/Cloudflare/Workers/index.js +1 -0
- package/lib/Cloudflare/Workers/index.js.map +1 -1
- package/lib/Platform.d.ts +1 -1
- package/lib/Platform.d.ts.map +1 -1
- package/lib/Sidecar/Lock.d.ts +24 -0
- package/lib/Sidecar/Lock.d.ts.map +1 -0
- package/lib/Sidecar/Lock.js +102 -0
- package/lib/Sidecar/Lock.js.map +1 -0
- package/lib/Sidecar/RpcClient.d.ts +15 -0
- package/lib/Sidecar/RpcClient.d.ts.map +1 -0
- package/lib/Sidecar/RpcClient.js +75 -0
- package/lib/Sidecar/RpcClient.js.map +1 -0
- package/lib/Sidecar/RpcHandler.d.ts +48 -0
- package/lib/Sidecar/RpcHandler.d.ts.map +1 -0
- package/lib/Sidecar/RpcHandler.js +64 -0
- package/lib/Sidecar/RpcHandler.js.map +1 -0
- package/lib/Sidecar/RpcPaths.d.ts +14 -0
- package/lib/Sidecar/RpcPaths.d.ts.map +1 -0
- package/lib/Sidecar/RpcPaths.js +22 -0
- package/lib/Sidecar/RpcPaths.js.map +1 -0
- package/lib/Sidecar/RpcServer.d.ts +10 -0
- package/lib/Sidecar/RpcServer.d.ts.map +1 -0
- package/lib/Sidecar/RpcServer.js +50 -0
- package/lib/Sidecar/RpcServer.js.map +1 -0
- package/lib/Sidecar/RpcTransport.d.ts +22 -0
- package/lib/Sidecar/RpcTransport.d.ts.map +1 -0
- package/lib/Sidecar/RpcTransport.js +81 -0
- package/lib/Sidecar/RpcTransport.js.map +1 -0
- package/lib/Telemetry/Metrics.d.ts +15 -0
- package/lib/Telemetry/Metrics.d.ts.map +1 -1
- package/lib/Telemetry/Metrics.js +20 -0
- package/lib/Telemetry/Metrics.js.map +1 -1
- package/lib/Util/PlatformServices.d.ts +3 -0
- package/lib/Util/PlatformServices.d.ts.map +1 -1
- package/lib/Util/PlatformServices.js +13 -0
- package/lib/Util/PlatformServices.js.map +1 -1
- package/lib/Util/layer-scoped.d.ts +16 -0
- package/lib/Util/layer-scoped.d.ts.map +1 -0
- package/lib/Util/layer-scoped.js +14 -0
- package/lib/Util/layer-scoped.js.map +1 -0
- package/package.json +4 -2
- package/src/AdoptPolicy.ts +9 -9
- package/src/AlchemyContext.ts +2 -0
- package/src/Build/Command.ts +8 -1
- package/src/Bundle/Bundle.ts +65 -25
- package/src/Cloudflare/Local/Sidecar.ts +44 -0
- package/src/Cloudflare/Local/SidecarHandlers.ts +182 -0
- package/src/Cloudflare/Local/SidecarServer.ts +57 -0
- package/src/Cloudflare/SecretsStore/Secret.ts +78 -7
- package/src/Cloudflare/SecretsStore/SecretsStore.ts +47 -12
- package/src/Cloudflare/StateStore/State.ts +281 -64
- package/src/Cloudflare/StateStore/index.ts +1 -1
- package/src/Cloudflare/Workers/Assets.ts +10 -1
- package/src/Cloudflare/Workers/Compatibility.ts +12 -0
- package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
- package/src/Cloudflare/Workers/Worker.ts +102 -19
- package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
- package/src/Cloudflare/Workers/WorkerName.ts +10 -0
- package/src/Cloudflare/Workers/index.ts +1 -0
- package/src/Platform.ts +1 -1
- package/src/Sidecar/Lock.ts +190 -0
- package/src/Sidecar/RpcClient.ts +141 -0
- package/src/Sidecar/RpcHandler.ts +169 -0
- package/src/Sidecar/RpcPaths.ts +29 -0
- package/src/Sidecar/RpcServer.ts +80 -0
- package/src/Sidecar/RpcTransport.ts +85 -0
- package/src/Telemetry/Metrics.ts +38 -0
- package/src/Util/PlatformServices.ts +19 -0
- package/src/Util/layer-scoped.ts +25 -0
|
@@ -22,6 +22,8 @@ import { pathToFileURL } from "node:url";
|
|
|
22
22
|
import type * as rolldown from "rolldown";
|
|
23
23
|
import Sonda from "sonda/rolldown";
|
|
24
24
|
import type * as vite from "vite";
|
|
25
|
+
import { AdoptPolicy } from "../../AdoptPolicy.ts";
|
|
26
|
+
import { AlchemyContext } from "../../AlchemyContext.ts";
|
|
25
27
|
import * as Artifacts from "../../Artifacts.ts";
|
|
26
28
|
import * as Binding from "../../Binding.ts";
|
|
27
29
|
import {
|
|
@@ -53,6 +55,7 @@ import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
|
|
|
53
55
|
import { D1Database } from "../D1/D1Database.ts";
|
|
54
56
|
import { fromCloudflareFetcher } from "../Fetcher.ts";
|
|
55
57
|
import type { KVNamespace } from "../KV/KVNamespace.ts";
|
|
58
|
+
import { SidecarLive } from "../Local/Sidecar.ts";
|
|
56
59
|
import { CloudflareLogs } from "../Logs.ts";
|
|
57
60
|
import type { Providers } from "../Providers.ts";
|
|
58
61
|
import type { Queue as CloudflareQueue } from "../Queue/Queue.ts";
|
|
@@ -72,6 +75,7 @@ import {
|
|
|
72
75
|
type DurableObjectNamespaceLike,
|
|
73
76
|
} from "./DurableObjectNamespace.ts";
|
|
74
77
|
import { workersHttpHandler } from "./HttpServer.ts";
|
|
78
|
+
import { LocalWorkerProvider } from "./LocalWorkerProvider.ts";
|
|
75
79
|
import { Request } from "./Request.ts";
|
|
76
80
|
import { makeRpcStub } from "./Rpc.ts";
|
|
77
81
|
import { isWorkflowExport } from "./Workflow.ts";
|
|
@@ -966,7 +970,30 @@ function getDurableObjectTagMap(tags: ReadonlyArray<string>) {
|
|
|
966
970
|
);
|
|
967
971
|
}
|
|
968
972
|
|
|
973
|
+
const selectLayer = <
|
|
974
|
+
LayerLive extends Layer.Layer<any, any, any>,
|
|
975
|
+
LayerDev extends Layer.Layer<any, any, any>,
|
|
976
|
+
>(input: {
|
|
977
|
+
live: () => LayerLive;
|
|
978
|
+
dev: () => LayerDev;
|
|
979
|
+
}): Layer.Layer<
|
|
980
|
+
Layer.Success<LayerLive | LayerDev>,
|
|
981
|
+
Layer.Error<LayerLive | LayerDev>,
|
|
982
|
+
Layer.Services<LayerLive | LayerDev> | AlchemyContext
|
|
983
|
+
> =>
|
|
984
|
+
Layer.unwrap(
|
|
985
|
+
AlchemyContext.useSync((context) =>
|
|
986
|
+
context.dev ? input.dev() : input.live(),
|
|
987
|
+
),
|
|
988
|
+
);
|
|
989
|
+
|
|
969
990
|
export const WorkerProvider = () =>
|
|
991
|
+
selectLayer({
|
|
992
|
+
live: LiveWorkerProvider,
|
|
993
|
+
dev: () => Layer.provide(LocalWorkerProvider(), SidecarLive),
|
|
994
|
+
});
|
|
995
|
+
|
|
996
|
+
export const LiveWorkerProvider = () =>
|
|
970
997
|
Provider.effect(
|
|
971
998
|
Worker,
|
|
972
999
|
Effect.gen(function* () {
|
|
@@ -1094,27 +1121,83 @@ export const WorkerProvider = () =>
|
|
|
1094
1121
|
|
|
1095
1122
|
if (desired.length === 0) return [];
|
|
1096
1123
|
|
|
1124
|
+
const adoptEnabled = yield* Effect.serviceOption(AdoptPolicy).pipe(
|
|
1125
|
+
Effect.map(Option.getOrElse(() => false)),
|
|
1126
|
+
);
|
|
1127
|
+
|
|
1097
1128
|
const zoneCache = new Map<string, string>();
|
|
1129
|
+
|
|
1130
|
+
/**
|
|
1131
|
+
* Attach `hostname` to `scriptName`, recovering from a "hostname
|
|
1132
|
+
* already in use" conflict when adoption is enabled.
|
|
1133
|
+
*
|
|
1134
|
+
* The Cloudflare API rejects a `PUT /workers/domains` whose
|
|
1135
|
+
* hostname is already registered (even when the existing entry
|
|
1136
|
+
* points at the same Worker, which is exactly what happens
|
|
1137
|
+
* after a state-store wipe). The SDK surfaces this as the
|
|
1138
|
+
* unmatched `UnknownCloudflareError` channel because there's
|
|
1139
|
+
* no dedicated error tag for it. With adoption opted-in we
|
|
1140
|
+
* list domains for the hostname; if the existing entry
|
|
1141
|
+
* already targets *this* Worker we adopt it in place. We
|
|
1142
|
+
* never transfer a hostname off another Worker — that would
|
|
1143
|
+
* silently re-route someone else's traffic.
|
|
1144
|
+
*/
|
|
1145
|
+
const attachDomain = Effect.fnUntraced(function* (hostname: string) {
|
|
1146
|
+
const zoneId = yield* inferZoneIdForHostname(hostname, zoneCache);
|
|
1147
|
+
return yield* putDomain({
|
|
1148
|
+
accountId,
|
|
1149
|
+
hostname,
|
|
1150
|
+
service: scriptName,
|
|
1151
|
+
zoneId,
|
|
1152
|
+
}).pipe(
|
|
1153
|
+
Effect.map((res) => ({
|
|
1154
|
+
hostname,
|
|
1155
|
+
id: res.id ?? "",
|
|
1156
|
+
zoneId: res.zoneId ?? zoneId,
|
|
1157
|
+
})),
|
|
1158
|
+
Effect.catchTag("UnknownCloudflareError", (err) =>
|
|
1159
|
+
adoptEnabled && /already in use/i.test(err.message)
|
|
1160
|
+
? adoptDomainIfSameService(hostname, zoneId, err)
|
|
1161
|
+
: Effect.fail(err),
|
|
1162
|
+
),
|
|
1163
|
+
);
|
|
1164
|
+
});
|
|
1165
|
+
|
|
1166
|
+
const adoptDomainIfSameService = Effect.fnUntraced(function* (
|
|
1167
|
+
hostname: string,
|
|
1168
|
+
zoneId: string,
|
|
1169
|
+
originalError: { message: string },
|
|
1170
|
+
) {
|
|
1171
|
+
const existing = yield* listDomains({
|
|
1172
|
+
accountId,
|
|
1173
|
+
hostname,
|
|
1174
|
+
}).pipe(
|
|
1175
|
+
Effect.map((r) =>
|
|
1176
|
+
(r.result ?? []).find((d) => d.hostname === hostname),
|
|
1177
|
+
),
|
|
1178
|
+
);
|
|
1179
|
+
if (existing?.id && existing.service === scriptName) {
|
|
1180
|
+
return {
|
|
1181
|
+
hostname,
|
|
1182
|
+
id: existing.id,
|
|
1183
|
+
zoneId: existing.zoneId ?? zoneId,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
1186
|
+
// The hostname exists but on a different Worker. Refuse to
|
|
1187
|
+
// hijack it; surface a more actionable message than the raw
|
|
1188
|
+
// Cloudflare error.
|
|
1189
|
+
return yield* Effect.die(
|
|
1190
|
+
new Error(
|
|
1191
|
+
`Cannot attach hostname '${hostname}' to Worker '${scriptName}': ` +
|
|
1192
|
+
`it is already attached to Worker '${existing?.service ?? "<unknown>"}'. ` +
|
|
1193
|
+
`Detach it from that Worker first, or pick a different hostname. ` +
|
|
1194
|
+
`(Cloudflare reported: ${originalError.message})`,
|
|
1195
|
+
),
|
|
1196
|
+
);
|
|
1197
|
+
});
|
|
1198
|
+
|
|
1098
1199
|
const applied = yield* Effect.all(
|
|
1099
|
-
desired.map(
|
|
1100
|
-
Effect.gen(function* () {
|
|
1101
|
-
const zoneId = yield* inferZoneIdForHostname(
|
|
1102
|
-
hostname,
|
|
1103
|
-
zoneCache,
|
|
1104
|
-
);
|
|
1105
|
-
const res = yield* putDomain({
|
|
1106
|
-
accountId,
|
|
1107
|
-
hostname,
|
|
1108
|
-
service: scriptName,
|
|
1109
|
-
zoneId,
|
|
1110
|
-
});
|
|
1111
|
-
return {
|
|
1112
|
-
hostname,
|
|
1113
|
-
id: res.id ?? "",
|
|
1114
|
-
zoneId: res.zoneId ?? zoneId,
|
|
1115
|
-
};
|
|
1116
|
-
}),
|
|
1117
|
-
),
|
|
1200
|
+
desired.map(attachDomain),
|
|
1118
1201
|
{ concurrency: "unbounded" },
|
|
1119
1202
|
);
|
|
1120
1203
|
return applied;
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import cloudflareRolldown from "@distilled.cloud/cloudflare-rolldown-plugin";
|
|
2
|
+
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as FileSystem from "effect/FileSystem";
|
|
4
|
+
import { flow } from "effect/Function";
|
|
5
|
+
import type * as Path from "effect/Path";
|
|
6
|
+
import * as Stream from "effect/Stream";
|
|
7
|
+
import type * as rolldown from "rolldown";
|
|
8
|
+
import * as Artifacts from "../../Artifacts.ts";
|
|
9
|
+
import * as Bundle from "../../Bundle/Bundle.ts";
|
|
10
|
+
import { findCwdForBundle } from "../../Bundle/TempRoot.ts";
|
|
11
|
+
import { Self } from "../../Self.ts";
|
|
12
|
+
import {
|
|
13
|
+
isDurableObjectExport,
|
|
14
|
+
type DurableObjectExport,
|
|
15
|
+
} from "./DurableObjectNamespace.ts";
|
|
16
|
+
import { isWorkflowExport, type WorkflowExport } from "./Workflow.ts";
|
|
17
|
+
|
|
18
|
+
export interface WorkerBundleOptions {
|
|
19
|
+
id: string;
|
|
20
|
+
main: string;
|
|
21
|
+
compatibility: {
|
|
22
|
+
date: string;
|
|
23
|
+
flags: string[];
|
|
24
|
+
};
|
|
25
|
+
entry:
|
|
26
|
+
| {
|
|
27
|
+
kind: "external";
|
|
28
|
+
}
|
|
29
|
+
| {
|
|
30
|
+
kind: "effect";
|
|
31
|
+
exports: Record<string, DurableObjectExport | WorkflowExport>;
|
|
32
|
+
};
|
|
33
|
+
stack: { name: string; stage: string };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const WorkerBundle = Effect.gen(function* () {
|
|
37
|
+
const fs = yield* FileSystem.FileSystem;
|
|
38
|
+
const context = yield* Effect.context<FileSystem.FileSystem | Path.Path>();
|
|
39
|
+
const virtualEntryPlugin = yield* Bundle.virtualEntryPlugin;
|
|
40
|
+
|
|
41
|
+
const makeOptions = Effect.fnUntraced(function* (
|
|
42
|
+
options: WorkerBundleOptions,
|
|
43
|
+
) {
|
|
44
|
+
const realMain = yield* fs.realPath(options.main).pipe(
|
|
45
|
+
Effect.mapError(
|
|
46
|
+
(cause) =>
|
|
47
|
+
new Bundle.BundleError({
|
|
48
|
+
message: `Failed to find real path for bundle: ${options.main}`,
|
|
49
|
+
cause,
|
|
50
|
+
}),
|
|
51
|
+
),
|
|
52
|
+
);
|
|
53
|
+
const inputOptions: rolldown.InputOptions = {
|
|
54
|
+
input: realMain,
|
|
55
|
+
cwd: yield* findCwdForBundle(realMain).pipe(
|
|
56
|
+
Effect.mapError(
|
|
57
|
+
(cause) =>
|
|
58
|
+
new Bundle.BundleError({
|
|
59
|
+
message: `Failed to find cwd for bundle: ${realMain}`,
|
|
60
|
+
cause,
|
|
61
|
+
}),
|
|
62
|
+
),
|
|
63
|
+
Effect.provide(context),
|
|
64
|
+
),
|
|
65
|
+
plugins: [
|
|
66
|
+
cloudflareRolldown({
|
|
67
|
+
compatibilityDate: options.compatibility.date,
|
|
68
|
+
compatibilityFlags: options.compatibility.flags,
|
|
69
|
+
}),
|
|
70
|
+
options.entry.kind === "effect"
|
|
71
|
+
? virtualEntryPlugin(
|
|
72
|
+
makeEffectVirtualEntry(options.entry.exports, options.stack),
|
|
73
|
+
)
|
|
74
|
+
: undefined,
|
|
75
|
+
],
|
|
76
|
+
checks: {
|
|
77
|
+
// Suppress unresolved import warnings for unrelated AWS packages
|
|
78
|
+
unresolvedImport: false,
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
const outputOptions: rolldown.OutputOptions = {
|
|
82
|
+
format: "esm",
|
|
83
|
+
sourcemap: "hidden",
|
|
84
|
+
minify: true,
|
|
85
|
+
keepNames: true,
|
|
86
|
+
dir: `.alchemy/bundles/${options.id}`,
|
|
87
|
+
};
|
|
88
|
+
return { inputOptions, outputOptions };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
build: flow(
|
|
93
|
+
makeOptions,
|
|
94
|
+
Effect.flatMap((resolved) =>
|
|
95
|
+
Bundle.build(resolved.inputOptions, resolved.outputOptions),
|
|
96
|
+
),
|
|
97
|
+
Artifacts.cached("build"),
|
|
98
|
+
),
|
|
99
|
+
watch: flow(
|
|
100
|
+
makeOptions,
|
|
101
|
+
Stream.fromEffect,
|
|
102
|
+
Stream.flatMap((resolved) =>
|
|
103
|
+
Bundle.watch(resolved.inputOptions, resolved.outputOptions),
|
|
104
|
+
),
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
const makeEffectVirtualEntry = (
|
|
110
|
+
exports: Record<string, DurableObjectExport | WorkflowExport>,
|
|
111
|
+
stack: { name: string; stage: string },
|
|
112
|
+
) => {
|
|
113
|
+
const durableObjectClasses: string[] = [];
|
|
114
|
+
const workflowClasses: string[] = [];
|
|
115
|
+
for (const [className, entry] of Object.entries(exports)) {
|
|
116
|
+
if (isDurableObjectExport(entry)) {
|
|
117
|
+
durableObjectClasses.push(className);
|
|
118
|
+
} else if (isWorkflowExport(entry)) {
|
|
119
|
+
workflowClasses.push(className);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return (importPath: string) => `import * as Config from "effect/Config";
|
|
123
|
+
import * as ConfigProvider from "effect/ConfigProvider";
|
|
124
|
+
import * as Console from "effect/Console";
|
|
125
|
+
import * as Effect from "effect/Effect";
|
|
126
|
+
import * as FetchHttpClient from "effect/unstable/http/FetchHttpClient";
|
|
127
|
+
import * as Layer from "effect/Layer";
|
|
128
|
+
import * as Logger from "effect/Logger";
|
|
129
|
+
import * as Context from "effect/Context";
|
|
130
|
+
import * as Stream from "effect/Stream";
|
|
131
|
+
|
|
132
|
+
import { env, DurableObject${workflowClasses.length > 0 ? ", WorkflowEntrypoint" : ""} } from "cloudflare:workers";
|
|
133
|
+
import { MinimumLogLevel } from "effect/References";
|
|
134
|
+
import { NodeServices } from "@effect/platform-node";
|
|
135
|
+
import { Stack } from "alchemy/Stack";
|
|
136
|
+
import { WorkerEnvironment, makeDurableObjectBridge${workflowClasses.length > 0 ? ", makeWorkflowBridge" : ""}, ExportedHandlerMethods } from "alchemy/Cloudflare";
|
|
137
|
+
|
|
138
|
+
import entry from "${importPath}";
|
|
139
|
+
|
|
140
|
+
const tag = Context.Service("${Self.key}")
|
|
141
|
+
const layer =
|
|
142
|
+
typeof entry?.build === "function"
|
|
143
|
+
? entry
|
|
144
|
+
: Layer.effect(tag, typeof entry?.asEffect === "function" ? entry.asEffect() : entry);
|
|
145
|
+
|
|
146
|
+
const platform = Layer.mergeAll(
|
|
147
|
+
NodeServices.layer,
|
|
148
|
+
FetchHttpClient.layer,
|
|
149
|
+
// TODO(sam): wire this up to telemetry more directly
|
|
150
|
+
Logger.layer([Logger.consolePretty()]),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
const stack = Layer.succeed(
|
|
154
|
+
Stack,
|
|
155
|
+
{
|
|
156
|
+
name: "${stack.name}",
|
|
157
|
+
stage: "${stack.stage}",
|
|
158
|
+
bindings: {},
|
|
159
|
+
resources: {}
|
|
160
|
+
}
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const exportsEffect = tag.asEffect().pipe(
|
|
164
|
+
Effect.flatMap(func => func.ExecutionContext.exports),
|
|
165
|
+
Effect.map(exports => exports),
|
|
166
|
+
Effect.provide(
|
|
167
|
+
layer.pipe(
|
|
168
|
+
Layer.provideMerge(stack),
|
|
169
|
+
// TODO(sam): additional credentials?
|
|
170
|
+
Layer.provideMerge(platform),
|
|
171
|
+
Layer.provideMerge(
|
|
172
|
+
Layer.succeed(
|
|
173
|
+
ConfigProvider.ConfigProvider,
|
|
174
|
+
ConfigProvider.orElse(
|
|
175
|
+
ConfigProvider.fromUnknown({ ALCHEMY_PHASE: "runtime" }),
|
|
176
|
+
ConfigProvider.fromUnknown(env),
|
|
177
|
+
),
|
|
178
|
+
)
|
|
179
|
+
),
|
|
180
|
+
Layer.provideMerge(
|
|
181
|
+
Layer.succeed(
|
|
182
|
+
WorkerEnvironment,
|
|
183
|
+
env,
|
|
184
|
+
)
|
|
185
|
+
),
|
|
186
|
+
Layer.provideMerge(
|
|
187
|
+
Layer.succeed(
|
|
188
|
+
MinimumLogLevel,
|
|
189
|
+
env.DEBUG ? "Debug" : "Info",
|
|
190
|
+
)
|
|
191
|
+
),
|
|
192
|
+
)
|
|
193
|
+
),
|
|
194
|
+
Effect.scoped
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
// TODO(sam): we could kick this off during module init, but any I/O will break deploy
|
|
198
|
+
// let exportsPromise = Effect.runPromise(exportsEffect);
|
|
199
|
+
|
|
200
|
+
// for now, we delay initializing the worker until the first request
|
|
201
|
+
let exportsPromise;
|
|
202
|
+
|
|
203
|
+
// don't initialize the workerEffect during module init because Cloudflare does not allow I/O during module init
|
|
204
|
+
// we cache it synchronously (??=) to guarnatee only one initialization ever happens
|
|
205
|
+
const getExports = () => (exportsPromise ??= Effect.runPromise(exportsEffect))
|
|
206
|
+
const getExport = (name) => getExports().then(exports => exports[name]?.make)
|
|
207
|
+
const worker = () => getExports().then(exports => exports.default)
|
|
208
|
+
|
|
209
|
+
export default Object.fromEntries(ExportedHandlerMethods.map(
|
|
210
|
+
method => [method, async (...args) => (await worker())[method](...args)])
|
|
211
|
+
) satisfies Required<cf.ExportedHandler>;
|
|
212
|
+
|
|
213
|
+
// export class proxy stubs for Durable Objects and Workflows
|
|
214
|
+
${[
|
|
215
|
+
...(durableObjectClasses.length > 0
|
|
216
|
+
? [
|
|
217
|
+
"const DurableObjectBridge = makeDurableObjectBridge(DurableObject, getExport);",
|
|
218
|
+
...durableObjectClasses.map(
|
|
219
|
+
(id) =>
|
|
220
|
+
`export class ${id} extends DurableObjectBridge("${id}") {}`,
|
|
221
|
+
),
|
|
222
|
+
]
|
|
223
|
+
: []),
|
|
224
|
+
...(workflowClasses.length > 0
|
|
225
|
+
? [
|
|
226
|
+
"const WorkflowBridgeFn = makeWorkflowBridge(WorkflowEntrypoint, getExport);",
|
|
227
|
+
...workflowClasses.map(
|
|
228
|
+
(id) => `export class ${id} extends WorkflowBridgeFn("${id}") {}`,
|
|
229
|
+
),
|
|
230
|
+
]
|
|
231
|
+
: []),
|
|
232
|
+
].join("\n")}
|
|
233
|
+
`;
|
|
234
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import * as Effect from "effect/Effect";
|
|
2
|
+
import { createPhysicalName } from "../../PhysicalName.ts";
|
|
3
|
+
|
|
4
|
+
export const createWorkerName = (id: string, name: string | undefined) =>
|
|
5
|
+
name
|
|
6
|
+
? Effect.succeed(name)
|
|
7
|
+
: createPhysicalName({
|
|
8
|
+
id,
|
|
9
|
+
maxLength: 54,
|
|
10
|
+
}).pipe(Effect.map((name) => name.toLowerCase()));
|
|
@@ -7,6 +7,7 @@ export * from "./DynamicWorkerLoader.ts";
|
|
|
7
7
|
export * from "./Fetch.ts";
|
|
8
8
|
export * from "./HttpServer.ts";
|
|
9
9
|
export * from "./InferEnv.ts";
|
|
10
|
+
export * from "./LocalWorkerProvider.ts";
|
|
10
11
|
export * from "./Request.ts";
|
|
11
12
|
export * from "./Rpc.ts";
|
|
12
13
|
export * from "./ScheduledEvents.ts";
|
package/src/Platform.ts
CHANGED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import * as Context from "effect/Context";
|
|
2
|
+
import * as Duration from "effect/Duration";
|
|
3
|
+
import * as Effect from "effect/Effect";
|
|
4
|
+
import * as Fiber from "effect/Fiber";
|
|
5
|
+
import * as FileSystem from "effect/FileSystem";
|
|
6
|
+
import * as Layer from "effect/Layer";
|
|
7
|
+
import * as Option from "effect/Option";
|
|
8
|
+
import * as Schedule from "effect/Schedule";
|
|
9
|
+
import * as Schema from "effect/Schema";
|
|
10
|
+
import type * as Scope from "effect/Scope";
|
|
11
|
+
import * as RpcPaths from "./RpcPaths.ts";
|
|
12
|
+
|
|
13
|
+
export class LockError extends Schema.TaggedErrorClass<LockError>()(
|
|
14
|
+
"LockError",
|
|
15
|
+
{
|
|
16
|
+
reason: Schema.Literals([
|
|
17
|
+
"Cancelled",
|
|
18
|
+
"Conflict",
|
|
19
|
+
"Invalid",
|
|
20
|
+
"Timeout",
|
|
21
|
+
"PlatformError",
|
|
22
|
+
]),
|
|
23
|
+
message: Schema.String,
|
|
24
|
+
cause: Schema.optional(Schema.DefectWithStack),
|
|
25
|
+
},
|
|
26
|
+
) {}
|
|
27
|
+
|
|
28
|
+
export class Lock extends Context.Service<
|
|
29
|
+
Lock,
|
|
30
|
+
{
|
|
31
|
+
readonly check: Effect.Effect<boolean>;
|
|
32
|
+
readonly acquire: Effect.Effect<
|
|
33
|
+
Fiber.Fiber<number, LockError>,
|
|
34
|
+
LockError,
|
|
35
|
+
Scope.Scope
|
|
36
|
+
>;
|
|
37
|
+
}
|
|
38
|
+
>()("Lock") {}
|
|
39
|
+
|
|
40
|
+
const LOCK_TTL = Duration.seconds(10);
|
|
41
|
+
const TOUCH_INTERVAL = Duration.seconds(1);
|
|
42
|
+
|
|
43
|
+
const make = Effect.gen(function* () {
|
|
44
|
+
const fs = yield* FileSystem.FileSystem;
|
|
45
|
+
const paths = yield* RpcPaths.RpcPaths;
|
|
46
|
+
|
|
47
|
+
const readLockFile = fs.readFileString(paths.lock).pipe(
|
|
48
|
+
Effect.mapError(
|
|
49
|
+
(e) =>
|
|
50
|
+
new LockError({
|
|
51
|
+
reason: "PlatformError",
|
|
52
|
+
message:
|
|
53
|
+
e.reason._tag === "NotFound"
|
|
54
|
+
? "Lock file not found"
|
|
55
|
+
: "Failed to read lock file",
|
|
56
|
+
cause: e,
|
|
57
|
+
}),
|
|
58
|
+
),
|
|
59
|
+
Effect.flatMap((text) => {
|
|
60
|
+
const pid = Number.parseInt(text);
|
|
61
|
+
return Number.isNaN(pid)
|
|
62
|
+
? Effect.fail(
|
|
63
|
+
new LockError({
|
|
64
|
+
reason: "Invalid",
|
|
65
|
+
message: "Lock file is invalid",
|
|
66
|
+
}),
|
|
67
|
+
)
|
|
68
|
+
: Effect.succeed(pid);
|
|
69
|
+
}),
|
|
70
|
+
);
|
|
71
|
+
|
|
72
|
+
const isLockProcessAlive = readLockFile.pipe(
|
|
73
|
+
Effect.flatMap((pid) =>
|
|
74
|
+
Effect.sync(() => {
|
|
75
|
+
try {
|
|
76
|
+
process.kill(pid, 0);
|
|
77
|
+
return true;
|
|
78
|
+
} catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}),
|
|
82
|
+
),
|
|
83
|
+
Effect.orElseSucceed(() => false),
|
|
84
|
+
);
|
|
85
|
+
|
|
86
|
+
const isLockFileStale = fs.stat(paths.lock).pipe(
|
|
87
|
+
Effect.map((stat) => {
|
|
88
|
+
const mtime = Option.getOrElse(stat.mtime, () => new Date(0));
|
|
89
|
+
return mtime.getTime() < Date.now() - Duration.toMillis(LOCK_TTL);
|
|
90
|
+
}),
|
|
91
|
+
Effect.orElseSucceed(() => true),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
const isLockValid = Effect.zipWith(
|
|
95
|
+
isLockProcessAlive,
|
|
96
|
+
isLockFileStale,
|
|
97
|
+
(alive, stale) => alive && !stale,
|
|
98
|
+
{
|
|
99
|
+
concurrent: true,
|
|
100
|
+
},
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const makeLockFile = fs
|
|
104
|
+
.writeFileString(paths.lock, process.pid.toString(), { flag: "wx" })
|
|
105
|
+
.pipe(
|
|
106
|
+
Effect.mapError((e) =>
|
|
107
|
+
e.reason._tag === "AlreadyExists"
|
|
108
|
+
? new LockError({
|
|
109
|
+
reason: "Conflict",
|
|
110
|
+
message: "Lock already held by another process",
|
|
111
|
+
})
|
|
112
|
+
: new LockError({
|
|
113
|
+
reason: "PlatformError",
|
|
114
|
+
message: "Failed to create lock file",
|
|
115
|
+
cause: e,
|
|
116
|
+
}),
|
|
117
|
+
),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
const assertOwnLock = readLockFile.pipe(
|
|
121
|
+
Effect.flatMap((pid) =>
|
|
122
|
+
pid === process.pid
|
|
123
|
+
? Effect.void
|
|
124
|
+
: Effect.fail(
|
|
125
|
+
new LockError({
|
|
126
|
+
reason: "Conflict",
|
|
127
|
+
message: "Lock not held by this process",
|
|
128
|
+
}),
|
|
129
|
+
),
|
|
130
|
+
),
|
|
131
|
+
);
|
|
132
|
+
|
|
133
|
+
const removeLock = fs.remove(paths.lock, { force: true }).pipe(
|
|
134
|
+
Effect.mapError(
|
|
135
|
+
(e) =>
|
|
136
|
+
new LockError({
|
|
137
|
+
reason: "PlatformError",
|
|
138
|
+
message: "Failed to remove lock file",
|
|
139
|
+
cause: e,
|
|
140
|
+
}),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
|
|
144
|
+
const releaseLock = assertOwnLock.pipe(Effect.andThen(() => removeLock));
|
|
145
|
+
|
|
146
|
+
const acquireLock = makeLockFile.pipe(
|
|
147
|
+
Effect.catchIf(
|
|
148
|
+
(e) => e.reason === "Conflict",
|
|
149
|
+
(e) =>
|
|
150
|
+
isLockValid.pipe(
|
|
151
|
+
Effect.flatMap((valid) =>
|
|
152
|
+
valid
|
|
153
|
+
? Effect.fail(e)
|
|
154
|
+
: removeLock.pipe(Effect.andThen(() => makeLockFile)),
|
|
155
|
+
),
|
|
156
|
+
),
|
|
157
|
+
),
|
|
158
|
+
);
|
|
159
|
+
|
|
160
|
+
const touchLock = assertOwnLock.pipe(
|
|
161
|
+
Effect.flatMap(() => {
|
|
162
|
+
const now = Date.now();
|
|
163
|
+
return fs.utimes(paths.lock, now, now).pipe(
|
|
164
|
+
Effect.mapError(
|
|
165
|
+
(e) =>
|
|
166
|
+
new LockError({
|
|
167
|
+
reason: "PlatformError",
|
|
168
|
+
message: "Failed to update lock file",
|
|
169
|
+
cause: e,
|
|
170
|
+
}),
|
|
171
|
+
),
|
|
172
|
+
);
|
|
173
|
+
}),
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return Lock.of({
|
|
177
|
+
check: isLockValid,
|
|
178
|
+
acquire: acquireLock.pipe(
|
|
179
|
+
Effect.flatMap(() =>
|
|
180
|
+
touchLock.pipe(
|
|
181
|
+
Effect.repeat(Schedule.spaced(TOUCH_INTERVAL)),
|
|
182
|
+
Effect.ensuring(releaseLock.pipe(Effect.ignore)),
|
|
183
|
+
Effect.forkScoped,
|
|
184
|
+
),
|
|
185
|
+
),
|
|
186
|
+
),
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
export const LockLive = Layer.effect(Lock, make);
|