alchemy 0.55.2 → 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 (87) hide show
  1. package/bin/alchemy.js +13920 -6724
  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-alias.d.ts +3 -0
  6. package/lib/cloudflare/bundle/plugin-alias.d.ts.map +1 -0
  7. package/lib/cloudflare/bundle/plugin-alias.js +40 -0
  8. package/lib/cloudflare/bundle/plugin-alias.js.map +1 -0
  9. package/lib/cloudflare/bundle/plugin-hot-reload.d.ts +1 -3
  10. package/lib/cloudflare/bundle/plugin-hot-reload.d.ts.map +1 -1
  11. package/lib/cloudflare/bundle/plugin-hot-reload.js +4 -1
  12. package/lib/cloudflare/bundle/plugin-hot-reload.js.map +1 -1
  13. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  14. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  15. package/lib/cloudflare/compatibility-presets.d.ts +1 -1
  16. package/lib/cloudflare/compatibility-presets.js +1 -1
  17. package/lib/cloudflare/compatibility-presets.js.map +1 -1
  18. package/lib/cloudflare/local-dev-cloudflare-shim.d.ts +3 -1
  19. package/lib/cloudflare/local-dev-cloudflare-shim.d.ts.map +1 -1
  20. package/lib/cloudflare/local-dev-cloudflare-shim.js +10 -5
  21. package/lib/cloudflare/local-dev-cloudflare-shim.js.map +1 -1
  22. package/lib/cloudflare/miniflare/build-worker-options.d.ts +22 -0
  23. package/lib/cloudflare/miniflare/build-worker-options.d.ts.map +1 -0
  24. package/lib/cloudflare/miniflare/build-worker-options.js +394 -0
  25. package/lib/cloudflare/miniflare/build-worker-options.js.map +1 -0
  26. package/lib/cloudflare/miniflare/miniflare-controller.d.ts +23 -0
  27. package/lib/cloudflare/miniflare/miniflare-controller.d.ts.map +1 -0
  28. package/lib/cloudflare/miniflare/miniflare-controller.js +110 -0
  29. package/lib/cloudflare/miniflare/miniflare-controller.js.map +1 -0
  30. package/lib/cloudflare/miniflare/miniflare-worker-proxy.d.ts.map +1 -1
  31. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js +5 -15
  32. package/lib/cloudflare/miniflare/miniflare-worker-proxy.js.map +1 -1
  33. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts +1 -5
  34. package/lib/cloudflare/miniflare/remote-binding-proxy.d.ts.map +1 -1
  35. package/lib/cloudflare/miniflare/remote-binding-proxy.js +17 -22
  36. package/lib/cloudflare/miniflare/remote-binding-proxy.js.map +1 -1
  37. package/lib/cloudflare/website.js +4 -4
  38. package/lib/cloudflare/website.js.map +1 -1
  39. package/lib/cloudflare/worker-bundle.d.ts +2 -0
  40. package/lib/cloudflare/worker-bundle.d.ts.map +1 -1
  41. package/lib/cloudflare/worker-bundle.js +21 -4
  42. package/lib/cloudflare/worker-bundle.js.map +1 -1
  43. package/lib/cloudflare/worker.d.ts.map +1 -1
  44. package/lib/cloudflare/worker.js +76 -97
  45. package/lib/cloudflare/worker.js.map +1 -1
  46. package/lib/context.d.ts +11 -0
  47. package/lib/context.d.ts.map +1 -1
  48. package/lib/context.js +8 -0
  49. package/lib/context.js.map +1 -1
  50. package/lib/scope.d.ts +12 -0
  51. package/lib/scope.d.ts.map +1 -1
  52. package/lib/scope.js +25 -0
  53. package/lib/scope.js.map +1 -1
  54. package/lib/util/telemetry/client.d.ts +3 -4
  55. package/lib/util/telemetry/client.d.ts.map +1 -1
  56. package/lib/util/telemetry/client.js +19 -25
  57. package/lib/util/telemetry/client.js.map +1 -1
  58. package/lib/util/telemetry/types.d.ts +1 -1
  59. package/lib/util/telemetry/types.d.ts.map +1 -1
  60. package/package.json +3 -2
  61. package/src/alchemy.ts +7 -0
  62. package/src/cloudflare/bundle/plugin-alias.ts +49 -0
  63. package/src/cloudflare/bundle/plugin-hot-reload.ts +4 -3
  64. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  65. package/src/cloudflare/compatibility-presets.ts +1 -1
  66. package/src/cloudflare/local-dev-cloudflare-shim.ts +11 -5
  67. package/src/cloudflare/miniflare/build-worker-options.ts +454 -0
  68. package/src/cloudflare/miniflare/miniflare-controller.ts +128 -0
  69. package/src/cloudflare/miniflare/miniflare-worker-proxy.ts +6 -15
  70. package/src/cloudflare/miniflare/remote-binding-proxy.ts +18 -23
  71. package/src/cloudflare/website.ts +4 -4
  72. package/src/cloudflare/worker-bundle.ts +30 -4
  73. package/src/cloudflare/worker.ts +90 -125
  74. package/src/context.ts +19 -0
  75. package/src/scope.ts +27 -0
  76. package/src/util/telemetry/client.ts +23 -33
  77. package/src/util/telemetry/types.ts +3 -3
  78. package/lib/cloudflare/miniflare/miniflare-worker-options.d.ts +0 -14
  79. package/lib/cloudflare/miniflare/miniflare-worker-options.d.ts.map +0 -1
  80. package/lib/cloudflare/miniflare/miniflare-worker-options.js +0 -468
  81. package/lib/cloudflare/miniflare/miniflare-worker-options.js.map +0 -1
  82. package/lib/cloudflare/miniflare/miniflare.d.ts +0 -40
  83. package/lib/cloudflare/miniflare/miniflare.d.ts.map +0 -1
  84. package/lib/cloudflare/miniflare/miniflare.js +0 -142
  85. package/lib/cloudflare/miniflare/miniflare.js.map +0 -1
  86. package/src/cloudflare/miniflare/miniflare-worker-options.ts +0 -599
  87. package/src/cloudflare/miniflare/miniflare.ts +0 -183
@@ -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,8 +1,10 @@
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";
7
+ import { esbuildPluginAlias } from "./bundle/plugin-alias.ts";
6
8
  import { esbuildPluginCompatWarning } from "./bundle/plugin-compat-warning.ts";
7
9
  import { createHotReloadPlugin } from "./bundle/plugin-hot-reload.ts";
8
10
  import { esbuildPluginHybridNodeCompat } from "./bundle/plugin-hybrid-node-compat.ts";
@@ -16,6 +18,7 @@ export interface WorkerBundle {
16
18
  }
17
19
 
18
20
  export function normalizeWorkerBundle(props: {
21
+ id: string;
19
22
  script: string | undefined;
20
23
  entrypoint: string | undefined;
21
24
  noBundle: boolean | undefined;
@@ -65,6 +68,7 @@ export function normalizeWorkerBundle(props: {
65
68
  sourcemaps: props.sourceMap !== false,
66
69
  })
67
70
  : new WorkerBundleSource.ESBuild({
71
+ id: props.id,
68
72
  entrypoint: props.entrypoint,
69
73
  format: props.format ?? "esm",
70
74
  nodeCompat,
@@ -237,6 +241,7 @@ export namespace WorkerBundleSource {
237
241
  esbuild.BuildOptions,
238
242
  "entryPoints" | "format" | "absWorkingDir" | "outdir"
239
243
  > {
244
+ id: string;
240
245
  entrypoint: string;
241
246
  cwd: string;
242
247
  outdir: string;
@@ -273,7 +278,20 @@ export namespace WorkerBundleSource {
273
278
 
274
279
  async *watch(signal: AbortSignal): AsyncIterable<WorkerBundle> {
275
280
  const wasm = createWasmPlugin();
276
- 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
+ });
277
295
  const options = this.buildOptions([wasm.plugin, hotReload.plugin]);
278
296
 
279
297
  const context = await esbuild.context(options);
@@ -281,6 +299,7 @@ export namespace WorkerBundleSource {
281
299
  await context.watch();
282
300
 
283
301
  for await (const result of hotReload.iterator) {
302
+ count++;
284
303
  const { entrypoint, root, modules } = this.resolveBuildOutput(
285
304
  result.metafile!,
286
305
  );
@@ -300,7 +319,14 @@ export namespace WorkerBundleSource {
300
319
  }
301
320
 
302
321
  private buildOptions(additionalPlugins: esbuild.Plugin[]) {
303
- 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;
304
330
  return {
305
331
  entryPoints: [entrypoint],
306
332
  absWorkingDir: cwd,
@@ -319,13 +345,13 @@ export namespace WorkerBundleSource {
319
345
  ...props.loader,
320
346
  },
321
347
  plugins: [
348
+ esbuildPluginAlias(props.alias ?? {}, this.props.cwd),
322
349
  nodeCompat === "v2"
323
350
  ? esbuildPluginHybridNodeCompat()
324
351
  : esbuildPluginCompatWarning(nodeCompat ?? null),
325
352
  ...(props.plugins ?? []),
326
353
  ...additionalPlugins,
327
354
  ],
328
- alias: props.alias,
329
355
  external: [
330
356
  ...(nodeCompat === "als" ? external_als : external),
331
357
  ...(props.external ?? []),
@@ -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"> & {
package/src/context.ts CHANGED
@@ -67,6 +67,17 @@ export interface BaseContext<Out extends Resource> {
67
67
  * "return undefined" so that `await MyResource()` always returns a value.
68
68
  */
69
69
  destroy(): never;
70
+ /**
71
+ * Register a cleanup function that will be called when the process exits.
72
+ *
73
+ * @example
74
+ * const proc = spawn('my-command', ['arg1', 'arg2']);
75
+ * this.onCleanup(async () => {
76
+ * proc.kill();
77
+ * await waitForExit(proc);
78
+ * });
79
+ */
80
+ onCleanup(fn: () => void | Promise<void>): void;
70
81
  /**
71
82
  * Create the Resource envelope (with Alchemy + User properties)
72
83
  */
@@ -153,6 +164,14 @@ export function context<
153
164
  destroy: () => {
154
165
  throw new DestroyedSignal();
155
166
  },
167
+ onCleanup: (fn: () => void | Promise<void>) => {
168
+ // make the function idempotent so repeated calls don't cause the process to hang
169
+ let promise: Promise<void> | undefined;
170
+ scope.root.onCleanup(async () => {
171
+ promise ??= Promise.resolve(fn());
172
+ await promise;
173
+ });
174
+ },
156
175
  create,
157
176
  }) as unknown as Context<Out>;
158
177
  }
package/src/scope.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import kleur from "kleur";
1
2
  import { AsyncLocalStorage } from "node:async_hooks";
2
3
  import util from "node:util";
3
4
  import type { Phase } from "./alchemy.ts";
@@ -99,6 +100,8 @@ export class Scope {
99
100
  new AsyncLocalStorage<Scope>());
100
101
  public static globals: Scope[] = (globalThis.__ALCHEMY_GLOBALS__ ??= []);
101
102
 
103
+ private static exitHandlerInstalled = false;
104
+
102
105
  public static getScope(): Scope | undefined {
103
106
  const scope = Scope.storage.getStore();
104
107
  if (!scope) {
@@ -144,6 +147,7 @@ export class Scope {
144
147
  private finalized = false;
145
148
  private startedAt = performance.now();
146
149
  private deferred: (() => Promise<any>)[] = [];
150
+ private cleanups: (() => Promise<void>)[] = [];
147
151
 
148
152
  public get appName(): string {
149
153
  if (this.parent) {
@@ -466,6 +470,7 @@ export class Scope {
466
470
  });
467
471
 
468
472
  if (!this.parent && process.env.ALCHEMY_TEST_KILL_ON_FINALIZE) {
473
+ await this.cleanup();
469
474
  process.exit(0);
470
475
  }
471
476
  }
@@ -537,6 +542,28 @@ export class Scope {
537
542
  return promise;
538
543
  }
539
544
 
545
+ /**
546
+ * Run all cleanup functions registered with `onCleanup`.
547
+ * This should only be called on the root scope.
548
+ */
549
+ public async cleanup() {
550
+ if (this.parent) return;
551
+ this.logger.log(kleur.gray("Exiting..."));
552
+ await Promise.allSettled(this.cleanups.map((cleanup) => cleanup()));
553
+ }
554
+
555
+ /**
556
+ * Register a cleanup function that will be called when the process exits.
557
+ * This should only be called on the root scope.
558
+ */
559
+ public onCleanup(fn: () => Promise<void>) {
560
+ if (this.parent) {
561
+ this.root.onCleanup(fn);
562
+ return;
563
+ }
564
+ this.cleanups.push(fn);
565
+ }
566
+
540
567
  /**
541
568
  * Returns a string representation of the scope.
542
569
  */