alchemy 2.0.0-beta.24 → 2.0.0-beta.25

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.
@@ -22,7 +22,6 @@ import { pathToFileURL } from "node:url";
22
22
  import type * as rolldown from "rolldown";
23
23
  import Sonda from "sonda/rolldown";
24
24
  import type * as vite from "vite";
25
- import { AdoptPolicy } from "../../AdoptPolicy.ts";
26
25
  import { AlchemyContext } from "../../AlchemyContext.ts";
27
26
  import * as Artifacts from "../../Artifacts.ts";
28
27
  import * as Binding from "../../Binding.ts";
@@ -1105,11 +1104,44 @@ export const LiveWorkerProvider = () =>
1105
1104
  const reconcileDomains = (
1106
1105
  scriptName: string,
1107
1106
  desired: string[],
1108
- previous: Worker["Attributes"]["domains"],
1107
+ _previous: Worker["Attributes"]["domains"],
1109
1108
  ) =>
1110
1109
  Effect.gen(function* () {
1110
+ // Always query the live state of domains attached to *this*
1111
+ // Worker rather than trusting `_previous` from local state.
1112
+ // State may have been wiped, populated by another machine, or
1113
+ // simply be out of date. Without this we PUT domains that are
1114
+ // already registered to this same Worker and Cloudflare
1115
+ // returns a confusing "hostname already in use" error.
1116
+ const liveAll = yield* listDomains({
1117
+ accountId,
1118
+ service: scriptName,
1119
+ }).pipe(
1120
+ Effect.map((r) =>
1121
+ (r.result ?? []).flatMap((d) =>
1122
+ d.id && d.hostname && d.zoneId
1123
+ ? [
1124
+ {
1125
+ id: d.id,
1126
+ hostname: d.hostname,
1127
+ zoneId: d.zoneId,
1128
+ service: d.service ?? undefined,
1129
+ },
1130
+ ]
1131
+ : [],
1132
+ ),
1133
+ ),
1134
+ Effect.catch(() => Effect.succeed([])),
1135
+ );
1136
+
1111
1137
  const desiredSet = new Set(desired);
1112
- const toRemove = previous.filter((p) => !desiredSet.has(p.hostname));
1138
+ const liveByHostname = new Map(liveAll.map((d) => [d.hostname, d]));
1139
+
1140
+ // Detach what's no longer wanted. Use the live list so we
1141
+ // don't try to delete domains we no longer track.
1142
+ const toRemove = liveAll.filter(
1143
+ (d) => !desiredSet.has(d.hostname),
1144
+ );
1113
1145
  yield* Effect.all(
1114
1146
  toRemove.map((d) =>
1115
1147
  deleteDomain({ accountId, domainId: d.id }).pipe(
@@ -1121,79 +1153,59 @@ export const LiveWorkerProvider = () =>
1121
1153
 
1122
1154
  if (desired.length === 0) return [];
1123
1155
 
1124
- const adoptEnabled = yield* Effect.serviceOption(AdoptPolicy).pipe(
1125
- Effect.map(Option.getOrElse(() => false)),
1126
- );
1127
-
1128
1156
  const zoneCache = new Map<string, string>();
1129
1157
 
1130
- /**
1131
- * Attach `hostname` to `scriptName`, recovering from a "hostname
1132
- * already in use" conflict when adoption is enabled.
1133
- *
1134
- * The Cloudflare API rejects a `PUT /workers/domains` whose
1135
- * hostname is already registered (even when the existing entry
1136
- * points at the same Worker, which is exactly what happens
1137
- * after a state-store wipe). The SDK surfaces this as the
1138
- * unmatched `UnknownCloudflareError` channel because there's
1139
- * no dedicated error tag for it. With adoption opted-in we
1140
- * list domains for the hostname; if the existing entry
1141
- * already targets *this* Worker we adopt it in place. We
1142
- * never transfer a hostname off another Worker — that would
1143
- * silently re-route someone else's traffic.
1144
- */
1158
+ // Attach `hostname` to this Worker. Skip the PUT entirely if
1159
+ // the hostname is already attached to *this* Worker — that's a
1160
+ // no-op for Cloudflare and avoids the "already in use" 409.
1161
+ // If it's attached to a *different* Worker, refuse with a
1162
+ // clear message rather than silently re-routing traffic.
1145
1163
  const attachDomain = Effect.fnUntraced(function* (hostname: string) {
1146
- const zoneId = yield* inferZoneIdForHostname(hostname, zoneCache);
1147
- return yield* putDomain({
1148
- accountId,
1149
- hostname,
1150
- service: scriptName,
1151
- zoneId,
1152
- }).pipe(
1153
- Effect.map((res) => ({
1154
- hostname,
1155
- id: res.id ?? "",
1156
- zoneId: res.zoneId ?? zoneId,
1157
- })),
1158
- Effect.catchTag("UnknownCloudflareError", (err) =>
1159
- adoptEnabled && /already in use/i.test(err.message)
1160
- ? adoptDomainIfSameService(hostname, zoneId, err)
1161
- : Effect.fail(err),
1162
- ),
1163
- );
1164
- });
1164
+ const live = liveByHostname.get(hostname);
1165
+ if (live) {
1166
+ return {
1167
+ hostname: live.hostname,
1168
+ id: live.id,
1169
+ zoneId: live.zoneId,
1170
+ };
1171
+ }
1165
1172
 
1166
- const adoptDomainIfSameService = Effect.fnUntraced(function* (
1167
- hostname: string,
1168
- zoneId: string,
1169
- originalError: { message: string },
1170
- ) {
1171
- const existing = yield* listDomains({
1173
+ // Not attached to this Worker — but it could still belong
1174
+ // to another Worker. Check before we try to PUT so we can
1175
+ // emit a helpful error instead of the raw 409.
1176
+ const otherOwner = yield* listDomains({
1172
1177
  accountId,
1173
1178
  hostname,
1174
1179
  }).pipe(
1175
1180
  Effect.map((r) =>
1176
- (r.result ?? []).find((d) => d.hostname === hostname),
1181
+ (r.result ?? []).find(
1182
+ (d) => d.hostname === hostname && d.service !== scriptName,
1183
+ ),
1177
1184
  ),
1185
+ Effect.catch(() => Effect.succeed(undefined)),
1178
1186
  );
1179
- if (existing?.id && existing.service === scriptName) {
1180
- return {
1181
- hostname,
1182
- id: existing.id,
1183
- zoneId: existing.zoneId ?? zoneId,
1184
- };
1187
+ if (otherOwner?.id) {
1188
+ return yield* Effect.die(
1189
+ new Error(
1190
+ `Cannot attach hostname '${hostname}' to Worker '${scriptName}': ` +
1191
+ `it is already attached to Worker '${otherOwner.service ?? "<unknown>"}'. ` +
1192
+ `Detach it from that Worker first, or pick a different hostname.`,
1193
+ ),
1194
+ );
1185
1195
  }
1186
- // The hostname exists but on a different Worker. Refuse to
1187
- // hijack it; surface a more actionable message than the raw
1188
- // Cloudflare error.
1189
- return yield* Effect.die(
1190
- new Error(
1191
- `Cannot attach hostname '${hostname}' to Worker '${scriptName}': ` +
1192
- `it is already attached to Worker '${existing?.service ?? "<unknown>"}'. ` +
1193
- `Detach it from that Worker first, or pick a different hostname. ` +
1194
- `(Cloudflare reported: ${originalError.message})`,
1195
- ),
1196
- );
1196
+
1197
+ const zoneId = yield* inferZoneIdForHostname(hostname, zoneCache);
1198
+ const res = yield* putDomain({
1199
+ accountId,
1200
+ hostname,
1201
+ service: scriptName,
1202
+ zoneId,
1203
+ });
1204
+ return {
1205
+ hostname,
1206
+ id: res.id ?? "",
1207
+ zoneId: res.zoneId ?? zoneId,
1208
+ };
1197
1209
  });
1198
1210
 
1199
1211
  const applied = yield* Effect.all(
@@ -1288,21 +1300,15 @@ export const LiveWorkerProvider = () =>
1288
1300
  return undefined;
1289
1301
  }
1290
1302
 
1291
- // Handle AssetsWithHash (from Build resource)
1292
- // Props are resolved by Plan, so Input<string> values are already strings at runtime
1293
1303
  if (
1294
1304
  typeof assets === "object" &&
1295
1305
  "path" in assets &&
1296
1306
  "hash" in assets
1297
1307
  ) {
1298
- const result = yield* readAssets({
1308
+ return yield* readAssets({
1299
1309
  directory: assets.path as string,
1300
1310
  config: assets.config,
1301
1311
  });
1302
- return {
1303
- ...result,
1304
- hash: assets.hash as string,
1305
- };
1306
1312
  }
1307
1313
 
1308
1314
  // Handle string path or AssetsProps
@@ -1630,31 +1636,30 @@ ${[
1630
1636
  let metadataAssets:
1631
1637
  | workers.PutScriptRequest["metadata"]["assets"]
1632
1638
  | undefined;
1633
- let keepAssets = false;
1639
+ const keepAssets = false;
1634
1640
  if (assets) {
1635
- if (output?.hash?.assets !== assets.hash) {
1636
- yield* Effect.logInfo(
1637
- `Cloudflare Worker ${olds ? "update" : "create"}: uploading assets for ${name}`,
1638
- );
1639
- const { jwt } = yield* uploadAssets(
1640
- accountId,
1641
- name,
1642
- assets,
1643
- session,
1644
- );
1645
- metadataAssets = {
1646
- jwt,
1647
- config: assets.config,
1648
- };
1649
- } else {
1650
- yield* Effect.logInfo(
1651
- `Cloudflare Worker update: reusing existing assets for ${name}`,
1652
- );
1653
- metadataAssets = {
1654
- config: assets.config,
1655
- };
1656
- keepAssets = true;
1657
- }
1641
+ // Always upload assets on every deploy. The "skip if hash
1642
+ // unchanged" optimization was the source of subtle bundle/
1643
+ // asset desync bugs (deploy succeeds but worker serves 404s
1644
+ // because the bundle references hashed asset filenames that
1645
+ // aren't in the still-on-Cloudflare manifest). The upload
1646
+ // session is content-addressed on Cloudflare's side — only
1647
+ // genuinely-new asset bytes are PUT; unchanged assets cost
1648
+ // a manifest check and nothing else, so the optimization
1649
+ // wasn't worth the failure mode.
1650
+ yield* Effect.logInfo(
1651
+ `Cloudflare Worker ${olds ? "update" : "create"}: uploading assets for ${name}`,
1652
+ );
1653
+ const { jwt } = yield* uploadAssets(
1654
+ accountId,
1655
+ name,
1656
+ assets,
1657
+ session,
1658
+ );
1659
+ metadataAssets = {
1660
+ jwt,
1661
+ config: assets.config,
1662
+ };
1658
1663
  metadataBindings.push({
1659
1664
  type: "assets",
1660
1665
  name: "ASSETS",
@@ -1951,11 +1956,19 @@ ${[
1951
1956
  const input = yield* hashDirectory(props.vite);
1952
1957
  return input !== output.hash?.input;
1953
1958
  }
1959
+ // Always recompute both hashes by walking `dist/` (assets) and
1960
+ // re-bundling the worker. The previous short-circuit
1961
+ // (`Effect.succeed(output.hash.assets)`) compared the cached
1962
+ // hash to itself and could never detect drift, and the
1963
+ // `props.assets.hash` form is the *input* (source) hash which
1964
+ // misses non-deterministic build output (e.g. Astro/Vite
1965
+ // shuffling content-hashed filenames between builds). The
1966
+ // result of either shortcut was deploying a new bundle on top
1967
+ // of stale assets and 404'ing in production. Re-walking is the
1968
+ // only correct comparison.
1954
1969
  const [assetsHash, bundleHash] = yield* Effect.all(
1955
1970
  [
1956
- "assets" in output && output.hash?.assets
1957
- ? Effect.succeed(output.hash.assets)
1958
- : prepareAssets(props.assets).pipe(Effect.map((a) => a?.hash)),
1971
+ prepareAssets(props.assets).pipe(Effect.map((a) => a?.hash)),
1959
1972
  prepareBundle(id, props).pipe(Effect.map((b) => b.hash)),
1960
1973
  ],
1961
1974
  { concurrency: "unbounded" },