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.
Files changed (143) hide show
  1. package/bin/alchemy.js +12589 -2451
  2. package/bin/alchemy.js.map +1 -1
  3. package/bin/alchemy.ts +2 -0
  4. package/bin/cli.js +18 -0
  5. package/bin/commands/bootstrap.ts +88 -4
  6. package/bin/commands/deploy.ts +54 -20
  7. package/bin/commands/dev.ts +45 -0
  8. package/bin/exec.ts +22 -0
  9. package/lib/AdoptPolicy.d.ts +2 -2
  10. package/lib/AdoptPolicy.d.ts.map +1 -1
  11. package/lib/AdoptPolicy.js.map +1 -1
  12. package/lib/AlchemyContext.d.ts +1 -0
  13. package/lib/AlchemyContext.d.ts.map +1 -1
  14. package/lib/AlchemyContext.js +1 -0
  15. package/lib/AlchemyContext.js.map +1 -1
  16. package/lib/Build/Command.d.ts.map +1 -1
  17. package/lib/Build/Command.js +8 -1
  18. package/lib/Build/Command.js.map +1 -1
  19. package/lib/Bundle/Bundle.d.ts +21 -9
  20. package/lib/Bundle/Bundle.d.ts.map +1 -1
  21. package/lib/Bundle/Bundle.js +29 -15
  22. package/lib/Bundle/Bundle.js.map +1 -1
  23. package/lib/Cloudflare/Local/Sidecar.d.ts +27 -0
  24. package/lib/Cloudflare/Local/Sidecar.d.ts.map +1 -0
  25. package/lib/Cloudflare/Local/Sidecar.js +24 -0
  26. package/lib/Cloudflare/Local/Sidecar.js.map +1 -0
  27. package/lib/Cloudflare/Local/SidecarHandlers.d.ts +5 -0
  28. package/lib/Cloudflare/Local/SidecarHandlers.d.ts.map +1 -0
  29. package/lib/Cloudflare/Local/SidecarHandlers.js +119 -0
  30. package/lib/Cloudflare/Local/SidecarHandlers.js.map +1 -0
  31. package/lib/Cloudflare/Local/SidecarServer.d.ts +2 -0
  32. package/lib/Cloudflare/Local/SidecarServer.d.ts.map +1 -0
  33. package/lib/Cloudflare/Local/SidecarServer.js +27 -0
  34. package/lib/Cloudflare/Local/SidecarServer.js.map +1 -0
  35. package/lib/Cloudflare/SecretsStore/Secret.d.ts.map +1 -1
  36. package/lib/Cloudflare/SecretsStore/Secret.js +55 -7
  37. package/lib/Cloudflare/SecretsStore/Secret.js.map +1 -1
  38. package/lib/Cloudflare/SecretsStore/SecretsStore.d.ts.map +1 -1
  39. package/lib/Cloudflare/SecretsStore/SecretsStore.js +35 -11
  40. package/lib/Cloudflare/SecretsStore/SecretsStore.js.map +1 -1
  41. package/lib/Cloudflare/StateStore/State.d.ts +35 -0
  42. package/lib/Cloudflare/StateStore/State.d.ts.map +1 -1
  43. package/lib/Cloudflare/StateStore/State.js +187 -45
  44. package/lib/Cloudflare/StateStore/State.js.map +1 -1
  45. package/lib/Cloudflare/StateStore/index.d.ts +1 -1
  46. package/lib/Cloudflare/StateStore/index.d.ts.map +1 -1
  47. package/lib/Cloudflare/StateStore/index.js +1 -1
  48. package/lib/Cloudflare/StateStore/index.js.map +1 -1
  49. package/lib/Cloudflare/Workers/Assets.d.ts +1 -1
  50. package/lib/Cloudflare/Workers/Assets.d.ts.map +1 -1
  51. package/lib/Cloudflare/Workers/Assets.js +6 -1
  52. package/lib/Cloudflare/Workers/Assets.js.map +1 -1
  53. package/lib/Cloudflare/Workers/Compatibility.d.ts +6 -0
  54. package/lib/Cloudflare/Workers/Compatibility.d.ts.map +1 -0
  55. package/lib/Cloudflare/Workers/Compatibility.js +10 -0
  56. package/lib/Cloudflare/Workers/Compatibility.js.map +1 -0
  57. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts +7 -0
  58. package/lib/Cloudflare/Workers/LocalWorkerProvider.d.ts.map +1 -0
  59. package/lib/Cloudflare/Workers/LocalWorkerProvider.js +81 -0
  60. package/lib/Cloudflare/Workers/LocalWorkerProvider.js.map +1 -0
  61. package/lib/Cloudflare/Workers/Worker.d.ts +3 -1
  62. package/lib/Cloudflare/Workers/Worker.d.ts.map +1 -1
  63. package/lib/Cloudflare/Workers/Worker.js +54 -7
  64. package/lib/Cloudflare/Workers/Worker.js.map +1 -1
  65. package/lib/Cloudflare/Workers/WorkerBundle.d.ts +31 -0
  66. package/lib/Cloudflare/Workers/WorkerBundle.d.ts.map +1 -0
  67. package/lib/Cloudflare/Workers/WorkerBundle.js +174 -0
  68. package/lib/Cloudflare/Workers/WorkerBundle.js.map +1 -0
  69. package/lib/Cloudflare/Workers/WorkerName.d.ts +3 -0
  70. package/lib/Cloudflare/Workers/WorkerName.d.ts.map +1 -0
  71. package/lib/Cloudflare/Workers/WorkerName.js +9 -0
  72. package/lib/Cloudflare/Workers/WorkerName.js.map +1 -0
  73. package/lib/Cloudflare/Workers/index.d.ts +1 -0
  74. package/lib/Cloudflare/Workers/index.d.ts.map +1 -1
  75. package/lib/Cloudflare/Workers/index.js +1 -0
  76. package/lib/Cloudflare/Workers/index.js.map +1 -1
  77. package/lib/Platform.d.ts +1 -1
  78. package/lib/Platform.d.ts.map +1 -1
  79. package/lib/Sidecar/Lock.d.ts +24 -0
  80. package/lib/Sidecar/Lock.d.ts.map +1 -0
  81. package/lib/Sidecar/Lock.js +102 -0
  82. package/lib/Sidecar/Lock.js.map +1 -0
  83. package/lib/Sidecar/RpcClient.d.ts +15 -0
  84. package/lib/Sidecar/RpcClient.d.ts.map +1 -0
  85. package/lib/Sidecar/RpcClient.js +75 -0
  86. package/lib/Sidecar/RpcClient.js.map +1 -0
  87. package/lib/Sidecar/RpcHandler.d.ts +48 -0
  88. package/lib/Sidecar/RpcHandler.d.ts.map +1 -0
  89. package/lib/Sidecar/RpcHandler.js +64 -0
  90. package/lib/Sidecar/RpcHandler.js.map +1 -0
  91. package/lib/Sidecar/RpcPaths.d.ts +14 -0
  92. package/lib/Sidecar/RpcPaths.d.ts.map +1 -0
  93. package/lib/Sidecar/RpcPaths.js +22 -0
  94. package/lib/Sidecar/RpcPaths.js.map +1 -0
  95. package/lib/Sidecar/RpcServer.d.ts +10 -0
  96. package/lib/Sidecar/RpcServer.d.ts.map +1 -0
  97. package/lib/Sidecar/RpcServer.js +50 -0
  98. package/lib/Sidecar/RpcServer.js.map +1 -0
  99. package/lib/Sidecar/RpcTransport.d.ts +22 -0
  100. package/lib/Sidecar/RpcTransport.d.ts.map +1 -0
  101. package/lib/Sidecar/RpcTransport.js +81 -0
  102. package/lib/Sidecar/RpcTransport.js.map +1 -0
  103. package/lib/Telemetry/Metrics.d.ts +15 -0
  104. package/lib/Telemetry/Metrics.d.ts.map +1 -1
  105. package/lib/Telemetry/Metrics.js +20 -0
  106. package/lib/Telemetry/Metrics.js.map +1 -1
  107. package/lib/Util/PlatformServices.d.ts +3 -0
  108. package/lib/Util/PlatformServices.d.ts.map +1 -1
  109. package/lib/Util/PlatformServices.js +13 -0
  110. package/lib/Util/PlatformServices.js.map +1 -1
  111. package/lib/Util/layer-scoped.d.ts +16 -0
  112. package/lib/Util/layer-scoped.d.ts.map +1 -0
  113. package/lib/Util/layer-scoped.js +14 -0
  114. package/lib/Util/layer-scoped.js.map +1 -0
  115. package/package.json +4 -2
  116. package/src/AdoptPolicy.ts +9 -9
  117. package/src/AlchemyContext.ts +2 -0
  118. package/src/Build/Command.ts +8 -1
  119. package/src/Bundle/Bundle.ts +65 -25
  120. package/src/Cloudflare/Local/Sidecar.ts +44 -0
  121. package/src/Cloudflare/Local/SidecarHandlers.ts +182 -0
  122. package/src/Cloudflare/Local/SidecarServer.ts +57 -0
  123. package/src/Cloudflare/SecretsStore/Secret.ts +78 -7
  124. package/src/Cloudflare/SecretsStore/SecretsStore.ts +47 -12
  125. package/src/Cloudflare/StateStore/State.ts +281 -64
  126. package/src/Cloudflare/StateStore/index.ts +1 -1
  127. package/src/Cloudflare/Workers/Assets.ts +10 -1
  128. package/src/Cloudflare/Workers/Compatibility.ts +12 -0
  129. package/src/Cloudflare/Workers/LocalWorkerProvider.ts +93 -0
  130. package/src/Cloudflare/Workers/Worker.ts +102 -19
  131. package/src/Cloudflare/Workers/WorkerBundle.ts +234 -0
  132. package/src/Cloudflare/Workers/WorkerName.ts +10 -0
  133. package/src/Cloudflare/Workers/index.ts +1 -0
  134. package/src/Platform.ts +1 -1
  135. package/src/Sidecar/Lock.ts +190 -0
  136. package/src/Sidecar/RpcClient.ts +141 -0
  137. package/src/Sidecar/RpcHandler.ts +169 -0
  138. package/src/Sidecar/RpcPaths.ts +29 -0
  139. package/src/Sidecar/RpcServer.ts +80 -0
  140. package/src/Sidecar/RpcTransport.ts +85 -0
  141. package/src/Telemetry/Metrics.ts +38 -0
  142. package/src/Util/PlatformServices.ts +19 -0
  143. 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((hostname) =>
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
@@ -180,7 +180,7 @@ export const Platform = <
180
180
  string,
181
181
  | {
182
182
  env?: Record<string, any>;
183
- exports?: string[];
183
+ exports?: string[] | Record<string, any>;
184
184
  }
185
185
  | undefined
186
186
  >,
@@ -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);