alchemy 0.77.0 → 0.77.2

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 (45) hide show
  1. package/bin/alchemy.js +21741 -2128
  2. package/lib/cloudflare/bucket-custom-domain.d.ts +4 -4
  3. package/lib/cloudflare/bucket-custom-domain.d.ts.map +1 -1
  4. package/lib/cloudflare/bucket-custom-domain.js +7 -7
  5. package/lib/cloudflare/bucket-custom-domain.js.map +1 -1
  6. package/lib/cloudflare/compatibility-date.gen.d.ts +1 -1
  7. package/lib/cloudflare/compatibility-date.gen.js +1 -1
  8. package/lib/cloudflare/container.d.ts.map +1 -1
  9. package/lib/cloudflare/container.js +8 -5
  10. package/lib/cloudflare/container.js.map +1 -1
  11. package/lib/cloudflare/d1-database.d.ts +13 -3
  12. package/lib/cloudflare/d1-database.d.ts.map +1 -1
  13. package/lib/cloudflare/d1-database.js +31 -15
  14. package/lib/cloudflare/d1-database.js.map +1 -1
  15. package/lib/docker/api.d.ts +4 -0
  16. package/lib/docker/api.d.ts.map +1 -1
  17. package/lib/docker/api.js +3 -0
  18. package/lib/docker/api.js.map +1 -1
  19. package/lib/docker/image.d.ts +50 -12
  20. package/lib/docker/image.d.ts.map +1 -1
  21. package/lib/docker/image.js +86 -58
  22. package/lib/docker/image.js.map +1 -1
  23. package/lib/docker/remote-image.d.ts +1 -0
  24. package/lib/docker/remote-image.d.ts.map +1 -1
  25. package/lib/docker/remote-image.js +2 -0
  26. package/lib/docker/remote-image.js.map +1 -1
  27. package/lib/util/safe-fetch.d.ts +1 -0
  28. package/lib/util/safe-fetch.d.ts.map +1 -1
  29. package/lib/util/safe-fetch.js +1 -1
  30. package/lib/util/safe-fetch.js.map +1 -1
  31. package/lib/util/telemetry.d.ts.map +1 -1
  32. package/lib/util/telemetry.js +32 -20
  33. package/lib/util/telemetry.js.map +1 -1
  34. package/package.json +1 -1
  35. package/src/cloudflare/bucket-custom-domain.ts +9 -9
  36. package/src/cloudflare/compatibility-date.gen.ts +1 -1
  37. package/src/cloudflare/container.ts +10 -7
  38. package/src/cloudflare/d1-database.ts +44 -6
  39. package/src/docker/api.ts +7 -0
  40. package/src/docker/image.ts +164 -89
  41. package/src/docker/remote-image.ts +3 -0
  42. package/src/util/safe-fetch.ts +1 -1
  43. package/src/util/telemetry.ts +39 -19
  44. package/workers/cloudflare-state-store.js +57 -57
  45. package/workers/tunnel-proxy.js +1 -1
@@ -8,6 +8,7 @@ import {
8
8
  type CloudflareApi,
9
9
  type CloudflareApiOptions,
10
10
  } from "./api.ts";
11
+ import { withJurisdiction } from "./bucket.ts";
11
12
  import { cloneD1Database } from "./d1-clone.ts";
12
13
  import { applyLocalD1Migrations } from "./d1-local-migrations.ts";
13
14
  import { applyMigrations, listMigrationsFiles } from "./d1-migrations.ts";
@@ -15,6 +16,8 @@ import { deleteMiniflareBinding } from "./miniflare/delete.ts";
15
16
 
16
17
  const DEFAULT_MIGRATIONS_TABLE = "d1_migrations";
17
18
 
19
+ export type D1DatabaseJurisdiction = "default" | "eu" | "fedramp";
20
+
18
21
  type PrimaryLocationHint =
19
22
  | "wnam"
20
23
  | "enam"
@@ -115,6 +118,12 @@ export interface D1DatabaseProps extends CloudflareApiOptions {
115
118
  */
116
119
  force?: boolean;
117
120
  };
121
+
122
+ /**
123
+ * Optional jurisdiction for the bucket
124
+ * Determines the regulatory jurisdiction the bucket data falls under
125
+ */
126
+ jurisdiction?: D1DatabaseJurisdiction;
118
127
  }
119
128
 
120
129
  export function isD1Database(resource: any): resource is D1Database {
@@ -157,6 +166,11 @@ export type D1Database = Pick<
157
166
  */
158
167
  remote: boolean;
159
168
  };
169
+
170
+ /**
171
+ * The jurisdiction of the database
172
+ */
173
+ jurisdiction: D1DatabaseJurisdiction;
160
174
  };
161
175
 
162
176
  /**
@@ -260,10 +274,11 @@ const _D1Database = Resource(
260
274
  async function (
261
275
  this: Context<D1Database>,
262
276
  id: string,
263
- props: D1DatabaseProps = {},
277
+ props: D1DatabaseProps,
264
278
  ): Promise<D1Database> {
265
279
  const databaseName =
266
280
  props.name ?? this.output?.name ?? this.scope.createPhysicalName(id);
281
+ const jurisdiction = props.jurisdiction ?? "default";
267
282
 
268
283
  if (this.phase === "update" && this.output?.name !== databaseName) {
269
284
  this.replace();
@@ -294,6 +309,7 @@ const _D1Database = Resource(
294
309
  migrationsDir: props.migrationsDir,
295
310
  migrationsTable: props.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE,
296
311
  dev,
312
+ jurisdiction,
297
313
  };
298
314
  }
299
315
 
@@ -304,7 +320,7 @@ const _D1Database = Resource(
304
320
  await deleteMiniflareBinding(this.scope, "d1", this.output.dev.id);
305
321
  }
306
322
  if (props.delete !== false && this.output?.id) {
307
- await deleteDatabase(api, this.output.id);
323
+ await deleteDatabase(api, this.output.id, props);
308
324
  }
309
325
  // Return void (a deleted database has no content)
310
326
  return this.destroy();
@@ -328,7 +344,7 @@ const _D1Database = Resource(
328
344
 
329
345
  // If clone property is provided, perform cloning after database creation
330
346
  if (props.clone && dbData.result.uuid) {
331
- await cloneDb(api, props.clone, dbData.result.uuid);
347
+ await cloneDb(api, props.clone, dbData.result.uuid, jurisdiction);
332
348
  }
333
349
  } catch (error) {
334
350
  // Check if this is a "database already exists" error and adopt is enabled
@@ -339,7 +355,7 @@ const _D1Database = Resource(
339
355
  ) {
340
356
  logger.log(`Database ${databaseName} already exists, adopting it`);
341
357
  // Find the existing database by name
342
- const databases = await listDatabases(api, databaseName);
358
+ const databases = await listDatabases(api, databaseName, props);
343
359
  const existingDb = databases.find((db) => db.name === databaseName);
344
360
 
345
361
  if (!existingDb) {
@@ -349,7 +365,7 @@ const _D1Database = Resource(
349
365
  }
350
366
 
351
367
  // Get the database details using its ID
352
- dbData = await getDatabase(api, existingDb.id);
368
+ dbData = await getDatabase(api, existingDb.id, props);
353
369
 
354
370
  // Update the database with the provided properties
355
371
  if (props.readReplication) {
@@ -413,6 +429,7 @@ const _D1Database = Resource(
413
429
  dev,
414
430
  migrationsDir: props.migrationsDir,
415
431
  migrationsTable: props.migrationsTable ?? DEFAULT_MIGRATIONS_TABLE,
432
+ jurisdiction,
416
433
  };
417
434
  },
418
435
  );
@@ -454,6 +471,9 @@ export async function createDatabase(
454
471
  const createResponse = await api.post(
455
472
  `/accounts/${api.accountId}/d1/database`,
456
473
  createPayload,
474
+ {
475
+ headers: withJurisdiction(props),
476
+ },
457
477
  );
458
478
 
459
479
  if (!createResponse.ok) {
@@ -474,6 +494,7 @@ export async function createDatabase(
474
494
  export async function getDatabase(
475
495
  api: CloudflareApi,
476
496
  databaseId?: string,
497
+ props: D1DatabaseProps = {},
477
498
  ): Promise<CloudflareD1Response> {
478
499
  if (!databaseId) {
479
500
  throw new Error("Database ID is required");
@@ -481,6 +502,9 @@ export async function getDatabase(
481
502
 
482
503
  const response = await api.get(
483
504
  `/accounts/${api.accountId}/d1/database/${databaseId}`,
505
+ {
506
+ headers: withJurisdiction(props),
507
+ },
484
508
  );
485
509
 
486
510
  if (!response.ok) {
@@ -496,6 +520,7 @@ export async function getDatabase(
496
520
  export async function deleteDatabase(
497
521
  api: CloudflareApi,
498
522
  databaseId?: string,
523
+ props: D1DatabaseProps = {},
499
524
  ): Promise<void> {
500
525
  if (!databaseId) {
501
526
  logger.log("No database ID provided, skipping delete");
@@ -505,6 +530,9 @@ export async function deleteDatabase(
505
530
  // Delete D1 database
506
531
  const deleteResponse = await api.delete(
507
532
  `/accounts/${api.accountId}/d1/database/${databaseId}`,
533
+ {
534
+ headers: withJurisdiction(props),
535
+ },
508
536
  );
509
537
 
510
538
  if (!deleteResponse.ok && deleteResponse.status !== 404) {
@@ -524,12 +552,16 @@ export async function deleteDatabase(
524
552
  export async function listDatabases(
525
553
  api: CloudflareApi,
526
554
  name?: string,
555
+ props: D1DatabaseProps = {},
527
556
  ): Promise<{ name: string; id: string }[]> {
528
557
  // Construct query string if name is provided
529
558
  const queryParams = name ? `?name=${encodeURIComponent(name)}` : "";
530
559
 
531
560
  const response = await api.get(
532
561
  `/accounts/${api.accountId}/d1/database${queryParams}`,
562
+ {
563
+ headers: withJurisdiction(props),
564
+ },
533
565
  );
534
566
 
535
567
  if (!response.ok) {
@@ -582,6 +614,9 @@ export async function updateDatabase(
582
614
  const updateResponse = await api.patch(
583
615
  `/accounts/${api.accountId}/d1/database/${databaseId}`,
584
616
  updatePayload,
617
+ {
618
+ headers: withJurisdiction(props),
619
+ },
585
620
  );
586
621
 
587
622
  if (!updateResponse.ok) {
@@ -608,6 +643,7 @@ async function cloneDb(
608
643
  api: CloudflareApi,
609
644
  sourceDb: D1Database | { id: string } | { name: string },
610
645
  targetDbId: string,
646
+ jurisdiction: D1DatabaseJurisdiction,
611
647
  ): Promise<void> {
612
648
  let sourceId: string;
613
649
 
@@ -617,7 +653,9 @@ async function cloneDb(
617
653
  sourceId = sourceDb.id;
618
654
  } else if ("name" in sourceDb && sourceDb.name) {
619
655
  // Look up ID by name
620
- const databases = await listDatabases(api, sourceDb.name);
656
+ const databases = await listDatabases(api, sourceDb.name, {
657
+ jurisdiction,
658
+ });
621
659
  const foundDb = databases.find((db) => db.name === sourceDb.name);
622
660
 
623
661
  if (!foundDb) {
package/src/docker/api.ts CHANGED
@@ -177,6 +177,13 @@ export class DockerApi {
177
177
  return this.exec(["pull", image]);
178
178
  }
179
179
 
180
+ async tagImage(
181
+ source: string,
182
+ target: string,
183
+ ): Promise<{ stdout: string; stderr: string }> {
184
+ return this.exec(["tag", source, target]);
185
+ }
186
+
180
187
  /**
181
188
  * Build Docker image
182
189
  *
@@ -5,6 +5,7 @@ import type { Context } from "../context.ts";
5
5
  import { Resource } from "../resource.ts";
6
6
  import type { Secret } from "../secret.ts";
7
7
  import { DockerApi } from "./api.ts";
8
+ import type { RemoteImage } from "./remote-image.ts";
8
9
 
9
10
  /**
10
11
  * Options for building a Docker image
@@ -40,9 +41,28 @@ export interface DockerBuildOptions {
40
41
  target?: string;
41
42
 
42
43
  /**
43
- * List of images to use for cache
44
+ * Use an external cache source for a build
45
+ *
46
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#cache-from
47
+ *
44
48
  */
45
49
  cacheFrom?: string[];
50
+
51
+ /**
52
+ * Export build cache to an external cache destination
53
+ *
54
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#cache-to
55
+ *
56
+ */
57
+ cacheTo?: string[];
58
+
59
+ /**
60
+ * Additional options to pass to the Docker build command. This serves as an escape hatch for any additional options that are not supported by the other properties.
61
+ *
62
+ * @see https://docs.docker.com/reference/cli/docker/buildx/build/#options
63
+ *
64
+ */
65
+ options?: string[];
46
66
  }
47
67
 
48
68
  export interface ImageRegistry {
@@ -54,22 +74,12 @@ export interface ImageRegistry {
54
74
  /**
55
75
  * Properties for creating a Docker image
56
76
  */
57
- export interface ImageProps {
58
- /**
59
- * Repository name for the image (e.g., "username/image")
60
- */
61
- name?: string;
62
-
77
+ export type ImageProps = {
63
78
  /**
64
79
  * Tag for the image (e.g., "latest")
65
80
  */
66
81
  tag?: string;
67
82
 
68
- /**
69
- * Build configuration
70
- */
71
- build?: DockerBuildOptions;
72
-
73
83
  /**
74
84
  * Registry credentials
75
85
  */
@@ -79,12 +89,36 @@ export interface ImageProps {
79
89
  * Whether to skip pushing the image to registry
80
90
  */
81
91
  skipPush?: boolean;
82
- }
92
+ } & (
93
+ | {
94
+ /**
95
+ * Image name or reference (e.g., "nginx:alpine")
96
+ */
97
+ image: string | Image | RemoteImage;
98
+ build?: never;
99
+ name?: never;
100
+ }
101
+ | {
102
+ /**
103
+ * Repository name for the image (e.g., "username/image")
104
+ *
105
+ * @default - the id
106
+ */
107
+ name?: string;
108
+ /**
109
+ * Build configuration
110
+ */
111
+ build: DockerBuildOptions;
112
+
113
+ image?: never;
114
+ }
115
+ );
83
116
 
84
117
  /**
85
118
  * Docker Image resource
86
119
  */
87
- export interface Image extends ImageProps {
120
+ export interface Image {
121
+ kind: "Image";
88
122
  /**
89
123
  * Image name
90
124
  */
@@ -109,6 +143,15 @@ export interface Image extends ImageProps {
109
143
  * Time when the image was built
110
144
  */
111
145
  builtAt: number;
146
+ /**
147
+ * Tag for the image
148
+ */
149
+ tag: string;
150
+
151
+ /**
152
+ * Build configuration
153
+ */
154
+ build: DockerBuildOptions | undefined;
112
155
  }
113
156
 
114
157
  /**
@@ -142,12 +185,32 @@ export const Image = Resource(
142
185
  // No action needed for delete as Docker images aren't automatically removed
143
186
  // This is intentional as other resources might depend on the same image
144
187
  return this.destroy();
145
- } else {
146
- // Normalize properties
147
- const tag = props.tag || "latest";
148
- const name = props.name || id;
149
- const imageRef = `${name}:${tag}`;
188
+ }
150
189
 
190
+ const tag = props.tag || "latest";
191
+ const name =
192
+ props.name ||
193
+ (typeof props.image === "string"
194
+ ? props.image
195
+ : props.image?.name
196
+ )?.split(":")[0] ||
197
+ id;
198
+ const imageRef = `${name}:${tag}`;
199
+ let imageId: string | undefined;
200
+ if (props.image) {
201
+ const image =
202
+ typeof props.image === "string" ? props.image : props.image.imageRef;
203
+
204
+ const kind =
205
+ typeof props.image === "object" && props.image.kind === "Image"
206
+ ? "local"
207
+ : "remote";
208
+ if (kind === "remote") {
209
+ await api.pullImage(image);
210
+ }
211
+ await api.tagImage(image, imageRef);
212
+ // TODO: Extract image ID from pull output if available
213
+ } else {
151
214
  let context: string;
152
215
  let dockerfile: string;
153
216
  if (props.build?.dockerfile && props.build?.context) {
@@ -170,7 +233,7 @@ export const Image = Resource(
170
233
  const buildOptions: Record<string, string> = props.build?.args || {};
171
234
 
172
235
  // Add platform if specified
173
- let buildArgs = ["build", "-t", imageRef];
236
+ const buildArgs = ["build", "-t", imageRef];
174
237
 
175
238
  if (props.build?.platform) {
176
239
  buildArgs.push("--platform", props.build.platform);
@@ -188,11 +251,23 @@ export const Image = Resource(
188
251
  }
189
252
  }
190
253
 
254
+ // Add cache destinations if specified
255
+ if (props.build?.cacheTo && props.build.cacheTo.length > 0) {
256
+ for (const cacheTarget of props.build.cacheTo) {
257
+ buildArgs.push("--cache-to", cacheTarget);
258
+ }
259
+ }
260
+
191
261
  // Add build arguments
192
262
  for (const [key, value] of Object.entries(buildOptions)) {
193
263
  buildArgs.push("--build-arg", `${key}=${value}`);
194
264
  }
195
265
 
266
+ // Add build options if specified
267
+ if (props.build?.options && props.build.options.length > 0) {
268
+ buildArgs.push(...props.build.options);
269
+ }
270
+
196
271
  buildArgs.push("-f", dockerfile);
197
272
 
198
273
  // Add context path
@@ -203,77 +278,77 @@ export const Image = Resource(
203
278
 
204
279
  // Extract image ID from build output if available
205
280
  const imageIdMatch = /Successfully built ([a-f0-9]+)/.exec(stdout);
206
- const imageId = imageIdMatch ? imageIdMatch[1] : undefined;
207
-
208
- // Handle push if required
209
- let repoDigest: string | undefined;
210
- let finalImageRef = imageRef;
211
- if (props.registry && !props.skipPush) {
212
- const { server, username, password } = props.registry;
213
-
214
- // Ensure the registry server does not have trailing slash
215
- const registryHost = server.replace(/\/$/, "");
216
-
217
- // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
218
- const firstSegment = imageRef.split("/")[0];
219
- const hasRegistryPrefix = firstSegment.includes(".");
220
-
221
- // Compose the target image reference that will be pushed
222
- const targetImage = hasRegistryPrefix
223
- ? imageRef // already fully-qualified
224
- : `${registryHost}/${imageRef}`;
225
-
226
- try {
227
- // Create a temporary directory that will act as an isolated Docker config
228
- // (credentials) directory. This prevents race-conditions when multiple
229
- // concurrent tests perform `docker login` / `logout` by ensuring each
230
- // Image operation has its own credential store.
231
- const tempConfigDir = await fs.mkdtemp(
232
- path.join(os.tmpdir(), "docker-config-"),
233
- );
234
- const api = new DockerApi({ configDir: tempConfigDir });
235
-
236
- // Authenticate to registry using the isolated config directory
237
- await api.login(registryHost, username, password.unencrypted);
238
-
239
- // Tag local image with fully qualified name if necessary
240
- if (targetImage !== imageRef) {
241
- await api.exec(["tag", imageRef, targetImage]);
242
- }
243
-
244
- // Push the image
245
- const { stdout: pushOut } = await api.exec(["push", targetImage]);
246
-
247
- // Attempt to extract the repo digest from push output
248
- const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(
249
- pushOut,
250
- );
251
- if (digestMatch) {
252
- const digestHash = digestMatch[1];
253
- // Strip tag (anything after last :) to build image@digest reference
254
- const [repoWithoutTag] =
255
- targetImage.split(":").length > 2
256
- ? [targetImage] // unlikely but safety
257
- : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
258
- repoDigest = `${repoWithoutTag}@${digestHash}`;
259
- }
260
-
261
- // Update the final image reference to point at the pushed image
262
- finalImageRef = targetImage;
263
- } finally {
264
- // Clean up credentials from the isolated config
265
- await api.logout(registryHost);
281
+ imageId = imageIdMatch ? imageIdMatch[1] : undefined;
282
+ }
283
+
284
+ // Handle push if required
285
+ let repoDigest: string | undefined;
286
+ let finalImageRef = imageRef;
287
+ if (props.registry && !props.skipPush) {
288
+ const { server, username, password } = props.registry;
289
+
290
+ // Ensure the registry server does not have trailing slash
291
+ const registryHost = server.replace(/\/$/, "");
292
+
293
+ // Determine if the built image already includes a registry host (e.g. ghcr.io/user/repo)
294
+ const firstSegment = imageRef.split("/")[0];
295
+ const hasRegistryPrefix = firstSegment.includes(".");
296
+
297
+ // Compose the target image reference that will be pushed
298
+ const targetImage = hasRegistryPrefix
299
+ ? imageRef // already fully-qualified
300
+ : `${registryHost}/${imageRef}`;
301
+
302
+ try {
303
+ // Create a temporary directory that will act as an isolated Docker config
304
+ // (credentials) directory. This prevents race-conditions when multiple
305
+ // concurrent tests perform `docker login` / `logout` by ensuring each
306
+ // Image operation has its own credential store.
307
+ const tempConfigDir = await fs.mkdtemp(
308
+ path.join(os.tmpdir(), "docker-config-"),
309
+ );
310
+ const api = new DockerApi({ configDir: tempConfigDir });
311
+
312
+ // Authenticate to registry using the isolated config directory
313
+ await api.login(registryHost, username, password.unencrypted);
314
+
315
+ // Tag local image with fully qualified name if necessary
316
+ if (targetImage !== imageRef) {
317
+ await api.exec(["tag", imageRef, targetImage]);
266
318
  }
267
- }
268
319
 
269
- return {
270
- ...props,
271
- name,
272
- imageRef: finalImageRef,
273
- imageId,
274
- repoDigest,
275
- builtAt: Date.now(),
276
- };
320
+ // Push the image
321
+ const { stdout: pushOut } = await api.exec(["push", targetImage]);
322
+
323
+ // Attempt to extract the repo digest from push output
324
+ const digestMatch = /digest:\s+([a-z0-9]+:[a-f0-9]{64})/.exec(pushOut);
325
+ if (digestMatch) {
326
+ const digestHash = digestMatch[1];
327
+ // Strip tag (anything after last :) to build image@digest reference
328
+ const [repoWithoutTag] =
329
+ targetImage.split(":").length > 2
330
+ ? [targetImage] // unlikely but safety
331
+ : [targetImage.substring(0, targetImage.lastIndexOf(":"))];
332
+ repoDigest = `${repoWithoutTag}@${digestHash}`;
333
+ }
334
+
335
+ // Update the final image reference to point at the pushed image
336
+ finalImageRef = targetImage;
337
+ } finally {
338
+ // Clean up credentials from the isolated config
339
+ await api.logout(registryHost);
340
+ }
277
341
  }
342
+ return {
343
+ kind: "Image",
344
+ ...props,
345
+ tag,
346
+ name,
347
+ imageRef: finalImageRef,
348
+ imageId,
349
+ repoDigest,
350
+ builtAt: Date.now(),
351
+ build: props.build,
352
+ };
278
353
  },
279
354
  );
@@ -26,6 +26,7 @@ export interface RemoteImageProps {
26
26
  * Docker Remote Image resource
27
27
  */
28
28
  export interface RemoteImage extends RemoteImageProps {
29
+ kind: "RemoteImage";
29
30
  /**
30
31
  * Full image reference (name:tag)
31
32
  */
@@ -71,8 +72,10 @@ export const RemoteImage = Resource(
71
72
  await api.pullImage(imageRef);
72
73
 
73
74
  return {
75
+ kind: "RemoteImage",
74
76
  ...props,
75
77
  imageRef,
78
+ tag,
76
79
  createdAt: Date.now(),
77
80
  };
78
81
  }
@@ -31,7 +31,7 @@ export async function safeFetch(
31
31
  throw latestErr;
32
32
  }
33
33
 
34
- function isTransientNetworkError(err: any) {
34
+ export function isTransientNetworkError(err: any) {
35
35
  return (
36
36
  err?.code === "UND_ERR_SOCKET" ||
37
37
  err?.code === "ECONNRESET" ||
@@ -12,6 +12,7 @@ import { Scope } from "../scope.ts";
12
12
  import { parseOption } from "./cli-args.ts";
13
13
  import { logger } from "./logger.ts";
14
14
  import { memoize } from "./memoize.ts";
15
+ import { isTransientNetworkError } from "./safe-fetch.ts";
15
16
 
16
17
  const ALCHEMY_DIR = path.join(os.homedir(), ".alchemy");
17
18
  const ID_PATH = path.join(ALCHEMY_DIR, "id");
@@ -299,25 +300,44 @@ export async function createAndSendEvent(
299
300
  if (await isTelemetryDisabled()) {
300
301
  return;
301
302
  }
302
- try {
303
- const eventData = {
304
- ...data,
305
- ...("duration" in data
306
- ? { duration: Math.round(data.duration * 1000) }
307
- : {}),
308
- ...(await collectData()),
309
- ...serializeError(error),
310
- };
311
- await fetchNoResponse(TELEMETRY_API_URL, {
312
- method: "POST",
313
- headers: {
314
- "Content-Type": "application/json",
315
- },
316
- body: JSON.stringify(eventData),
317
- });
318
- } catch (error) {
319
- if (!SUPPRESS_TELEMETRY_ERRORS) {
320
- logger.warn("Failed to send telemetry event:", error);
303
+
304
+ const maxRetries = 3;
305
+
306
+ let telemetryErrors = [];
307
+
308
+ for (let attempt = 0; attempt < maxRetries; attempt++) {
309
+ try {
310
+ const eventData = {
311
+ ...data,
312
+ ...("duration" in data
313
+ ? { duration: Math.round(data.duration * 1000) }
314
+ : {}),
315
+ ...(await collectData()),
316
+ ...serializeError(error),
317
+ telemetryErrors: JSON.stringify(
318
+ telemetryErrors.map((e) => serializeError(e)),
319
+ ),
320
+ };
321
+ await fetchNoResponse(TELEMETRY_API_URL, {
322
+ method: "POST",
323
+ headers: {
324
+ "Content-Type": "application/json",
325
+ },
326
+ body: JSON.stringify(eventData),
327
+ });
328
+ return;
329
+ } catch (error: any) {
330
+ telemetryErrors.push(error);
331
+
332
+ const shouldRetry =
333
+ isTransientNetworkError(error) || isTransientNetworkError(error.cause);
334
+
335
+ if (!shouldRetry || attempt === maxRetries - 1) {
336
+ if (!SUPPRESS_TELEMETRY_ERRORS) {
337
+ logger.warn("Failed to send telemetry event:", error);
338
+ }
339
+ return;
340
+ }
321
341
  }
322
342
  }
323
343
  }