@alchemy.run/fly 2.0.0-beta.84 → 2.0.0-beta.85

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 (148) hide show
  1. package/lib/App.d.ts +4 -4
  2. package/lib/App.d.ts.map +1 -1
  3. package/lib/App.js +4 -6
  4. package/lib/App.js.map +1 -1
  5. package/lib/AuthProvider.d.ts +1 -1
  6. package/lib/AuthProvider.d.ts.map +1 -1
  7. package/lib/BindService.d.ts.map +1 -1
  8. package/lib/BindService.js +1 -1
  9. package/lib/BindService.js.map +1 -1
  10. package/lib/Bucket.d.ts +2 -2
  11. package/lib/Bucket.d.ts.map +1 -1
  12. package/lib/Bucket.js +10 -10
  13. package/lib/Bucket.js.map +1 -1
  14. package/lib/BucketBinding.js +1 -1
  15. package/lib/BucketBinding.js.map +1 -1
  16. package/lib/Certificate.js +4 -4
  17. package/lib/Certificate.js.map +1 -1
  18. package/lib/ConnectPostgres.d.ts +1 -1
  19. package/lib/ConnectPostgres.d.ts.map +1 -1
  20. package/lib/ConnectPostgresHttp.js +1 -1
  21. package/lib/ConnectPostgresHttp.js.map +1 -1
  22. package/lib/Contributions.d.ts +1 -1
  23. package/lib/Credentials.d.ts +1 -1
  24. package/lib/Credentials.d.ts.map +1 -1
  25. package/lib/Credentials.js +10 -14
  26. package/lib/Credentials.js.map +1 -1
  27. package/lib/DeploymentImages.js +1 -1
  28. package/lib/DeploymentImages.js.map +1 -1
  29. package/lib/Errors.d.ts +1 -1
  30. package/lib/Errors.d.ts.map +1 -1
  31. package/lib/IpAssignment.js +4 -4
  32. package/lib/IpAssignment.js.map +1 -1
  33. package/lib/Machine.d.ts +1 -1
  34. package/lib/Machine.d.ts.map +1 -1
  35. package/lib/Machine.js +2 -2
  36. package/lib/Machine.js.map +1 -1
  37. package/lib/Postgres.d.ts +1 -1
  38. package/lib/Postgres.d.ts.map +1 -1
  39. package/lib/Postgres.js +6 -6
  40. package/lib/Postgres.js.map +1 -1
  41. package/lib/PostgresMigrations.d.ts +7 -22
  42. package/lib/PostgresMigrations.d.ts.map +1 -1
  43. package/lib/PostgresMigrations.js +37 -89
  44. package/lib/PostgresMigrations.js.map +1 -1
  45. package/lib/Providers.d.ts +1 -1
  46. package/lib/Providers.d.ts.map +1 -1
  47. package/lib/Redis.d.ts +2 -2
  48. package/lib/Redis.d.ts.map +1 -1
  49. package/lib/Redis.js +9 -6
  50. package/lib/Redis.js.map +1 -1
  51. package/lib/RedisBinding.js +1 -1
  52. package/lib/RedisBinding.js.map +1 -1
  53. package/lib/RedisHttp.d.ts +1 -1
  54. package/lib/RedisHttp.d.ts.map +1 -1
  55. package/lib/RedisHttp.js +4 -1
  56. package/lib/RedisHttp.js.map +1 -1
  57. package/lib/Secret.d.ts +1 -1
  58. package/lib/Secret.d.ts.map +1 -1
  59. package/lib/Secret.js +2 -2
  60. package/lib/Secret.js.map +1 -1
  61. package/lib/SecretKey.d.ts +1 -1
  62. package/lib/SecretKey.d.ts.map +1 -1
  63. package/lib/SecretKey.js +2 -2
  64. package/lib/SecretKey.js.map +1 -1
  65. package/lib/Service.d.ts +8 -13
  66. package/lib/Service.d.ts.map +1 -1
  67. package/lib/Service.js +51 -18
  68. package/lib/Service.js.map +1 -1
  69. package/lib/Sprite.d.ts +2 -2
  70. package/lib/Sprite.d.ts.map +1 -1
  71. package/lib/Sprite.js +7 -5
  72. package/lib/Sprite.js.map +1 -1
  73. package/lib/Volume.d.ts +2 -2
  74. package/lib/Volume.d.ts.map +1 -1
  75. package/lib/Volume.js +5 -5
  76. package/lib/Volume.js.map +1 -1
  77. package/lib/VolumeSnapshot.js +1 -1
  78. package/lib/VolumeSnapshot.js.map +1 -1
  79. package/lib/Website/AssetDeployment.d.ts.map +1 -1
  80. package/lib/Website/AssetDeployment.js +9 -9
  81. package/lib/Website/AssetDeployment.js.map +1 -1
  82. package/lib/Website/Astro.d.ts +8 -6
  83. package/lib/Website/Astro.d.ts.map +1 -1
  84. package/lib/Website/Astro.js +12 -13
  85. package/lib/Website/Astro.js.map +1 -1
  86. package/lib/Website/FrameworkSite.d.ts +39 -2
  87. package/lib/Website/FrameworkSite.d.ts.map +1 -1
  88. package/lib/Website/FrameworkSite.js +46 -3
  89. package/lib/Website/FrameworkSite.js.map +1 -1
  90. package/lib/Website/StaticSite.d.ts +10 -3
  91. package/lib/Website/StaticSite.d.ts.map +1 -1
  92. package/lib/Website/StaticSite.js +16 -4
  93. package/lib/Website/StaticSite.js.map +1 -1
  94. package/lib/Website/Vite.d.ts +9 -0
  95. package/lib/Website/Vite.d.ts.map +1 -1
  96. package/lib/Website/Vite.js +9 -0
  97. package/lib/Website/Vite.js.map +1 -1
  98. package/lib/WriteCertificatesHttp.js +2 -2
  99. package/lib/WriteCertificatesHttp.js.map +1 -1
  100. package/lib/bluegreen.d.ts +4 -4
  101. package/lib/bluegreen.d.ts.map +1 -1
  102. package/lib/bluegreen.js +6 -4
  103. package/lib/bluegreen.js.map +1 -1
  104. package/lib/hosted.d.ts +3 -4
  105. package/lib/hosted.d.ts.map +1 -1
  106. package/lib/hosted.js.map +1 -1
  107. package/lib/internal/RemoteBuilder.d.ts.map +1 -1
  108. package/lib/internal/RemoteBuilder.js +21 -8
  109. package/lib/internal/RemoteBuilder.js.map +1 -1
  110. package/lib/leases.d.ts +2 -2
  111. package/lib/replicas.d.ts +10 -10
  112. package/lib/replicas.d.ts.map +1 -1
  113. package/lib/replicas.js +7 -7
  114. package/lib/replicas.js.map +1 -1
  115. package/package.json +13 -13
  116. package/src/App.ts +5 -7
  117. package/src/BindService.ts +1 -2
  118. package/src/Bucket.ts +15 -13
  119. package/src/BucketBinding.ts +1 -1
  120. package/src/Certificate.ts +4 -4
  121. package/src/ConnectPostgres.ts +1 -1
  122. package/src/ConnectPostgresHttp.ts +1 -1
  123. package/src/Credentials.ts +9 -36
  124. package/src/DeploymentImages.ts +1 -1
  125. package/src/Errors.ts +1 -1
  126. package/src/IpAssignment.ts +4 -4
  127. package/src/Machine.ts +5 -3
  128. package/src/Postgres.ts +7 -7
  129. package/src/PostgresMigrations.ts +39 -99
  130. package/src/Redis.ts +11 -8
  131. package/src/RedisBinding.ts +1 -1
  132. package/src/RedisHttp.ts +4 -1
  133. package/src/Secret.ts +3 -3
  134. package/src/SecretKey.ts +3 -3
  135. package/src/Service.ts +53 -19
  136. package/src/Sprite.ts +11 -9
  137. package/src/Volume.ts +6 -6
  138. package/src/VolumeSnapshot.ts +1 -1
  139. package/src/Website/AssetDeployment.ts +13 -15
  140. package/src/Website/Astro.ts +20 -15
  141. package/src/Website/FrameworkSite.ts +92 -6
  142. package/src/Website/StaticSite.ts +27 -7
  143. package/src/Website/Vite.ts +9 -0
  144. package/src/WriteCertificatesHttp.ts +2 -2
  145. package/src/bluegreen.ts +7 -4
  146. package/src/hosted.ts +3 -4
  147. package/src/internal/RemoteBuilder.ts +37 -19
  148. package/src/replicas.ts +8 -8
package/src/Secret.ts CHANGED
@@ -207,7 +207,7 @@ export const Secret: typeof SecretResource = Object.assign(
207
207
 
208
208
  export class SecretNotCreated extends Data.TaggedError("Fly.SecretNotCreated")<{
209
209
  appName: string;
210
- name: string;
210
+ secretName: string;
211
211
  }> {}
212
212
 
213
213
  export class SecretAppRequired extends Data.TaggedError("Fly.SecretAppRequired")<{
@@ -251,7 +251,7 @@ const getByName = (appName: string, secretName: string) =>
251
251
  secret_name: secretName,
252
252
  show_secrets: false,
253
253
  })
254
- .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
254
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined));
255
255
 
256
256
  const listSecrets = (appName: string) =>
257
257
  machines
@@ -360,7 +360,7 @@ export const SecretProvider = () =>
360
360
  }
361
361
 
362
362
  if (current === undefined) {
363
- return yield* new SecretNotCreated({ appName, name });
363
+ return yield* new SecretNotCreated({ appName, secretName: name });
364
364
  }
365
365
 
366
366
  // Sync — skip if we just created. Prefer observed digest vs hash of
package/src/SecretKey.ts CHANGED
@@ -184,7 +184,7 @@ export const SecretKey: typeof SecretKeyResource = Object.assign(
184
184
 
185
185
  export class SecretKeyNotCreated extends Data.TaggedError("Fly.SecretKeyNotCreated")<{
186
186
  appName: string;
187
- name: string;
187
+ secretName: string;
188
188
  }> {}
189
189
 
190
190
  export class SecretKeyAppRequired extends Data.TaggedError("Fly.SecretKeyAppRequired")<{
@@ -223,7 +223,7 @@ const getByName = (appName: string, secretName: string) =>
223
223
  app_name: appName,
224
224
  secret_name: secretName,
225
225
  })
226
- .pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
226
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined));
227
227
 
228
228
  const listKeys = (appName: string) =>
229
229
  machines.listSecretKeys({ app_name: appName }).pipe(
@@ -346,7 +346,7 @@ export const SecretKeyProvider = () =>
346
346
  }
347
347
 
348
348
  if (current === undefined) {
349
- return yield* new SecretKeyNotCreated({ appName, name });
349
+ return yield* new SecretKeyNotCreated({ appName, secretName: name });
350
350
  }
351
351
 
352
352
  // Sync — re-set when the caller supplied new material. Observed
package/src/Service.ts CHANGED
@@ -52,6 +52,7 @@ import {
52
52
  createFlyAppName,
53
53
  createFlyResourceName,
54
54
  diffMachineMetadata,
55
+ matchesAlchemyPhysicalName,
55
56
  sanitizeFlyAppName,
56
57
  } from "./Metadata.ts";
57
58
  import type { BoundTarget, MountedDisk, ServiceBinding } from "./MountVolume.ts";
@@ -928,28 +929,23 @@ export type ServiceRuntimeContext = FlyHostRuntimeContext;
928
929
  * ) {}
929
930
  * ```
930
931
  *
931
- * **Example:** Install `pg` unbundled
932
- * `pg` is CommonJS. Rolldown's interop turns `Client` into a namespace.
933
- * Install it into the image so `@effect/sql-pg` / `Drizzle.Postgres` load
934
- * it with Node's CJS semantics — same `build.install` as Lambda.
932
+ * **Example:** Install a native package unbundled
933
+ * `build.install` runs `npm install` in the image for packages that
934
+ * cannot be bundled, such as native addons — same `build.install` as
935
+ * Lambda.
935
936
  * ```typescript
936
937
  * export default class Api extends Fly.Service<Api>()(
937
938
  * "Api",
938
939
  * {
939
940
  * main: import.meta.url,
940
941
  * port: 3000,
941
- * build: { install: ["pg"] },
942
+ * build: { install: ["sharp"] },
942
943
  * },
943
944
  * Effect.gen(function* () {
944
- * const conn = yield* Fly.ConnectPostgres(Db);
945
- * const db = yield* Drizzle.Postgres(conn.connectionString);
946
945
  * return {
947
- * fetch: Effect.gen(function* () {
948
- * const rows = yield* db.execute("select 1 as ok");
949
- * return HttpServerResponse.json({ rows });
950
- * }),
946
+ * fetch: Effect.succeed(HttpServerResponse.text("hello")),
951
947
  * };
952
- * }).pipe(Effect.provide(Fly.ConnectPostgresHttp)),
948
+ * }),
953
949
  * ) {}
954
950
  * ```
955
951
  *
@@ -1064,7 +1060,7 @@ export const Service: Platform<Service, ServiceServices, ServiceShape, ServiceRu
1064
1060
  });
1065
1061
 
1066
1062
  export class ServiceNotCreated extends Data.TaggedError("Fly.ServiceNotCreated")<{
1067
- name: string;
1063
+ resourceName: string;
1068
1064
  appName: string;
1069
1065
  }> {}
1070
1066
 
@@ -1235,9 +1231,6 @@ const configDrifted = (
1235
1231
  );
1236
1232
  };
1237
1233
 
1238
- const portsOf = (services: FlyMachineService[] | undefined) =>
1239
- (services ?? []).flatMap((service) => service.ports ?? []);
1240
-
1241
1234
  const withPort = (base: string, port: number, standard: number) =>
1242
1235
  port === standard ? base : `${base}:${port}`;
1243
1236
 
@@ -1517,6 +1510,26 @@ const toAttrs = (set: ReplicaSet, codeHash: string, access: Access): Service["At
1517
1510
  code: { hash: codeHash },
1518
1511
  });
1519
1512
 
1513
+ /** The replica set of a Service whose App exists but runs no Machines yet. */
1514
+ const emptyReplicaSet = (appName: string, name: string): ReplicaSet => ({
1515
+ appName,
1516
+ machineId: "",
1517
+ machineIds: [],
1518
+ name,
1519
+ baseName: name,
1520
+ region: "",
1521
+ regions: [],
1522
+ state: "created",
1523
+ instanceId: undefined,
1524
+ privateIp: undefined,
1525
+ imageRef: undefined,
1526
+ guest: undefined,
1527
+ services: undefined,
1528
+ count: 0,
1529
+ mounts: [],
1530
+ replicas: [],
1531
+ });
1532
+
1520
1533
  const machineIdsOf = (output: Service["Attributes"] | undefined) =>
1521
1534
  output?.machineIds ??
1522
1535
  (output?.machineId !== undefined && output.machineId.length > 0 ? [output.machineId] : []);
@@ -1587,7 +1600,7 @@ export const ServiceProvider = () =>
1587
1600
  stables: ["appName"],
1588
1601
  nuke: { dependsOn: ["Fly.App"] },
1589
1602
 
1590
- diff: Effect.fn(function* ({ id, news, output }) {
1603
+ diff: Effect.fn(function* ({ news, output }) {
1591
1604
  if (news === undefined) return;
1592
1605
  const shape = news as Partial<Pick<ServiceProps, "app" | "public" | "network">>;
1593
1606
  const ownsApp = shape.app === undefined;
@@ -1717,7 +1730,26 @@ export const ServiceProvider = () =>
1717
1730
  machineIds: machineIdsOf(output),
1718
1731
  baseName: name,
1719
1732
  });
1720
- if (found === undefined) return undefined;
1733
+ if (found === undefined) {
1734
+ // A create interrupted between creating the Service's own App and
1735
+ // starting its Machines (an image build in between can take
1736
+ // minutes) leaves the App with nothing in it. Report it, so the
1737
+ // engine's recovery of the interrupted create deletes it instead
1738
+ // of leaking it. The App's name is derived from this resource's
1739
+ // instance, so an App with that name is this resource's.
1740
+ if (!ownsApp || appName === undefined) return undefined;
1741
+ const app = yield* machines
1742
+ .getApp({ app_name: appName })
1743
+ .pipe(Effect.catchTag("NotFound", () => Effect.undefined));
1744
+ if (app === undefined || !matchesAlchemyPhysicalName(app.name)) return undefined;
1745
+ return toAttrs(emptyReplicaSet(appName, name), output?.code.hash ?? "", {
1746
+ ownsApp,
1747
+ isPublic: false,
1748
+ network: output?.network ?? olds?.network,
1749
+ bindingPort: olds?.bindingPort ?? DEFAULT_BINDING_PORT,
1750
+ rpcToken: output?.rpcToken,
1751
+ });
1752
+ }
1721
1753
  return toAttrs(found, output?.code.hash ?? "", {
1722
1754
  ownsApp,
1723
1755
  isPublic: ownsApp && (yield* hasPublicAddress(found.appName)),
@@ -1841,7 +1873,9 @@ export const ServiceProvider = () =>
1841
1873
  buildConfig({ image: imageRef, guest, env, services, mounts, metadata, statics }),
1842
1874
  }).pipe(
1843
1875
  Effect.catchTag("Fly.ReplicaNotCreated", (error) =>
1844
- Effect.fail(new ServiceNotCreated({ name: error.name, appName: error.appName })),
1876
+ Effect.fail(
1877
+ new ServiceNotCreated({ resourceName: error.resourceName, appName: error.appName }),
1878
+ ),
1845
1879
  ),
1846
1880
  );
1847
1881
  return toAttrs(set, codeHash, {
package/src/Sprite.ts CHANGED
@@ -318,18 +318,18 @@ export const Sprite: Platform<Sprite, SpriteServices, SpriteShape, SpriteRuntime
318
318
  );
319
319
 
320
320
  export class SpriteNotCreated extends Data.TaggedError("Fly.SpriteNotCreated")<{
321
- name: string;
321
+ resourceName: string;
322
322
  }> {}
323
323
 
324
324
  export class SpriteExecFailed extends Data.TaggedError("Fly.SpriteExecFailed")<{
325
- name: string;
325
+ resourceName: string;
326
326
  cmd: string;
327
327
  exitCode: number | undefined;
328
328
  stderr: string | undefined;
329
329
  }> {}
330
330
 
331
331
  class SpritePending extends Data.TaggedError("Fly.SpritePending")<{
332
- name: string;
332
+ resourceName: string;
333
333
  }> {}
334
334
 
335
335
  const toStatus = (status: string | undefined): SpriteStatus =>
@@ -368,12 +368,14 @@ const ownershipLabels = (id: string) =>
368
368
  });
369
369
 
370
370
  const getByName = (name: string) =>
371
- sprites.getSprite({ name }).pipe(Effect.catchTag("NotFound", () => Effect.succeed(undefined)));
371
+ sprites.getSprite({ name }).pipe(Effect.catchTag("NotFound", () => Effect.undefined));
372
372
 
373
373
  const waitForSprite = (name: string) =>
374
374
  getByName(name).pipe(
375
375
  Effect.flatMap((found) =>
376
- found !== undefined ? Effect.succeed(found) : Effect.fail(new SpritePending({ name })),
376
+ found !== undefined
377
+ ? Effect.succeed(found)
378
+ : Effect.fail(new SpritePending({ resourceName: name })),
377
379
  ),
378
380
  Effect.retry({
379
381
  while: (e) => e._tag === "Fly.SpritePending",
@@ -458,7 +460,7 @@ const runExec = (input: { name: string; cmd: string[]; env?: string[] }) =>
458
460
  );
459
461
  if ((result.exit_code ?? 0) !== 0) {
460
462
  return yield* new SpriteExecFailed({
461
- name: input.name,
463
+ resourceName: input.name,
462
464
  cmd: input.cmd.join(" "),
463
465
  exitCode: result.exit_code,
464
466
  stderr: result.stderr,
@@ -576,12 +578,12 @@ export const SpriteProvider = () =>
576
578
  read: Effect.fn(function* ({ id, olds, output }) {
577
579
  const name = yield* resolveSpriteName(id, olds?.name, output?.name);
578
580
  const found = yield* (
579
- output?.name !== undefined ? getByName(output.name) : Effect.succeed(undefined)
581
+ output?.name !== undefined ? getByName(output.name) : Effect.undefined
580
582
  ).pipe(
581
583
  Effect.flatMap((existing) =>
582
584
  existing !== undefined ? Effect.succeed(existing) : getByName(name),
583
585
  ),
584
- Effect.catchTag("SpritesNotEnabled", () => Effect.succeed(undefined)),
586
+ Effect.catchTag("SpritesNotEnabled", () => Effect.undefined),
585
587
  );
586
588
  if (found === undefined) return undefined;
587
589
  const attrs = toAttrs(found, name, output?.code.hash ?? "");
@@ -628,7 +630,7 @@ export const SpriteProvider = () =>
628
630
  }
629
631
 
630
632
  if (current === undefined) {
631
- return yield* new SpriteNotCreated({ name });
633
+ return yield* new SpriteNotCreated({ resourceName: name });
632
634
  }
633
635
  // Narrowed once so the `NotFound` fallbacks below keep the
634
636
  // non-optional type instead of widening back to `| undefined`.
package/src/Volume.ts CHANGED
@@ -16,7 +16,7 @@ export const DEFAULT_VOLUME_REGION = "iad";
16
16
  export const MIN_SIZE_GB = 1;
17
17
 
18
18
  export class VolumeNotCreated extends Data.TaggedError("Fly.VolumeNotCreated")<{
19
- name: string;
19
+ resourceName: string;
20
20
  appName: string;
21
21
  }> {}
22
22
 
@@ -39,7 +39,7 @@ const transientState = (state: string | undefined) =>
39
39
  export const getVolumeById = (appName: string, volumeId: string) =>
40
40
  machines.getVolumeById({ app_name: appName, volume_id: volumeId }).pipe(
41
41
  Effect.map((volume) => (destroying(volume.state) ? undefined : volume)),
42
- Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
42
+ Effect.catchTag("NotFound", () => Effect.undefined),
43
43
  );
44
44
 
45
45
  export const listVolumesByApp = (appName: string) =>
@@ -66,7 +66,7 @@ export const listVolumeGroup = (appName: string, name: string, region: string) =
66
66
  export const waitUntilVolumeReady = (appName: string, volumeId: string) =>
67
67
  getVolumeById(appName, volumeId).pipe(
68
68
  Effect.flatMap((volume) => {
69
- if (volume === undefined) return Effect.succeed(undefined);
69
+ if (volume === undefined) return Effect.undefined;
70
70
  if (transientState(volume.state)) {
71
71
  return Effect.fail(
72
72
  new VolumePending({
@@ -130,7 +130,7 @@ export const createVolume = Effect.fn(function* (input: {
130
130
  source_volume_id: input.disk.sourceVolumeId,
131
131
  require_unique_zone: input.disk.requireUniqueZone,
132
132
  })
133
- .pipe(Effect.catchTag("Conflict", () => Effect.succeed(undefined)));
133
+ .pipe(Effect.catchTag("Conflict", () => Effect.undefined));
134
134
  if (created?.id !== undefined && created.id.length > 0) {
135
135
  return (yield* waitUntilVolumeReady(input.appName, created.id)) ?? created;
136
136
  }
@@ -138,7 +138,7 @@ export const createVolume = Effect.fn(function* (input: {
138
138
  const hit = group.at(-1);
139
139
  if (hit === undefined || hit.id === undefined) {
140
140
  return yield* new VolumeNotCreated({
141
- name: input.name,
141
+ resourceName: input.name,
142
142
  appName: input.appName,
143
143
  });
144
144
  }
@@ -211,7 +211,7 @@ export const ensureVolumeGroup = Effect.fn(function* (input: {
211
211
  }
212
212
  if (group.length < input.count) {
213
213
  return yield* new VolumeNotCreated({
214
- name: input.name,
214
+ resourceName: input.name,
215
215
  appName: input.appName,
216
216
  });
217
217
  }
@@ -214,7 +214,7 @@ const waitForNewSnapshot = (appName: string, volumeId: string, knownIds: Readonl
214
214
  times: 8,
215
215
  schedule: backoff,
216
216
  }),
217
- Effect.catchTag("Fly.VolumeSnapshotPending", () => Effect.succeed(undefined)),
217
+ Effect.catchTag("Fly.VolumeSnapshotPending", () => Effect.undefined),
218
218
  );
219
219
 
220
220
  export const VolumeSnapshotProvider = () =>
@@ -95,7 +95,7 @@ const scopeOf = (bucket: Bucket) =>
95
95
  endpoint === undefined
96
96
  ) {
97
97
  return yield* new TigrisCredentialsMissing({
98
- name: bucketName ?? bucket.LogicalId,
98
+ resourceName: bucketName ?? bucket.LogicalId,
99
99
  });
100
100
  }
101
101
  return { bucketName, accessKeyId, secretAccessKey, endpoint, region };
@@ -131,14 +131,14 @@ const walkFiles = Effect.fn(function* (root: string) {
131
131
  const path = yield* Path.Path;
132
132
  if (!(yield* fs.exists(root))) return [] as string[];
133
133
  const names = yield* fs.readDirectory(root, { recursive: true });
134
- const files = yield* Effect.all(
135
- names.map((name) =>
134
+ const files = yield* Effect.forEach(
135
+ names,
136
+ (name) =>
136
137
  Effect.gen(function* () {
137
138
  const full = path.join(root, name);
138
139
  const stat = yield* fs.stat(full);
139
140
  return stat.type === "File" ? name.replaceAll("\\", "/") : undefined;
140
141
  }),
141
- ),
142
142
  { concurrency: "unbounded" },
143
143
  );
144
144
  return files
@@ -213,8 +213,9 @@ export const AssetDeploymentProvider = () =>
213
213
  : path.resolve(initialCwd, news.sourcePath);
214
214
  const files = yield* walkFiles(root);
215
215
  const observed = yield* listObserved(scope, prefix);
216
- const prepared = yield* Effect.all(
217
- files.map((relative) =>
216
+ const prepared = yield* Effect.forEach(
217
+ files,
218
+ (relative) =>
218
219
  Effect.gen(function* () {
219
220
  const body = yield* fs.readFile(path.join(root, relative));
220
221
  const key = prefix.length > 0 ? `${prefix}/${relative}` : relative;
@@ -222,7 +223,6 @@ export const AssetDeploymentProvider = () =>
222
223
  const cacheControl = cacheControlOf(relative);
223
224
  return { relative, key, body, contentType, cacheControl };
224
225
  }),
225
- ),
226
226
  { concurrency: "unbounded" },
227
227
  );
228
228
  const version = yield* Effect.sync(() => {
@@ -258,10 +258,9 @@ export const AssetDeploymentProvider = () =>
258
258
  );
259
259
 
260
260
  if (news.purge ?? true) {
261
- yield* Effect.all(
262
- [...observed.keys()]
263
- .filter((key) => !desired.has(key))
264
- .map((key) => deleteObject(scope, key)),
261
+ yield* Effect.forEach(
262
+ [...observed.keys()].filter((key) => !desired.has(key)),
263
+ (key) => deleteObject(scope, key),
265
264
  { concurrency: s3Concurrency },
266
265
  );
267
266
  }
@@ -283,10 +282,9 @@ export const AssetDeploymentProvider = () =>
283
282
  if (!(olds.purge ?? true)) return;
284
283
  const scope = yield* scopeOf(olds.bucket);
285
284
  const observed = yield* listObserved(scope, output.prefix);
286
- yield* Effect.all(
287
- [...observed.keys()].map((key) => deleteObject(scope, key)),
288
- { concurrency: s3Concurrency },
289
- );
285
+ yield* Effect.forEach([...observed.keys()], (key) => deleteObject(scope, key), {
286
+ concurrency: s3Concurrency,
287
+ });
290
288
  },
291
289
  Effect.catchTag("NoSuchBucket", () => Effect.void),
292
290
  ),
@@ -1,3 +1,5 @@
1
+ import { resolveAstroOutput } from "alchemy/Website/AstroOutput";
2
+ import * as Effect from "effect/Effect";
1
3
  import { frameworkSite, staticConfigFromAssets, type FrameworkSiteProps } from "./FrameworkSite.ts";
2
4
 
3
5
  /** The framework-integration package that drives the Astro build. */
@@ -21,8 +23,9 @@ export interface AstroProps extends FrameworkSiteProps {
21
23
  * Astro output target. `"server"` renders pages on demand; individual
22
24
  * pages opt into prerendering with `export const prerender = true`.
23
25
  * `"static"` prerenders every page at build time and deploys
24
- * assets-only (no SSR server).
25
- * @default "server"
26
+ * assets-only (no SSR server). Overrides the `output` in your
27
+ * `astro.config.*`; leave it unset to use the file's value.
28
+ * @default the config file's `output`, else `"server"`
26
29
  */
27
30
  output?: "server" | "static";
28
31
  /** Source directory, relative to `rootDir`. @default "./src" */
@@ -41,9 +44,9 @@ export interface AstroProps extends FrameworkSiteProps {
41
44
  * running the Node adapter's serve entry (static files first, then the
42
45
  * framework handler) on port 3000.
43
46
  *
44
- * Pages render on demand by default (`output: "server"`). With
45
- * `astro: { output: "static" }` every page is prerendered and the deploy
46
- * is assets-only.
47
+ * Pages render on demand by default (`output: "server"`). When your
48
+ * `astro.config.*` or `astro: { output: "static" }` declares a static
49
+ * site, every page is prerendered and the deploy is assets-only.
47
50
  *
48
51
  *
49
52
  * ### Creating Astro Sites
@@ -75,13 +78,15 @@ export interface AstroProps extends FrameworkSiteProps {
75
78
  * @resource
76
79
  * @product Website
77
80
  */
78
- export const Astro = (id: string, props: AstroProps = {}) => {
79
- const output = props.astro?.output ?? "server";
80
- return frameworkSite(id, props, {
81
- name: "Astro",
82
- framework: ASTRO_FRAMEWORK_SPECIFIER,
83
- target: ASTRO_NODE_TARGET_SPECIFIER,
84
- options: { astro: { ...props.astro, output } },
85
- static: output === "static" ? staticConfigFromAssets(props.assets) : undefined,
86
- });
87
- };
81
+ export const Astro = (id: string, props: AstroProps = {}) =>
82
+ resolveAstroOutput({ rootDir: props.rootDir, output: props.astro?.output }).pipe(
83
+ Effect.flatMap((output) =>
84
+ frameworkSite(id, props, {
85
+ name: "Astro",
86
+ framework: ASTRO_FRAMEWORK_SPECIFIER,
87
+ target: ASTRO_NODE_TARGET_SPECIFIER,
88
+ options: { astro: { ...props.astro, output } },
89
+ static: output === "static" ? staticConfigFromAssets(props.assets) : undefined,
90
+ }),
91
+ ),
92
+ );
@@ -1,5 +1,7 @@
1
1
  import { AlchemyContext } from "alchemy/AlchemyContext";
2
2
  import type { MemoOptions } from "alchemy/Command/Memo";
3
+ import * as DNS from "alchemy/DNS/Adapter";
4
+ import type { Input } from "alchemy/Input";
3
5
  import * as Namespace from "alchemy/Namespace";
4
6
  import * as Output from "alchemy/Output";
5
7
  import { ProviderModePolicy } from "alchemy/ProviderMode";
@@ -18,7 +20,7 @@ import * as Path from "effect/Path";
18
20
  import * as Redacted from "effect/Redacted";
19
21
  import { App } from "../App.ts";
20
22
  import { Bucket } from "../Bucket.ts";
21
- import { Certificate } from "../Certificate.ts";
23
+ import { Certificate, type CertificateDnsRequirements } from "../Certificate.ts";
22
24
  import { IpAssignment } from "../IpAssignment.ts";
23
25
  import type { Providers } from "../Providers.ts";
24
26
  import { Service, type ServiceProps } from "../Service.ts";
@@ -33,6 +35,80 @@ export type Ref<T> = T | Effect.Effect<T, never, Providers>;
33
35
  export type { ServerDevProps, WebsiteAssetsProps, WebsiteNotFoundHandling };
34
36
  export { staticConfigFromAssets };
35
37
 
38
+ /**
39
+ * Custom hostname of a Fly website: the hostname itself, or
40
+ * `{ name, dns }` to also publish its DNS records.
41
+ */
42
+ export type WebsiteDomain =
43
+ | string
44
+ | {
45
+ /** Hostname, e.g. `www.example.com`. */
46
+ name: string;
47
+ /**
48
+ * DNS host that publishes the hostname's records (see
49
+ * [DNS Adapters](/infrastructure-as-code/dns-adapters)), e.g.
50
+ * `Cloudflare.DNS.Adapter()`: the ACME challenge `CNAME` Fly lists in
51
+ * the Certificate's `dnsRequirements`, and `A` / `AAAA` records at
52
+ * the site's addresses. Omitted: publish the records yourself.
53
+ */
54
+ dns?: DNS.DnsConfig;
55
+ };
56
+
57
+ /** The hostname of a {@link WebsiteDomain}. */
58
+ export const websiteDomainName = (domain: WebsiteDomain | undefined): string | undefined =>
59
+ domain === undefined || typeof domain === "string" ? domain : domain.name;
60
+
61
+ const acmeChallengeRecords = (
62
+ hostname: string,
63
+ requirements: CertificateDnsRequirements | undefined,
64
+ ): DNS.DnsRecord[] => {
65
+ const challenge = requirements?.acmeChallenge;
66
+ if (challenge?.target === undefined || challenge.target.length === 0) {
67
+ return [];
68
+ }
69
+ const expected = `_acme-challenge.${hostname}`;
70
+ const name =
71
+ challenge.name !== undefined &&
72
+ DNS.normalizeDnsName(challenge.name).endsWith(DNS.normalizeDnsName(hostname))
73
+ ? challenge.name
74
+ : expected;
75
+ return [{ name, type: "CNAME", value: challenge.target }];
76
+ };
77
+
78
+ /**
79
+ * Publish a Fly website's records through `domain.dns`: the Certificate's
80
+ * ACME challenge `CNAME` (`CertificateValidation`) and `A` / `AAAA`
81
+ * records at the site's shared IPv4 plus any IPv6 Fly lists for the
82
+ * hostname (`Domain`). No-op for a plain hostname or an object without
83
+ * `dns`.
84
+ */
85
+ export const publishWebsiteDomainDns = Effect.fn("Fly.Website.publishDomainDns")(function* (args: {
86
+ readonly domain: WebsiteDomain;
87
+ readonly certificate: Certificate;
88
+ readonly ip: IpAssignment;
89
+ }) {
90
+ if (typeof args.domain === "string" || args.domain.dns === undefined) {
91
+ return;
92
+ }
93
+ const dns = yield* DNS.resolve(args.domain.dns);
94
+ const name = args.domain.name;
95
+ yield* dns.records("CertificateValidation", {
96
+ records: Output.map(args.certificate.dnsRequirements, (requirements) =>
97
+ acmeChallengeRecords(name, requirements),
98
+ ) as unknown as Input<DNS.DnsRecord[]>,
99
+ });
100
+ yield* dns.alias("Domain", {
101
+ name,
102
+ target: {
103
+ ipv4: Output.map(args.ip.ip, (ip) => [ip]) as unknown as Input<string[]>,
104
+ ipv6: Output.map(
105
+ args.certificate.dnsRequirements,
106
+ (requirements) => requirements?.aaaa ?? [],
107
+ ) as unknown as Input<string[]>,
108
+ },
109
+ });
110
+ });
111
+
36
112
  /**
37
113
  * Props shared by every Fly framework website composite.
38
114
  */
@@ -78,9 +154,15 @@ export interface FrameworkSiteProps {
78
154
  dev?: ServerDevProps;
79
155
  /**
80
156
  * Optional custom hostname. Requests ACME (`Fly.Certificate`) on the
81
- * App. `url` becomes `https://<domain>` (existing DNS only for v1).
157
+ * App and `url` becomes `https://<domain>`.
158
+ *
159
+ * A string (or `{ name }`) leaves DNS to you. Set `dns` (e.g.
160
+ * `{ name: "www.example.com", dns: Cloudflare.DNS.Adapter() }`) to
161
+ * publish the ACME challenge `CNAME` and the `A` / `AAAA` records
162
+ * through that DNS host. See
163
+ * [DNS Adapters](/infrastructure-as-code/dns-adapters).
82
164
  */
83
- domain?: string;
165
+ domain?: WebsiteDomain;
84
166
  /**
85
167
  * User-defined tags. Accepted for API parity; Fly Services do not
86
168
  * surface resource tags.
@@ -327,16 +409,20 @@ const runFrameworkSite = Effect.fn("Fly.Website.FrameworkSite")(function* (
327
409
  : undefined,
328
410
  });
329
411
 
412
+ const domainName = websiteDomainName(props.domain);
330
413
  const certificate =
331
- props.domain !== undefined
414
+ domainName !== undefined
332
415
  ? yield* Certificate("Certificate", {
333
416
  app,
334
- hostname: props.domain,
417
+ hostname: domainName,
335
418
  kind: "acme",
336
419
  })
337
420
  : undefined;
421
+ if (props.domain !== undefined && certificate !== undefined) {
422
+ yield* publishWebsiteDomainDns({ domain: props.domain, certificate, ip });
423
+ }
338
424
 
339
- const url = props.domain !== undefined ? `https://${props.domain}` : app.url;
425
+ const url = domainName !== undefined ? `https://${domainName}` : app.url;
340
426
 
341
427
  return { url, app, service, ip, certificate };
342
428
  });
@@ -15,9 +15,12 @@ import { IpAssignment } from "../IpAssignment.ts";
15
15
  import { Service, type ServiceProps } from "../Service.ts";
16
16
  import {
17
17
  type FrameworkSite,
18
+ publishWebsiteDomainDns,
18
19
  type Ref,
19
20
  type WebsiteAssetsProps,
21
+ type WebsiteDomain,
20
22
  staticConfigFromAssets,
23
+ websiteDomainName,
21
24
  } from "./FrameworkSite.ts";
22
25
 
23
26
  const DEFAULT_PORT = 3000;
@@ -69,9 +72,16 @@ export interface StaticSiteProps {
69
72
  */
70
73
  app?: Ref<App>;
71
74
  /**
72
- * Optional custom hostname. Requests ACME (`Fly.Certificate`) on the App.
75
+ * Optional custom hostname. Requests ACME (`Fly.Certificate`) on the App
76
+ * and `url` becomes `https://<domain>`.
77
+ *
78
+ * A string (or `{ name }`) leaves DNS to you. Set `dns` (e.g.
79
+ * `{ name: "www.example.com", dns: Cloudflare.DNS.Adapter() }`) to
80
+ * publish the ACME challenge `CNAME` and the `A` / `AAAA` records
81
+ * through that DNS host. See
82
+ * [DNS Adapters](/infrastructure-as-code/dns-adapters).
73
83
  */
74
- domain?: string;
84
+ domain?: WebsiteDomain;
75
85
  /**
76
86
  * User-defined tags. Accepted for API parity; Fly Services do not
77
87
  * surface resource tags.
@@ -262,14 +272,24 @@ export const StaticSite = (id: string, props: StaticSiteProps) =>
262
272
  ],
263
273
  });
264
274
 
275
+ const domainName = websiteDomainName(props.domain);
265
276
  const certificate =
266
- props.domain !== undefined
267
- ? yield* Certificate("Certificate", { app, hostname: props.domain, kind: "acme" }).pipe(
268
- Namespace.push(id),
269
- )
277
+ domainName !== undefined
278
+ ? yield* Certificate("Certificate", {
279
+ app,
280
+ hostname: domainName,
281
+ kind: "acme",
282
+ }).pipe(Namespace.push(id))
270
283
  : undefined;
284
+ if (props.domain !== undefined && certificate !== undefined) {
285
+ yield* publishWebsiteDomainDns({
286
+ domain: props.domain,
287
+ certificate,
288
+ ip,
289
+ }).pipe(Namespace.push(id));
290
+ }
271
291
 
272
- const url = props.domain !== undefined ? `https://${props.domain}` : app.url;
292
+ const url = domainName !== undefined ? `https://${domainName}` : app.url;
273
293
 
274
294
  return { url, app, service, ip, certificate };
275
295
  }).pipe(Effect.orDie);
@@ -77,6 +77,15 @@ export interface ViteProps extends FrameworkSiteProps {
77
77
  * });
78
78
  * ```
79
79
  *
80
+ * **Example:** Publish DNS in Cloudflare
81
+ * ```typescript
82
+ * // The ACME challenge CNAME and A/AAAA records are published in the
83
+ * // Cloudflare zone. Requires Cloudflare.providers() in the stack.
84
+ * const site = yield* Fly.Website.Vite("Web", {
85
+ * domain: { name: "app.example.com", dns: Cloudflare.DNS.Adapter() },
86
+ * });
87
+ * ```
88
+ *
80
89
  * ### Build Configuration
81
90
  * **Example:** Custom Output Directory and Base Path
82
91
  * ```typescript