alchemy 0.55.3 → 0.56.0

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 (74) hide show
  1. package/bin/alchemy.js +46 -246
  2. package/lib/alchemy.d.ts.map +1 -1
  3. package/lib/alchemy.js +7 -0
  4. package/lib/alchemy.js.map +1 -1
  5. package/lib/cloudflare/bundle/plugin-hot-reload.d.ts +1 -3
  6. package/lib/cloudflare/bundle/plugin-hot-reload.d.ts.map +1 -1
  7. package/lib/cloudflare/bundle/plugin-hot-reload.js +4 -1
  8. package/lib/cloudflare/bundle/plugin-hot-reload.js.map +1 -1
  9. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  10. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  11. package/lib/cloudflare/compatibility-presets.d.ts +1 -1
  12. package/lib/cloudflare/compatibility-presets.js +1 -1
  13. package/lib/cloudflare/compatibility-presets.js.map +1 -1
  14. package/lib/cloudflare/local-dev-cloudflare-shim.d.ts +3 -1
  15. package/lib/cloudflare/local-dev-cloudflare-shim.d.ts.map +1 -1
  16. package/lib/cloudflare/local-dev-cloudflare-shim.js +10 -5
  17. package/lib/cloudflare/local-dev-cloudflare-shim.js.map +1 -1
  18. package/lib/cloudflare/miniflare/build-worker-options.d.ts +22 -0
  19. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -0
  20. package/lib/cloudflare/miniflare/build-worker-options.js +394 -0
  21. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -0
  22. package/lib/cloudflare/miniflare/miniflare-controller.d.ts +23 -0
  23. package/lib/cloudflare/miniflare/miniflare-controller.d.ts.map +1 -0
  24. package/lib/cloudflare/miniflare/miniflare-controller.js +110 -0
  25. package/lib/cloudflare/miniflare/miniflare-controller.js.map +1 -0
  26. package/lib/cloudflare/miniflare/miniflare-worker-proxy.d.ts.map +1 -1
  27. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js +5 -15
  28. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js.map +1 -1
  29. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts +1 -5
  30. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts.map +1 -1
  31. package/lib/cloudflare/miniflare/remote-binding-proxy.js +17 -22
  32. package/lib/cloudflare/miniflare/remote-binding-proxy.js.map +1 -1
  33. package/lib/cloudflare/website.js +4 -4
  34. package/lib/cloudflare/website.js.map +1 -1
  35. package/lib/cloudflare/worker-bundle.d.ts +2 -0
  36. package/lib/cloudflare/worker-bundle.d.ts.map +1 -1
  37. package/lib/cloudflare/worker-bundle.js +19 -3
  38. package/lib/cloudflare/worker-bundle.js.map +1 -1
  39. package/lib/cloudflare/worker.d.ts.map +1 -1
  40. package/lib/cloudflare/worker.js +76 -97
  41. package/lib/cloudflare/worker.js.map +1 -1
  42. package/lib/context.d.ts +11 -0
  43. package/lib/context.d.ts.map +1 -1
  44. package/lib/context.js +8 -0
  45. package/lib/context.js.map +1 -1
  46. package/lib/scope.d.ts +12 -0
  47. package/lib/scope.d.ts.map +1 -1
  48. package/lib/scope.js +25 -0
  49. package/lib/scope.js.map +1 -1
  50. package/package.json +3 -2
  51. package/src/alchemy.ts +7 -0
  52. package/src/cloudflare/bundle/plugin-hot-reload.ts +4 -3
  53. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  54. package/src/cloudflare/compatibility-presets.ts +1 -1
  55. package/src/cloudflare/local-dev-cloudflare-shim.ts +11 -5
  56. package/src/cloudflare/miniflare/build-worker-options.ts +454 -0
  57. package/src/cloudflare/miniflare/miniflare-controller.ts +128 -0
  58. package/src/cloudflare/miniflare/miniflare-worker-proxy.ts +6 -15
  59. package/src/cloudflare/miniflare/remote-binding-proxy.ts +18 -23
  60. package/src/cloudflare/website.ts +4 -4
  61. package/src/cloudflare/worker-bundle.ts +28 -3
  62. package/src/cloudflare/worker.ts +90 -125
  63. package/src/context.ts +19 -0
  64. package/src/scope.ts +27 -0
  65. package/lib/cloudflare/miniflare/miniflare-worker-options.d.ts +0 -14
  66. package/lib/cloudflare/miniflare/miniflare-worker-options.d.ts.map +0 -1
  67. package/lib/cloudflare/miniflare/miniflare-worker-options.js +0 -468
  68. package/lib/cloudflare/miniflare/miniflare-worker-options.js.map +0 -1
  69. package/lib/cloudflare/miniflare/miniflare.d.ts +0 -40
  70. package/lib/cloudflare/miniflare/miniflare.d.ts.map +0 -1
  71. package/lib/cloudflare/miniflare/miniflare.js +0 -142
  72. package/lib/cloudflare/miniflare/miniflare.js.map +0 -1
  73. package/src/cloudflare/miniflare/miniflare-worker-options.ts +0 -599
  74. package/src/cloudflare/miniflare/miniflare.ts +0 -183
@@ -0,0 +1,128 @@
1
+ import * as miniflare from "miniflare";
2
+ import assert from "node:assert";
3
+ import path from "node:path";
4
+ import { findOpenPort } from "../../util/find-open-port.ts";
5
+ import type { HTTPServer } from "../../util/http.ts";
6
+ import { logger } from "../../util/logger.ts";
7
+ import { AsyncMutex } from "../../util/mutex.ts";
8
+ import {
9
+ buildWorkerOptions,
10
+ type MiniflareWorkerInput,
11
+ } from "./build-worker-options.ts";
12
+ import { MiniflareWorkerProxy } from "./miniflare-worker-proxy.ts";
13
+
14
+ declare global {
15
+ var ALCHEMY_MINIFLARE_CONTROLLER: MiniflareController | undefined;
16
+ }
17
+
18
+ export class MiniflareController {
19
+ abort = new AbortController();
20
+ miniflare: miniflare.Miniflare | undefined;
21
+ options = new Map<string, miniflare.WorkerOptions>();
22
+ localProxies = new Map<string, MiniflareWorkerProxy>();
23
+ remoteProxies = new Map<string, HTTPServer>();
24
+ mutex = new AsyncMutex();
25
+
26
+ static get singleton() {
27
+ globalThis.ALCHEMY_MINIFLARE_CONTROLLER ??= new MiniflareController();
28
+ return globalThis.ALCHEMY_MINIFLARE_CONTROLLER;
29
+ }
30
+
31
+ async add(input: MiniflareWorkerInput) {
32
+ const { watch, remoteProxy } = await buildWorkerOptions(input);
33
+ if (remoteProxy) {
34
+ this.remoteProxies.set(input.name, remoteProxy);
35
+ }
36
+ const watcher = watch(this.abort.signal);
37
+ const first = await watcher.next();
38
+ assert(first.value, "First value is undefined");
39
+ this.options.set(input.name, first.value);
40
+ const miniflare = await this.update();
41
+ const proxy = new MiniflareWorkerProxy({
42
+ name: input.name,
43
+ port: input.port ?? (await findOpenPort()),
44
+ miniflare,
45
+ });
46
+ this.localProxies.set(input.name, proxy);
47
+ void this.watch(input.id, watcher);
48
+ logger.task(input.id, {
49
+ message: `Ready at ${proxy.url}`,
50
+ status: "success",
51
+ resource: input.id,
52
+ prefix: "dev",
53
+ prefixColor: "cyanBright",
54
+ });
55
+ return proxy.url;
56
+ }
57
+
58
+ private async watch(
59
+ id: string,
60
+ watcher: AsyncGenerator<miniflare.WorkerOptions>,
61
+ ) {
62
+ for await (const options of watcher) {
63
+ this.options.set(options.name!, options);
64
+ await this.update();
65
+ logger.task(id, {
66
+ message: "Updated",
67
+ status: "success",
68
+ resource: id,
69
+ prefix: "dev",
70
+ prefixColor: "cyanBright",
71
+ });
72
+ }
73
+ }
74
+
75
+ private async update() {
76
+ return await this.mutex.lock(async () => {
77
+ const options: miniflare.MiniflareOptions = {
78
+ workers: Array.from(this.options.values()),
79
+ defaultPersistRoot: path.join(process.cwd(), ".alchemy/miniflare"),
80
+ unsafeDevRegistryPath: miniflare.getDefaultDevRegistryPath(),
81
+ analyticsEngineDatasetsPersist: true,
82
+ cachePersist: true,
83
+ d1Persist: true,
84
+ durableObjectsPersist: true,
85
+ kvPersist: true,
86
+ r2Persist: true,
87
+ secretsStorePersist: true,
88
+ workflowsPersist: true,
89
+ log: process.env.DEBUG
90
+ ? new miniflare.Log(miniflare.LogLevel.DEBUG)
91
+ : undefined,
92
+ };
93
+ return await this.setMiniflareOptions(options);
94
+ });
95
+ }
96
+
97
+ private async setMiniflareOptions(options: miniflare.MiniflareOptions) {
98
+ try {
99
+ if (this.miniflare) {
100
+ await this.miniflare.setOptions(options);
101
+ } else {
102
+ this.miniflare = new miniflare.Miniflare(options);
103
+ await this.miniflare.ready;
104
+ }
105
+ return this.miniflare;
106
+ } catch (error) {
107
+ if (
108
+ error instanceof miniflare.MiniflareCoreError &&
109
+ error.code === "ERR_MODULE_STRING_SCRIPT"
110
+ ) {
111
+ throw new Error(
112
+ 'Miniflare detected an external dependency that could not be resolved. This typically occurs when the "nodejs_compat" or "nodejs_als" compatibility flag is not enabled.',
113
+ );
114
+ } else {
115
+ throw error;
116
+ }
117
+ }
118
+ }
119
+
120
+ async dispose() {
121
+ this.abort.abort();
122
+ await Promise.all([
123
+ this.miniflare?.dispose(),
124
+ ...this.localProxies.values().map((proxy) => proxy.close()),
125
+ ...this.remoteProxies.values().map((proxy) => proxy.close()),
126
+ ]);
127
+ }
128
+ }
@@ -55,18 +55,10 @@ export class MiniflareWorkerProxy {
55
55
  return;
56
56
  }
57
57
  this.wss.handleUpgrade(req, socket, head, (client) => {
58
- client.on("message", (event, binary) => {
59
- server.send(event, { binary });
60
- });
61
- client.on("close", (code, reason) => {
62
- server.close(code, reason);
63
- });
64
- server.on("message", (event, binary) => {
65
- client.send(event, { binary });
66
- });
67
- server.on("close", (code, reason) => {
68
- client.close(code, reason);
69
- });
58
+ client.on("message", (event, binary) => server.send(event, { binary }));
59
+ client.on("close", (code, reason) => server.close(code, reason));
60
+ server.on("message", (event, binary) => client.send(event, { binary }));
61
+ server.on("close", (code, reason) => client.close(code, reason));
70
62
  this.wss.emit("connection", client, req);
71
63
  });
72
64
  }
@@ -157,9 +149,8 @@ const writeMiniflareResponseToNode = (
157
149
  out: http.ServerResponse,
158
150
  ) => {
159
151
  out.statusCode = res.status;
160
- res.headers.forEach((value, key) => {
161
- out.setHeader(key, value);
162
- });
152
+ res.headers.forEach((value, key) => out.setHeader(key, value));
153
+
163
154
  if (res.body) {
164
155
  Readable.fromWeb(res.body).pipe(out, { end: true });
165
156
  } else {
@@ -32,7 +32,7 @@ export interface RemoteBindingProxy {
32
32
  export async function createRemoteProxyWorker(input: {
33
33
  name: string;
34
34
  bindings: WorkerBindingSpec[];
35
- }) {
35
+ }): Promise<RemoteBindingProxy> {
36
36
  const api = await createCloudflareApi();
37
37
  const script = await getInternalWorkerBundle("remote-binding-proxy");
38
38
  const [token, subdomain] = await Promise.all([
@@ -42,9 +42,7 @@ export async function createRemoteProxyWorker(input: {
42
42
  main_module: script.bundle.entrypoint,
43
43
  compatibility_date: "2025-06-16",
44
44
  bindings: input.bindings,
45
- observability: {
46
- enabled: false,
47
- },
45
+ observability: { enabled: false },
48
46
  },
49
47
  bundle: script.bundle,
50
48
  session: {
@@ -55,27 +53,24 @@ export async function createRemoteProxyWorker(input: {
55
53
  import("../worker-subdomain.ts").then((m) => m.getAccountSubdomain(api)),
56
54
  ]);
57
55
 
58
- const proxyURL = new URL(`https://${input.name}.${subdomain}.workers.dev`);
56
+ const proxyURL = `https://${input.name}.${subdomain}.workers.dev`;
59
57
  const server = new HTTPServer({
60
58
  fetch: async (req) => {
61
- const origin = new URL(req.url);
62
- const url = new URL(origin.pathname, proxyURL.toString());
63
- url.search = origin.search;
64
- url.hash = origin.hash;
59
+ const url = new URL(req.url);
60
+ const targetUrl = new URL(url.pathname + url.search, proxyURL);
65
61
 
66
- const requestHeaders = new Headers(req.headers);
67
- requestHeaders.set("cf-workers-preview-token", token);
68
- requestHeaders.set("host", proxyURL.hostname);
69
- requestHeaders.delete("cf-connecting-ip");
62
+ const headers = new Headers(req.headers);
63
+ headers.set("cf-workers-preview-token", token);
64
+ headers.set("host", new URL(proxyURL).hostname);
65
+ headers.delete("cf-connecting-ip");
70
66
 
71
- const res = await fetch(url, {
67
+ const res = await fetch(targetUrl, {
72
68
  method: req.method,
73
- headers: requestHeaders,
69
+ headers,
74
70
  body: req.body,
75
71
  redirect: "manual",
76
72
  });
77
73
 
78
- // Remove headers that are not supported by miniflare
79
74
  const responseHeaders = new Headers(res.headers);
80
75
  responseHeaders.delete("transfer-encoding");
81
76
  responseHeaders.delete("content-encoding");
@@ -86,6 +81,7 @@ export async function createRemoteProxyWorker(input: {
86
81
  });
87
82
  },
88
83
  });
84
+
89
85
  await server.listen();
90
86
  return {
91
87
  server,
@@ -126,13 +122,12 @@ async function createWorkersPreviewToken(
126
122
  }
127
123
 
128
124
  async function prewarm(url: string, previewToken: string) {
129
- const res = await fetch(url, {
130
- headers: {
131
- "cf-workers-preview-token": previewToken,
132
- },
133
- });
134
- if (!res.ok) {
135
- console.error(`Failed to prewarm worker: ${res.status} ${res.statusText}`);
125
+ try {
126
+ await fetch(url, {
127
+ headers: { "cf-workers-preview-token": previewToken },
128
+ });
129
+ } catch {
130
+ // Ignore prewarm errors
136
131
  }
137
132
  }
138
133
 
@@ -206,11 +206,11 @@ export default {
206
206
  } as WorkerProps<any> & { name: string };
207
207
 
208
208
  if (wrangler) {
209
- const wranglerPath = path.relative(cwd, wranglerJsonPath);
210
- const wranglerDir = path.dirname(wranglerPath);
209
+ const wranglerAbsPath = path.resolve(cwd, wranglerJsonPath);
210
+ const wranglerAbsDir = path.dirname(wranglerAbsPath);
211
211
 
212
212
  await WranglerJson("wrangler.jsonc", {
213
- path: wranglerPath,
213
+ path: path.relative(cwd, wranglerJsonPath),
214
214
  worker: workerProps,
215
215
  // @ts-expect-error - props.wrangler can be string | object, this is fine
216
216
  main: props.wrangler?.main ?? props.main,
@@ -218,7 +218,7 @@ export default {
218
218
  assets: {
219
219
  binding: "ASSETS",
220
220
  // path must be relative to the wrangler.jsonc file
221
- directory: path.relative(wranglerDir, assetsDirPath),
221
+ directory: path.relative(wranglerAbsDir, assetsDirPath),
222
222
  },
223
223
  transform: props.transform,
224
224
  });
@@ -1,7 +1,8 @@
1
1
  import esbuild from "esbuild";
2
2
  import { err, ok, type Result } from "neverthrow";
3
3
  import fs from "node:fs/promises";
4
- import path from "node:path";
4
+ import path from "pathe";
5
+ import { logger } from "../util/logger.ts";
5
6
  import { external, external_als } from "./bundle/externals.ts";
6
7
  import { esbuildPluginAlias } from "./bundle/plugin-alias.ts";
7
8
  import { esbuildPluginCompatWarning } from "./bundle/plugin-compat-warning.ts";
@@ -17,6 +18,7 @@ export interface WorkerBundle {
17
18
  }
18
19
 
19
20
  export function normalizeWorkerBundle(props: {
21
+ id: string;
20
22
  script: string | undefined;
21
23
  entrypoint: string | undefined;
22
24
  noBundle: boolean | undefined;
@@ -66,6 +68,7 @@ export function normalizeWorkerBundle(props: {
66
68
  sourcemaps: props.sourceMap !== false,
67
69
  })
68
70
  : new WorkerBundleSource.ESBuild({
71
+ id: props.id,
69
72
  entrypoint: props.entrypoint,
70
73
  format: props.format ?? "esm",
71
74
  nodeCompat,
@@ -238,6 +241,7 @@ export namespace WorkerBundleSource {
238
241
  esbuild.BuildOptions,
239
242
  "entryPoints" | "format" | "absWorkingDir" | "outdir"
240
243
  > {
244
+ id: string;
241
245
  entrypoint: string;
242
246
  cwd: string;
243
247
  outdir: string;
@@ -274,7 +278,20 @@ export namespace WorkerBundleSource {
274
278
 
275
279
  async *watch(signal: AbortSignal): AsyncIterable<WorkerBundle> {
276
280
  const wasm = createWasmPlugin();
277
- const hotReload = createHotReloadPlugin();
281
+ let count = 0;
282
+ const hotReload = createHotReloadPlugin({
283
+ onBuildStart: () => {
284
+ if (count > 0) {
285
+ logger.task(this.props.id, {
286
+ message: "Rebuilding",
287
+ status: "pending",
288
+ resource: this.props.id,
289
+ prefix: "dev",
290
+ prefixColor: "cyanBright",
291
+ });
292
+ }
293
+ },
294
+ });
278
295
  const options = this.buildOptions([wasm.plugin, hotReload.plugin]);
279
296
 
280
297
  const context = await esbuild.context(options);
@@ -282,6 +299,7 @@ export namespace WorkerBundleSource {
282
299
  await context.watch();
283
300
 
284
301
  for await (const result of hotReload.iterator) {
302
+ count++;
285
303
  const { entrypoint, root, modules } = this.resolveBuildOutput(
286
304
  result.metafile!,
287
305
  );
@@ -301,7 +319,14 @@ export namespace WorkerBundleSource {
301
319
  }
302
320
 
303
321
  private buildOptions(additionalPlugins: esbuild.Plugin[]) {
304
- const { entrypoint, nodeCompat, cwd, format, ...props } = this.props;
322
+ const {
323
+ id: _,
324
+ entrypoint,
325
+ nodeCompat,
326
+ cwd,
327
+ format,
328
+ ...props
329
+ } = this.props;
305
330
  return {
306
331
  entryPoints: [entrypoint],
307
332
  absWorkingDir: cwd,
@@ -1,4 +1,3 @@
1
- import kleur from "kleur";
2
1
  import { spawn } from "node:child_process";
3
2
  import fs from "node:fs/promises";
4
3
  import { isDeepStrictEqual } from "node:util";
@@ -9,6 +8,7 @@ import { Resource, ResourceKind } from "../resource.ts";
9
8
  import { isSecret } from "../secret.ts";
10
9
  import type { type } from "../type.ts";
11
10
  import { DeferredPromise } from "../util/deferred-promise.ts";
11
+ import { exists } from "../util/exists.ts";
12
12
  import { logger } from "../util/logger.ts";
13
13
  import { withExponentialBackoff } from "../util/retry.ts";
14
14
  import { CloudflareApiError, handleApiError } from "./api-error.ts";
@@ -38,8 +38,6 @@ import {
38
38
  isDurableObjectNamespace,
39
39
  } from "./durable-object-namespace.ts";
40
40
  import { type EventSource, isQueueEventSource } from "./event-source.ts";
41
- import type { MiniflareWorkerOptions } from "./miniflare/miniflare-worker-options.ts";
42
- import { miniflareServer } from "./miniflare/miniflare.ts";
43
41
  import {
44
42
  QueueConsumer,
45
43
  deleteQueueConsumer,
@@ -48,11 +46,7 @@ import {
48
46
  import { isQueue } from "./queue.ts";
49
47
  import { Route } from "./route.ts";
50
48
  import { uploadAssets } from "./worker-assets.ts";
51
- import {
52
- WorkerBundle,
53
- type WorkerBundleSource,
54
- normalizeWorkerBundle,
55
- } from "./worker-bundle.ts";
49
+ import { WorkerBundle, normalizeWorkerBundle } from "./worker-bundle.ts";
56
50
  import {
57
51
  type WorkerScriptMetadata,
58
52
  bumpMigrationTagVersion,
@@ -65,7 +59,6 @@ import { Workflow, isWorkflow, upsertWorkflow } from "./workflow.ts";
65
59
  // Previous versions of `Worker` used the `Bundle` resource.
66
60
  // This import is here to avoid errors when destroying the `Bundle` resource.
67
61
  import "../esbuild/bundle.ts";
68
- import { exists } from "../util/exists.ts";
69
62
 
70
63
  /**
71
64
  * Configuration options for static assets
@@ -736,6 +729,7 @@ const _Worker = Resource(
736
729
  : props.namespace?.namespaceName;
737
730
 
738
731
  const bundleSourceResult = normalizeWorkerBundle({
732
+ id,
739
733
  entrypoint: props.entrypoint,
740
734
  script: props.script,
741
735
  format: props.format,
@@ -762,7 +756,7 @@ const _Worker = Resource(
762
756
  }
763
757
 
764
758
  if (props.dev?.command) {
765
- const { url: commandUrl } = await createDevCommand({
759
+ const result = await createDevCommand({
766
760
  id,
767
761
  command: props.dev.command,
768
762
  cwd: props.dev.cwd || props.cwd || process.cwd(),
@@ -780,18 +774,25 @@ const _Worker = Resource(
780
774
  ),
781
775
  },
782
776
  });
783
- url = commandUrl;
777
+ url = result.url;
778
+ this.onCleanup(() => result.cleanup());
784
779
  } else {
785
- url = await createMiniflare({
780
+ const { MiniflareController } = await import(
781
+ "./miniflare/miniflare-controller.ts"
782
+ );
783
+ const controller = MiniflareController.singleton;
784
+ url = await controller.add({
786
785
  id,
787
- workerName,
786
+ name: workerName,
788
787
  compatibilityDate,
789
788
  compatibilityFlags,
790
789
  bindings: props.bindings,
790
+ eventSources: props.eventSources,
791
+ assets: props.assets,
791
792
  bundle: bundleSourceResult.value,
792
793
  port: props.dev?.port,
793
- assets: props.assets,
794
794
  });
795
+ this.onCleanup(() => controller.dispose());
795
796
  }
796
797
 
797
798
  return this({
@@ -926,7 +927,6 @@ const _Worker = Resource(
926
927
  let putWorkerResult: PutWorkerResult;
927
928
  if (watch) {
928
929
  const controller = new AbortController();
929
- cleanups.push(() => controller.abort());
930
930
  const promise = new DeferredPromise<PutWorkerResult>();
931
931
  const runWatch = async () => {
932
932
  for await (const bundle of bundleSource.watch(controller.signal)) {
@@ -973,14 +973,12 @@ const _Worker = Resource(
973
973
  }
974
974
  };
975
975
  void runWatch(); // this is not awaited because it's an ongoing process
976
+ this.onCleanup(() => controller.abort());
976
977
  putWorkerResult = await promise.value;
977
- await createTail(api, id, workerName)
978
- .then((tail) => {
979
- cleanups.push(() => tail.close());
980
- })
981
- .catch((error) => {
982
- logger.error(`Failed to create tail for ${workerName}`, error);
983
- });
978
+ const tail = await createTail(api, id, workerName).catch((error) => {
979
+ logger.error(`Failed to create tail for ${workerName}`, error);
980
+ });
981
+ this.onCleanup(() => tail?.close());
984
982
  } else {
985
983
  const scriptBundle = await bundleSource.create();
986
984
  putWorkerResult = await putWorkerWithAssets(props, scriptBundle);
@@ -1102,22 +1100,6 @@ const _Worker = Resource(
1102
1100
  },
1103
1101
  );
1104
1102
 
1105
- const cleanups: (() => any)[] = [];
1106
- let exiting = false;
1107
-
1108
- process.on("SIGINT", async () => {
1109
- if (cleanups.length > 0) {
1110
- if (!exiting) {
1111
- exiting = true;
1112
- logger.log(`\n${kleur.gray("Exiting...")}`);
1113
- }
1114
- // TODO: for some reason this runs twice...
1115
- // and this whole thing feels hacky anyway
1116
- await Promise.allSettled(cleanups.map((cleanup) => cleanup()));
1117
- }
1118
- process.exit(0);
1119
- });
1120
-
1121
1103
  const assertUnique = <T, Key extends keyof T>(
1122
1104
  inputs: T[],
1123
1105
  key: Key,
@@ -1337,56 +1319,12 @@ async function provisionSubdomain(
1337
1319
  }
1338
1320
  }
1339
1321
 
1340
- async function createMiniflare(props: {
1341
- id: string;
1342
- workerName: string;
1343
- compatibilityDate: string;
1344
- compatibilityFlags: string[];
1345
- bindings: Bindings | undefined;
1346
- port: number | undefined;
1347
- bundle: WorkerBundleSource;
1348
- assets: AssetsConfig | undefined;
1349
- }) {
1350
- const sharedOptions: Omit<MiniflareWorkerOptions, "bundle"> = {
1351
- name: props.workerName,
1352
- compatibilityDate: props.compatibilityDate,
1353
- compatibilityFlags: props.compatibilityFlags,
1354
- bindings: props.bindings,
1355
- port: props.port,
1356
- assets: props.assets,
1357
- };
1358
-
1359
- const startPromise = new DeferredPromise<string>();
1360
- const controller = new AbortController();
1361
- cleanups.push(() => controller.abort());
1362
- const run = async () => {
1363
- for await (const bundle of props.bundle.watch(controller.signal)) {
1364
- const server = await miniflareServer.push({
1365
- ...sharedOptions,
1366
- bundle,
1367
- });
1368
- if (startPromise.status === "pending") {
1369
- startPromise.resolve(server.url);
1370
- }
1371
- logger.task("", {
1372
- message: `ready at ${server.url}`,
1373
- status: "success",
1374
- resource: props.id,
1375
- prefix: "miniflare",
1376
- prefixColor: "greenBright",
1377
- });
1378
- }
1379
- };
1380
- void run();
1381
- return await startPromise.value;
1382
- }
1383
-
1384
1322
  async function createDevCommand(props: {
1385
1323
  id: string;
1386
1324
  command: string;
1387
1325
  cwd: string;
1388
1326
  env: Record<string, string | undefined>;
1389
- }): Promise<{ url: string }> {
1327
+ }) {
1390
1328
  const persistFile = path.join(process.cwd(), ".alchemy", `${props.id}.pid`);
1391
1329
  if (await exists(persistFile)) {
1392
1330
  const pid = Number.parseInt(await fs.readFile(persistFile, "utf8"));
@@ -1404,7 +1342,9 @@ async function createDevCommand(props: {
1404
1342
  }
1405
1343
  const command = props.command.split(" ");
1406
1344
  const [cmd, ...args] = command;
1407
- const proc = spawn(cmd, args, {
1345
+
1346
+ const promise = new DeferredPromise<string>();
1347
+ const childProcess = spawn(cmd, args, {
1408
1348
  cwd: props.cwd,
1409
1349
  shell: true,
1410
1350
  env: {
@@ -1420,56 +1360,81 @@ async function createDevCommand(props: {
1420
1360
  },
1421
1361
  stdio: ["inherit", "pipe", "pipe"],
1422
1362
  });
1423
- const { url } = await new Promise<{ url: string }>((resolve, reject) => {
1424
- let urlFound = false;
1425
- let stdout = "";
1426
- let stderr = "";
1427
- const urlRegex =
1428
- /http:\/\/(?:(?:localhost|0\.0\.0\.0|127\.0\.0\.1)|(?:\d{1,3}\.){3}\d{1,3}):\d+(?:\/)?/;
1429
-
1430
- const parseOutput = (data: string) => {
1431
- if (!urlFound) {
1432
- const match = data.match(urlRegex);
1433
- if (match) {
1434
- urlFound = true;
1435
- resolve({ url: match[0] });
1436
- }
1437
- }
1438
- };
1439
1363
 
1440
- // Handle stdout - parse for URL and write through with colors preserved
1441
- proc.stdout?.on("data", (data) => {
1442
- parseOutput(
1443
- (stdout += data.toString().replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")),
1444
- );
1445
- process.stdout.write(data);
1446
- });
1364
+ // Clean up the pid file when the process exits
1365
+ childProcess.once("exit", async () => {
1366
+ try {
1367
+ await fs.unlink(persistFile);
1368
+ } catch {
1369
+ // ignore
1370
+ }
1371
+ });
1447
1372
 
1448
- proc.stderr?.on("data", (data) => {
1449
- parseOutput(
1450
- (stderr += data.toString().replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")),
1451
- );
1452
- process.stderr.write(data);
1453
- });
1373
+ let urlFound = false;
1374
+ let stdout = "";
1375
+ let stderr = "";
1376
+ const urlRegex =
1377
+ /http:\/\/(?:(?:localhost|0\.0\.0\.0|127\.0\.0\.1)|(?:\d{1,3}\.){3}\d{1,3}):\d+(?:\/)?/;
1378
+
1379
+ const parseOutput = (data: string) => {
1380
+ if (!urlFound) {
1381
+ const match = data.match(urlRegex);
1382
+ if (match) {
1383
+ urlFound = true;
1384
+ promise.resolve(match[0]);
1385
+ }
1386
+ }
1387
+ };
1454
1388
 
1455
- proc.on("error", (error) => {
1456
- reject(error);
1457
- });
1389
+ // Handle stdout - parse for URL and write through with colors preserved
1390
+ childProcess.stdout?.on("data", (data) => {
1391
+ parseOutput(
1392
+ (stdout += data.toString().replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")),
1393
+ );
1394
+ process.stdout.write(data);
1395
+ });
1458
1396
 
1459
- cleanups.push(async () => {
1397
+ childProcess.stderr?.on("data", (data) => {
1398
+ parseOutput(
1399
+ (stderr += data.toString().replace(/\x1B\[[0-9;]*[a-zA-Z]/g, "")),
1400
+ );
1401
+ process.stderr.write(data);
1402
+ });
1403
+
1404
+ childProcess.on("error", (error) => {
1405
+ promise.reject(error);
1406
+ });
1407
+ if (childProcess.pid) {
1408
+ await fs.mkdir(path.dirname(persistFile), { recursive: true });
1409
+ await fs.writeFile(persistFile, childProcess.pid.toString());
1410
+ }
1411
+ return {
1412
+ url: await promise.value,
1413
+ cleanup: async () => {
1460
1414
  try {
1461
1415
  await fs.unlink(persistFile);
1462
1416
  } catch {
1463
1417
  // ignore
1464
1418
  }
1465
- proc.kill();
1466
- });
1467
- });
1468
- if (proc.pid) {
1469
- await fs.mkdir(path.dirname(persistFile), { recursive: true });
1470
- await fs.writeFile(persistFile, proc.pid.toString());
1471
- }
1472
- return { url };
1419
+ if (!childProcess.killed) {
1420
+ childProcess.kill("SIGTERM");
1421
+ // Give it time to exit gracefully
1422
+ await new Promise<void>((resolve) => {
1423
+ const timeout = setTimeout(() => {
1424
+ if (!childProcess.killed) {
1425
+ childProcess.kill("SIGKILL");
1426
+ }
1427
+ resolve();
1428
+ }, 5000);
1429
+
1430
+ childProcess.once("exit", () => {
1431
+ clearTimeout(timeout);
1432
+ resolve();
1433
+ });
1434
+ });
1435
+ }
1436
+ },
1437
+ };
1473
1438
  }
1474
1439
 
1475
1440
  type PutWorkerOptions = Omit<WorkerProps, "entrypoint"> & {