@void/deploy-cloudflare 0.20.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.
- package/LICENSE +21 -0
- package/README.md +28 -0
- package/dist/index.d.mts +322 -0
- package/dist/index.mjs +1926 -0
- package/package.json +38 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1926 @@
|
|
|
1
|
+
import { isDeepStrictEqual, stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { cpSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { SyntaxKind, applyEdits, createScanner, modify, parse, parseTree } from "jsonc-parser";
|
|
4
|
+
import nodePath from "node:path";
|
|
5
|
+
import { createHash } from "node:crypto";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
//#region src/config.ts
|
|
8
|
+
/** Parse a root Wrangler JSON/JSONC config without accepting a partial parse. */
|
|
9
|
+
function parseRootWranglerConfig(configPath) {
|
|
10
|
+
if (!existsSync(configPath)) return {};
|
|
11
|
+
let raw;
|
|
12
|
+
try {
|
|
13
|
+
raw = readFileSync(configPath, "utf-8");
|
|
14
|
+
} catch {
|
|
15
|
+
return {};
|
|
16
|
+
}
|
|
17
|
+
const content = raw.startsWith("") ? raw.slice(1) : raw;
|
|
18
|
+
const errors = [];
|
|
19
|
+
const parsed = parse(content, errors, { allowTrailingComma: true });
|
|
20
|
+
if (errors.length > 0 || typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return {};
|
|
21
|
+
return parsed;
|
|
22
|
+
}
|
|
23
|
+
function readWranglerTopLevelString(config, field) {
|
|
24
|
+
const value = config[field];
|
|
25
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
26
|
+
}
|
|
27
|
+
function readStoredResourceName(config, field, binding, nameField) {
|
|
28
|
+
const list = config[field];
|
|
29
|
+
if (!Array.isArray(list)) return;
|
|
30
|
+
for (const entry of list) if (entry && typeof entry === "object" && entry.binding === binding) {
|
|
31
|
+
const name = entry[nameField];
|
|
32
|
+
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
function readBindingFieldPresence(config, field, binding, key) {
|
|
36
|
+
const list = config[field];
|
|
37
|
+
if (!Array.isArray(list)) return {
|
|
38
|
+
present: false,
|
|
39
|
+
value: void 0
|
|
40
|
+
};
|
|
41
|
+
for (const entry of list) if (entry && typeof entry === "object" && entry.binding === binding) return Object.hasOwn(entry, key) ? {
|
|
42
|
+
present: true,
|
|
43
|
+
value: entry[key]
|
|
44
|
+
} : {
|
|
45
|
+
present: false,
|
|
46
|
+
value: void 0
|
|
47
|
+
};
|
|
48
|
+
return {
|
|
49
|
+
present: false,
|
|
50
|
+
value: void 0
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function readConfigEntryString(entry, field) {
|
|
54
|
+
if (!entry || typeof entry !== "object") return;
|
|
55
|
+
const value = entry[field];
|
|
56
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
57
|
+
}
|
|
58
|
+
function resolveD1MigrateTarget(config, binding) {
|
|
59
|
+
const list = config.d1_databases;
|
|
60
|
+
if (!Array.isArray(list)) return {
|
|
61
|
+
ok: false,
|
|
62
|
+
reason: `the root config has no "d1_databases" array`
|
|
63
|
+
};
|
|
64
|
+
const validatedIndex = list.findIndex((entry) => readConfigEntryString(entry, "binding") === binding);
|
|
65
|
+
if (validatedIndex === -1) return {
|
|
66
|
+
ok: false,
|
|
67
|
+
reason: `no d1_databases entry has binding ${JSON.stringify(binding)}`
|
|
68
|
+
};
|
|
69
|
+
const validated = list[validatedIndex];
|
|
70
|
+
const token = readConfigEntryString(validated, "database_name") ?? binding;
|
|
71
|
+
for (let i = 0; i < validatedIndex; i++) {
|
|
72
|
+
const earlier = list[i];
|
|
73
|
+
if (readConfigEntryString(earlier, "database_name") === token || readConfigEntryString(earlier, "binding") === token) return {
|
|
74
|
+
ok: false,
|
|
75
|
+
reason: `an earlier d1_databases entry (binding ${JSON.stringify(readConfigEntryString(earlier, "binding"))}, database_name ${JSON.stringify(readConfigEntryString(earlier, "database_name"))}) also matches the token ${JSON.stringify(token)}, so wrangler would migrate it instead`
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
return {
|
|
79
|
+
ok: true,
|
|
80
|
+
token
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
//#endregion
|
|
84
|
+
//#region src/existing.ts
|
|
85
|
+
/** Read the deployed version, not the most recently uploaded candidate. */
|
|
86
|
+
async function readExistingCloudflareWorker(options) {
|
|
87
|
+
const worker = await readCloudflareWorker(options, false);
|
|
88
|
+
if (!worker) throw new Error(`Cloudflare Worker "${options.workerName}" does not have an active version.`);
|
|
89
|
+
return worker;
|
|
90
|
+
}
|
|
91
|
+
async function readCloudflareWorker(options, allowMissing) {
|
|
92
|
+
const target = [
|
|
93
|
+
"-c",
|
|
94
|
+
options.configPath,
|
|
95
|
+
"--env=",
|
|
96
|
+
"--name",
|
|
97
|
+
options.workerName
|
|
98
|
+
];
|
|
99
|
+
const read = async (args, missingIsNull = false) => {
|
|
100
|
+
const result = await options.runWrangler([
|
|
101
|
+
...args,
|
|
102
|
+
...target,
|
|
103
|
+
"--json"
|
|
104
|
+
], { capture: true });
|
|
105
|
+
if (result.status !== 0 || result.signal || result.error) {
|
|
106
|
+
if (missingIsNull && isMissingWorkerOutput$1(`${result.stdout}\n${result.stderr}`)) return null;
|
|
107
|
+
throw new Error(`Could not verify existing Cloudflare Worker "${options.workerName}". Check the account, Worker name, and Cloudflare permissions before retrying.`);
|
|
108
|
+
}
|
|
109
|
+
try {
|
|
110
|
+
const value = JSON.parse(result.stdout);
|
|
111
|
+
if (record(value)) return value;
|
|
112
|
+
} catch {}
|
|
113
|
+
throw new Error("Cloudflare returned an invalid existing Worker response.");
|
|
114
|
+
};
|
|
115
|
+
const deployment = options.versionId ? { versions: [{
|
|
116
|
+
version_id: options.versionId,
|
|
117
|
+
percentage: 100
|
|
118
|
+
}] } : await read(["deployments", "status"], allowMissing);
|
|
119
|
+
if (deployment === null) return null;
|
|
120
|
+
const versions = deployment.versions;
|
|
121
|
+
if (allowMissing && Array.isArray(versions) && versions.length === 0) return null;
|
|
122
|
+
if (!Array.isArray(versions) || versions.length !== 1 || !record(versions[0]) || versions[0].percentage !== 100 || typeof versions[0].version_id !== "string") throw new Error("The existing Worker must have one deployed version receiving all traffic. Finish any gradual rollout before linking.");
|
|
123
|
+
const versionId = versions[0].version_id;
|
|
124
|
+
const version = await read([
|
|
125
|
+
"versions",
|
|
126
|
+
"view",
|
|
127
|
+
versionId
|
|
128
|
+
]);
|
|
129
|
+
if (version === null) throw new Error("Cloudflare did not return the existing Worker configuration.");
|
|
130
|
+
const resources = version.resources;
|
|
131
|
+
if (version.id !== versionId || !record(resources) || !Array.isArray(resources.bindings) || !resources.bindings.every(record) || !record(resources.script_runtime) || !record(resources.script)) throw new Error("Cloudflare did not return the existing Worker configuration.");
|
|
132
|
+
const after = options.versionId ? deployment : await read(["deployments", "status"]);
|
|
133
|
+
if (after === null || !isDeepStrictEqual(deployment.versions, after.versions)) throw new Error("Cloudflare production changed while linking. Retry from the current deployment.");
|
|
134
|
+
return {
|
|
135
|
+
versionId,
|
|
136
|
+
bindings: resources.bindings,
|
|
137
|
+
runtime: resources.script_runtime,
|
|
138
|
+
script: resources.script
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
/** Parse legacy version-detail shapes; this is not authoritative Worker migration state. */
|
|
142
|
+
function readCloudflareVersionMigrationTag(version) {
|
|
143
|
+
if (!record(version)) return null;
|
|
144
|
+
const resources = record(version.resources) ? version.resources : void 0;
|
|
145
|
+
const tag = [
|
|
146
|
+
resources && record(resources.script_runtime) ? resources.script_runtime.migration_tag : null,
|
|
147
|
+
resources && record(resources.script) ? resources.script.migration_tag : null,
|
|
148
|
+
record(version.script) ? version.script.migration_tag : null,
|
|
149
|
+
version.migration_tag
|
|
150
|
+
].find((candidate) => typeof candidate === "string");
|
|
151
|
+
return typeof tag === "string" && tag.length > 0 ? tag : null;
|
|
152
|
+
}
|
|
153
|
+
/** Read the authoritative migration tag from Worker script metadata. */
|
|
154
|
+
async function readCloudflareWorkerMigrationTag(options) {
|
|
155
|
+
const auth = await readCloudflareApiAuthentication(options.runWrangler);
|
|
156
|
+
const endpoint = `${options.apiBaseUrl.replace(/\/+$/, "")}/accounts/${encodeURIComponent(options.accountId)}/workers/scripts`;
|
|
157
|
+
let response;
|
|
158
|
+
try {
|
|
159
|
+
response = await fetch(endpoint, {
|
|
160
|
+
headers: auth,
|
|
161
|
+
redirect: "error",
|
|
162
|
+
signal: AbortSignal.timeout(3e4)
|
|
163
|
+
});
|
|
164
|
+
} catch {
|
|
165
|
+
throw new Error(`Could not verify Durable Object migration state for Cloudflare Worker "${options.workerName}".`);
|
|
166
|
+
}
|
|
167
|
+
let body;
|
|
168
|
+
try {
|
|
169
|
+
body = await response.json();
|
|
170
|
+
} catch {
|
|
171
|
+
throw new Error(`Cloudflare returned an invalid Durable Object migration response for Worker "${options.workerName}".`);
|
|
172
|
+
}
|
|
173
|
+
if (!response.ok || !record(body) || body.success !== true || !Array.isArray(body.result)) throw new Error(`Could not verify Durable Object migration state for Cloudflare Worker "${options.workerName}".`);
|
|
174
|
+
const worker = body.result.find((candidate) => record(candidate) && candidate.id === options.workerName);
|
|
175
|
+
if (!record(worker)) throw new Error(`Cloudflare did not return migration state for Worker "${options.workerName}".`);
|
|
176
|
+
if (worker.migration_tag === void 0 || worker.migration_tag === null) return null;
|
|
177
|
+
if (typeof worker.migration_tag !== "string" || worker.migration_tag.length === 0) throw new Error(`Cloudflare returned an invalid Durable Object migration response for Worker "${options.workerName}".`);
|
|
178
|
+
return worker.migration_tag;
|
|
179
|
+
}
|
|
180
|
+
async function readCloudflareApiAuthentication(runWrangler) {
|
|
181
|
+
const result = await runWrangler([
|
|
182
|
+
"auth",
|
|
183
|
+
"token",
|
|
184
|
+
"--json"
|
|
185
|
+
], { capture: true });
|
|
186
|
+
if (result.status !== 0 || result.signal || result.error) throw new Error("Could not read the authenticated Cloudflare session.");
|
|
187
|
+
let value;
|
|
188
|
+
try {
|
|
189
|
+
value = JSON.parse(result.stdout);
|
|
190
|
+
} catch {
|
|
191
|
+
throw new Error("Cloudflare returned an invalid authentication response.");
|
|
192
|
+
}
|
|
193
|
+
if (!record(value)) throw new Error("Cloudflare returned an invalid authentication response.");
|
|
194
|
+
if ((value.type === "oauth" || value.type === "api_token") && typeof value.token === "string" && value.token.length > 0) return { Authorization: `Bearer ${value.token}` };
|
|
195
|
+
if (value.type === "api_key" && typeof value.key === "string" && value.key.length > 0 && typeof value.email === "string" && value.email.length > 0) return {
|
|
196
|
+
"X-Auth-Key": value.key,
|
|
197
|
+
"X-Auth-Email": value.email
|
|
198
|
+
};
|
|
199
|
+
throw new Error("Cloudflare returned an invalid authentication response.");
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* The Versions upload endpoint cannot safely apply a new Durable Object class migration to an
|
|
203
|
+
* existing Worker in the native beta. Reject that pending lifecycle change before the caller
|
|
204
|
+
* mutates a database or Worker secret; ordinary uploads whose migration tag is already active
|
|
205
|
+
* continue normally.
|
|
206
|
+
*/
|
|
207
|
+
async function assertCloudflareDurableObjectLifecycleSupported(options) {
|
|
208
|
+
const migrations = parseDurableObjectMigrations(parseRootWranglerConfig(options.configPath).migrations);
|
|
209
|
+
if (!migrations.some((migration) => migration.changesClasses)) return;
|
|
210
|
+
const existing = await readCloudflareWorker(options, true);
|
|
211
|
+
if (!existing) return;
|
|
212
|
+
const readMigrationTag = options.readMigrationTag ?? readCloudflareWorkerMigrationTag;
|
|
213
|
+
const migrationOptions = {
|
|
214
|
+
runWrangler: options.runWrangler,
|
|
215
|
+
accountId: options.accountId,
|
|
216
|
+
apiBaseUrl: options.apiBaseUrl,
|
|
217
|
+
workerName: options.workerName
|
|
218
|
+
};
|
|
219
|
+
const activeTag = await readMigrationTag(migrationOptions);
|
|
220
|
+
const after = await readCloudflareWorker(options, false);
|
|
221
|
+
const afterTag = await readMigrationTag(migrationOptions);
|
|
222
|
+
if (!after || after.versionId !== existing.versionId || afterTag !== activeTag) throw new Error("Cloudflare production or Durable Object migration state changed during verification. Retry from the current deployment.");
|
|
223
|
+
const activeIndex = activeTag === null ? -1 : migrations.findIndex((migration) => migration.tag === activeTag);
|
|
224
|
+
const pending = migrations.slice(activeIndex + 1).filter((migration) => migration.changesClasses);
|
|
225
|
+
if (pending.length === 0) return;
|
|
226
|
+
throw new Error([
|
|
227
|
+
"deploy: native Cloudflare beta cannot apply a Durable Object class migration to an existing Worker.",
|
|
228
|
+
`Pending migration tag${pending.length === 1 ? "" : "s"}: ${pending.map((migration) => migration.tag).join(", ")}.`,
|
|
229
|
+
"Apply the class migration through a supported Cloudflare deployment workflow, then retry after that migration tag is active.",
|
|
230
|
+
"No database migration or Worker secret was changed."
|
|
231
|
+
].join("\n"));
|
|
232
|
+
}
|
|
233
|
+
function parseDurableObjectMigrations(value) {
|
|
234
|
+
if (value === void 0) return [];
|
|
235
|
+
if (!Array.isArray(value)) throw new Error("deploy: generated Cloudflare config has a malformed migrations field.");
|
|
236
|
+
return value.map((migration) => {
|
|
237
|
+
if (!record(migration) || typeof migration.tag !== "string") throw new Error("deploy: generated Cloudflare config has a malformed class migration.");
|
|
238
|
+
const fields = [
|
|
239
|
+
migration.new_classes,
|
|
240
|
+
migration.new_sqlite_classes,
|
|
241
|
+
migration.renamed_classes,
|
|
242
|
+
migration.deleted_classes
|
|
243
|
+
];
|
|
244
|
+
for (const field of fields) if (field !== void 0 && !Array.isArray(field)) throw new Error("deploy: generated Cloudflare config has a malformed class migration.");
|
|
245
|
+
return {
|
|
246
|
+
tag: migration.tag,
|
|
247
|
+
changesClasses: fields.some((field) => Array.isArray(field) && field.length > 0)
|
|
248
|
+
};
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
function isMissingWorkerOutput$1(output) {
|
|
252
|
+
return /\[code:\s*10007\]/i.test(output) || /no deployments|\bworker\b.*\b(?:does not exist|not found)\b/i.test(output);
|
|
253
|
+
}
|
|
254
|
+
function assertCloudflareBindingsPreserved(before, after, approvedKv = []) {
|
|
255
|
+
const additions = newApprovedKvBindings(before, approvedKv);
|
|
256
|
+
for (const addition of additions) {
|
|
257
|
+
const matches = after.bindings.filter((binding) => binding.name === addition.binding);
|
|
258
|
+
if (matches.length !== 1 || matches[0].type !== "kv_namespace" || matches[0].namespace_id !== addition.id) throw new Error(`The uploaded version did not use the approved KV namespace for ${addition.binding}. It has not been activated.`);
|
|
259
|
+
}
|
|
260
|
+
const preservedBindings = after.bindings.filter((binding) => !additions.some((addition) => addition.binding === binding.name));
|
|
261
|
+
const sorted = (bindings) => [...bindings].sort((a, b) => String(a.name).localeCompare(String(b.name)));
|
|
262
|
+
if (!isDeepStrictEqual(sorted(before.bindings), sorted(preservedBindings))) throw new Error("The uploaded version did not preserve the existing Worker bindings and variables. It has not been activated.");
|
|
263
|
+
const handlers = (worker) => {
|
|
264
|
+
const value = worker.script.handlers;
|
|
265
|
+
return Array.isArray(value) && value.every((handler) => typeof handler === "string") ? [...new Set(value)].sort() : null;
|
|
266
|
+
};
|
|
267
|
+
if (!isDeepStrictEqual(handlers(before), handlers(after))) throw new Error("The uploaded version did not preserve the existing Worker event handlers. It has not been activated.");
|
|
268
|
+
}
|
|
269
|
+
/** Preserve the existing Worker, allowing only explicitly approved KV additions. */
|
|
270
|
+
function assertExistingCloudflareConfig(existing, config, built = false, approvedKv = []) {
|
|
271
|
+
const additions = newApprovedKvBindings(existing, approvedKv);
|
|
272
|
+
if (additions.length > 0) {
|
|
273
|
+
const entries = config.kv_namespaces;
|
|
274
|
+
for (const addition of additions) {
|
|
275
|
+
const matches = Array.isArray(entries) ? entries.filter((entry) => record(entry) && entry.binding === addition.binding) : [];
|
|
276
|
+
if ((built || matches.length > 0) && (matches.length !== 1 || matches[0].id !== addition.id)) throw new Error(`Binding ${addition.binding} must reference its approved KV namespace when linking.`);
|
|
277
|
+
}
|
|
278
|
+
if (Array.isArray(entries)) config = {
|
|
279
|
+
...config,
|
|
280
|
+
kv_namespaces: entries.filter((entry) => !record(entry) || !additions.some((addition) => addition.binding === entry.binding))
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
const handlers = existing.script.handlers;
|
|
284
|
+
if (!Array.isArray(handlers) || handlers.some((handler) => handler !== "fetch" && handler !== "scheduled")) throw new Error("Linking an existing Worker currently supports fetch and scheduled handlers only. Queue and Durable Object handlers need a separate migration.");
|
|
285
|
+
for (const field of [
|
|
286
|
+
"durable_objects",
|
|
287
|
+
"migrations",
|
|
288
|
+
"exports",
|
|
289
|
+
"containers",
|
|
290
|
+
"workflows",
|
|
291
|
+
"queues",
|
|
292
|
+
"triggers"
|
|
293
|
+
]) {
|
|
294
|
+
const value = config[field];
|
|
295
|
+
if (hasContent(value)) throw new Error(`The existing Worker handoff cannot change ${field}. Keep this deployment limited to the existing site.`);
|
|
296
|
+
}
|
|
297
|
+
if (record(existing.runtime.exports) && Object.values(existing.runtime.exports).some((value) => record(value) && value.type !== "worker")) throw new Error("The existing Worker exports stateful classes. Migrate those classes separately before linking.");
|
|
298
|
+
for (const field of [
|
|
299
|
+
"compatibility_date",
|
|
300
|
+
"compatibility_flags",
|
|
301
|
+
"limits"
|
|
302
|
+
]) {
|
|
303
|
+
const expected = existing.runtime[field] ?? (field === "compatibility_flags" ? [] : void 0);
|
|
304
|
+
const actual = config[field] ?? (field === "compatibility_flags" ? [] : void 0);
|
|
305
|
+
const equal = field === "compatibility_flags" ? stringSetEqual(actual, expected) : isDeepStrictEqual(actual, expected);
|
|
306
|
+
if ((built || config[field] !== void 0) && !equal) throw new Error(`Keep the existing Worker's ${field} when linking. Update it in a later deployment.`);
|
|
307
|
+
}
|
|
308
|
+
const vars = record(config.vars) ? config.vars : {};
|
|
309
|
+
const remote = new Map(existing.bindings.map((binding) => [binding.name, binding]));
|
|
310
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
311
|
+
const binding = remote.get(name);
|
|
312
|
+
const expected = binding?.type === "plain_text" ? binding.text : binding?.type === "json" ? binding.json : void 0;
|
|
313
|
+
if (!binding || !["plain_text", "json"].includes(String(binding.type)) || !isDeepStrictEqual(value, expected)) throw new Error(`Variable ${name} differs from the existing Worker. Preserve production variables and secrets when linking.`);
|
|
314
|
+
}
|
|
315
|
+
const supported = {
|
|
316
|
+
d1: [
|
|
317
|
+
"d1_databases",
|
|
318
|
+
"database_id",
|
|
319
|
+
"id"
|
|
320
|
+
],
|
|
321
|
+
kv_namespace: [
|
|
322
|
+
"kv_namespaces",
|
|
323
|
+
"id",
|
|
324
|
+
"namespace_id"
|
|
325
|
+
],
|
|
326
|
+
r2_bucket: [
|
|
327
|
+
"r2_buckets",
|
|
328
|
+
"bucket_name",
|
|
329
|
+
"bucket_name"
|
|
330
|
+
],
|
|
331
|
+
service: [
|
|
332
|
+
"services",
|
|
333
|
+
"service",
|
|
334
|
+
"service"
|
|
335
|
+
],
|
|
336
|
+
hyperdrive: [
|
|
337
|
+
"hyperdrive",
|
|
338
|
+
"id",
|
|
339
|
+
"id"
|
|
340
|
+
]
|
|
341
|
+
};
|
|
342
|
+
const found = /* @__PURE__ */ new Set();
|
|
343
|
+
for (const binding of existing.bindings) {
|
|
344
|
+
if (typeof binding.name !== "string") throw new Error("Cloudflare returned a binding without a name.");
|
|
345
|
+
if ([
|
|
346
|
+
"plain_text",
|
|
347
|
+
"json",
|
|
348
|
+
"secret_text",
|
|
349
|
+
"secret_key"
|
|
350
|
+
].includes(String(binding.type))) continue;
|
|
351
|
+
if (binding.type === "assets") {
|
|
352
|
+
if (built && (!record(config.assets) || config.assets.binding !== binding.name)) throw new Error(`Keep the existing assets binding ${binding.name} when linking.`);
|
|
353
|
+
continue;
|
|
354
|
+
}
|
|
355
|
+
const mapping = supported[String(binding.type)];
|
|
356
|
+
if (!mapping) throw new Error(`The existing binding ${binding.name} (${String(binding.type)}) is not yet supported when linking.`);
|
|
357
|
+
const [field, localId, remoteId] = mapping;
|
|
358
|
+
const entries = config[field];
|
|
359
|
+
const matches = Array.isArray(entries) ? entries.filter((entry) => record(entry) && entry.binding === binding.name) : [];
|
|
360
|
+
const entry = matches[0];
|
|
361
|
+
if (matches.length !== 1 || !record(entry) || typeof binding[remoteId] !== "string" || entry[localId] !== binding[remoteId] || binding.type === "service" && ((entry.environment ?? "production") !== (binding.environment ?? "production") || entry.entrypoint !== binding.entrypoint) || binding.type === "r2_bucket" && (entry.jurisdiction ?? "default") !== (binding.jurisdiction ?? "default")) throw new Error(`Binding ${binding.name} must reference the existing Cloudflare resource when linking.`);
|
|
362
|
+
found.add(binding.name);
|
|
363
|
+
}
|
|
364
|
+
for (const [field] of Object.values(supported)) {
|
|
365
|
+
const entries = config[field];
|
|
366
|
+
if (Array.isArray(entries) && entries.some((entry) => !record(entry) || !found.has(String(entry.binding)))) throw new Error(`Linking cannot add or replace ${field}. Use the existing resource bindings.`);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
function newApprovedKvBindings(existing, approved) {
|
|
370
|
+
const additions = [];
|
|
371
|
+
const names = /* @__PURE__ */ new Set();
|
|
372
|
+
for (const binding of approved) {
|
|
373
|
+
if (!binding.binding || names.has(binding.binding) || !/^[a-f0-9]{32}$/i.test(binding.id)) throw new Error("Invalid approved KV binding identity.");
|
|
374
|
+
names.add(binding.binding);
|
|
375
|
+
const previous = existing.bindings.filter((entry) => entry.name === binding.binding);
|
|
376
|
+
if (previous.length > 0) {
|
|
377
|
+
if (previous.length !== 1 || previous[0].type !== "kv_namespace" || previous[0].namespace_id !== binding.id) throw new Error(`The approved KV binding ${binding.binding} cannot replace an existing binding.`);
|
|
378
|
+
} else {
|
|
379
|
+
if (existing.bindings.some((entry) => entry.type === "kv_namespace" && entry.namespace_id === binding.id) || additions.some((entry) => entry.id === binding.id)) throw new Error(`The approved KV binding ${binding.binding} must use a separate namespace from existing bindings.`);
|
|
380
|
+
additions.push(binding);
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return additions;
|
|
384
|
+
}
|
|
385
|
+
function stringSetEqual(left, right) {
|
|
386
|
+
if (!Array.isArray(left) || !left.every((value) => typeof value === "string") || !Array.isArray(right) || !right.every((value) => typeof value === "string")) return false;
|
|
387
|
+
return isDeepStrictEqual([...new Set(left)].sort(), [...new Set(right)].sort());
|
|
388
|
+
}
|
|
389
|
+
function record(value) {
|
|
390
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
391
|
+
}
|
|
392
|
+
function hasContent(value) {
|
|
393
|
+
if (value === void 0 || value === null) return false;
|
|
394
|
+
if (Array.isArray(value)) return value.length > 0;
|
|
395
|
+
if (record(value)) return Object.values(value).some(hasContent);
|
|
396
|
+
return true;
|
|
397
|
+
}
|
|
398
|
+
//#endregion
|
|
399
|
+
//#region src/execute.ts
|
|
400
|
+
/** Upload the already-verified Worker artifact without Wrangler autoconfiguration. */
|
|
401
|
+
async function executeCloudflareDeploy(options) {
|
|
402
|
+
if (!options.verifyUploader()) return {
|
|
403
|
+
ok: false,
|
|
404
|
+
reason: "uploader-changed"
|
|
405
|
+
};
|
|
406
|
+
const result = await Promise.resolve(options.runWrangler([
|
|
407
|
+
"deploy",
|
|
408
|
+
...options.configPath ? ["-c", options.configPath] : [],
|
|
409
|
+
"--env=",
|
|
410
|
+
"--experimental-provision=false",
|
|
411
|
+
"--experimental-auto-create=false",
|
|
412
|
+
"--autoconfig=false"
|
|
413
|
+
], { capture: false }));
|
|
414
|
+
if (!isCleanCommandExit$1(result)) return {
|
|
415
|
+
ok: false,
|
|
416
|
+
reason: "deploy-failed",
|
|
417
|
+
status: result.status
|
|
418
|
+
};
|
|
419
|
+
return { ok: true };
|
|
420
|
+
}
|
|
421
|
+
function isCleanCommandExit$1(result) {
|
|
422
|
+
return result.status === 0 && result.signal == null && (result.error == null || result.error === "");
|
|
423
|
+
}
|
|
424
|
+
//#endregion
|
|
425
|
+
//#region ../../node_modules/.pnpm/pathslash@0.1.0/node_modules/pathslash/dist/index.mjs
|
|
426
|
+
const BACKSLASH_RE = /\\/g;
|
|
427
|
+
const isWindows = process.platform === "win32";
|
|
428
|
+
/**
|
|
429
|
+
* Flip backslashes to forward slashes, used to slash-ify `node:path.win32`
|
|
430
|
+
* output. `\\?\` (extended-length) paths are left untouched: there a forward
|
|
431
|
+
* slash is a literal character, not a separator.
|
|
432
|
+
*/
|
|
433
|
+
const toForwardSlash = (path) => path.startsWith("\\\\?\\") ? path : path.replace(BACKSLASH_RE, "/");
|
|
434
|
+
const w = nodePath.win32;
|
|
435
|
+
function slashed(fn) {
|
|
436
|
+
return (...args) => toForwardSlash(fn(...args));
|
|
437
|
+
}
|
|
438
|
+
const win32 = {
|
|
439
|
+
resolve: slashed(w.resolve),
|
|
440
|
+
normalize: slashed(w.normalize),
|
|
441
|
+
join: slashed(w.join),
|
|
442
|
+
relative: slashed(w.relative),
|
|
443
|
+
dirname: slashed(w.dirname),
|
|
444
|
+
format: slashed(w.format),
|
|
445
|
+
parse: (path) => {
|
|
446
|
+
const parsed = w.parse(path);
|
|
447
|
+
parsed.root = toForwardSlash(parsed.root);
|
|
448
|
+
parsed.dir = toForwardSlash(parsed.dir);
|
|
449
|
+
return parsed;
|
|
450
|
+
},
|
|
451
|
+
basename: w.basename,
|
|
452
|
+
extname: w.extname,
|
|
453
|
+
isAbsolute: w.isAbsolute,
|
|
454
|
+
matchesGlob: w.matchesGlob,
|
|
455
|
+
toNamespacedPath: w.toNamespacedPath,
|
|
456
|
+
delimiter: w.delimiter,
|
|
457
|
+
sep: "/"
|
|
458
|
+
};
|
|
459
|
+
const posix = { ...nodePath.posix };
|
|
460
|
+
posix.posix = win32.posix = posix;
|
|
461
|
+
posix.win32 = win32.win32 = win32;
|
|
462
|
+
const { basename, delimiter, dirname, extname, format, isAbsolute, join, matchesGlob, normalize, parse: parse$1, relative, resolve, sep, toNamespacedPath } = isWindows ? win32 : posix;
|
|
463
|
+
//#endregion
|
|
464
|
+
//#region src/framework.ts
|
|
465
|
+
/**
|
|
466
|
+
* Point a standalone framework Worker at the canonical root Cloudflare state.
|
|
467
|
+
*
|
|
468
|
+
* Resource ids and deployment history remain in the user's root wrangler
|
|
469
|
+
* config. The generated deploy pointer contains only a relative path back to
|
|
470
|
+
* that file, so Wrangler uploads the exact config Void verifies.
|
|
471
|
+
*/
|
|
472
|
+
function writeCloudflareFrameworkDeployConfig(options) {
|
|
473
|
+
const initial = existsSync(options.configPath) ? readFileSync(options.configPath, "utf-8") : "{}\n";
|
|
474
|
+
let content = initial;
|
|
475
|
+
const upsert = (path, value) => {
|
|
476
|
+
content = applyEdits(content, modify(content, path, value, { formattingOptions: {
|
|
477
|
+
insertSpaces: true,
|
|
478
|
+
tabSize: 2,
|
|
479
|
+
eol: "\n"
|
|
480
|
+
} }));
|
|
481
|
+
};
|
|
482
|
+
upsert(["name"], options.name);
|
|
483
|
+
upsert(["account_id"], options.accountId);
|
|
484
|
+
upsert(["main"], options.main);
|
|
485
|
+
upsert(["compatibility_date"], options.compatibilityDate);
|
|
486
|
+
if (options.compatibilityFlags && options.compatibilityFlags.length > 0) {
|
|
487
|
+
const config = parse(content);
|
|
488
|
+
const current = Array.isArray(config.compatibility_flags) ? config.compatibility_flags.filter((flag) => typeof flag === "string") : [];
|
|
489
|
+
upsert(["compatibility_flags"], [.../* @__PURE__ */ new Set([...current, ...options.compatibilityFlags])]);
|
|
490
|
+
}
|
|
491
|
+
upsert(["no_bundle"], false);
|
|
492
|
+
upsert(["assets", "directory"], options.assetsDirectory);
|
|
493
|
+
upsert(["assets", "binding"], "ASSETS");
|
|
494
|
+
upsert(["assets", "run_worker_first"], true);
|
|
495
|
+
upsert(["assets", "not_found_handling"], "none");
|
|
496
|
+
if (options.crons !== void 0) upsert(["triggers", "crons"], [...options.crons]);
|
|
497
|
+
if (options.requiredSecrets && options.requiredSecrets.length > 0) {
|
|
498
|
+
const config = parse(content);
|
|
499
|
+
if (config.secrets !== void 0 && (typeof config.secrets !== "object" || config.secrets === null || Array.isArray(config.secrets))) throw new Error("Cloudflare framework config has a malformed secrets field.");
|
|
500
|
+
const required = config.secrets?.required;
|
|
501
|
+
if (required !== void 0 && (!Array.isArray(required) || !required.every((name) => typeof name === "string"))) throw new Error("Cloudflare framework config has a malformed secrets.required field.");
|
|
502
|
+
upsert(["secrets", "required"], [.../* @__PURE__ */ new Set([...required ?? [], ...options.requiredSecrets])]);
|
|
503
|
+
}
|
|
504
|
+
let changed = content !== initial;
|
|
505
|
+
if (changed) writeFileSync(options.configPath, content);
|
|
506
|
+
const pointer = `${JSON.stringify({
|
|
507
|
+
configPath: relative(dirname(options.deployPointerPath), options.configPath),
|
|
508
|
+
auxiliaryWorkers: []
|
|
509
|
+
}, null, 2)}\n`;
|
|
510
|
+
if ((existsSync(options.deployPointerPath) ? readFileSync(options.deployPointerPath, "utf-8") : null) !== pointer) {
|
|
511
|
+
mkdirSync(dirname(options.deployPointerPath), { recursive: true });
|
|
512
|
+
writeFileSync(options.deployPointerPath, pointer);
|
|
513
|
+
changed = true;
|
|
514
|
+
}
|
|
515
|
+
if (options.assetsIgnore && options.assetsIgnore.length > 0) {
|
|
516
|
+
const ignorePath = join(dirname(options.configPath), options.assetsDirectory, ".assetsignore");
|
|
517
|
+
const previousIgnore = existsSync(ignorePath) ? readFileSync(ignorePath, "utf-8") : null;
|
|
518
|
+
const existingLines = new Set((previousIgnore ?? "").split(/\r?\n/));
|
|
519
|
+
let ignore = previousIgnore ?? "";
|
|
520
|
+
if (ignore.length > 0 && !ignore.endsWith("\n") && !ignore.endsWith("\r")) ignore += "\n";
|
|
521
|
+
for (const pattern of options.assetsIgnore) if (!existingLines.has(pattern)) ignore += `${pattern}\n`;
|
|
522
|
+
if (previousIgnore !== ignore) {
|
|
523
|
+
writeFileSync(ignorePath, ignore);
|
|
524
|
+
changed = true;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
return changed;
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/static.ts
|
|
531
|
+
/** Generate the deterministic thin Worker entry bundled by Wrangler. */
|
|
532
|
+
function renderCloudflareStaticEntry(options) {
|
|
533
|
+
const config = JSON.stringify({
|
|
534
|
+
...options.headerRules?.length ? { headerRules: options.headerRules } : {},
|
|
535
|
+
...options.redirectRules?.length ? { redirectRules: options.redirectRules } : {},
|
|
536
|
+
...options.fallbackRules?.length ? { fallbackRules: options.fallbackRules } : {}
|
|
537
|
+
});
|
|
538
|
+
return [
|
|
539
|
+
`import { createStaticEdgeHandler } from ${JSON.stringify(options.runtimeImport ?? "@void/edge")};`,
|
|
540
|
+
`export default createStaticEdgeHandler(${config});`,
|
|
541
|
+
""
|
|
542
|
+
].join("\n");
|
|
543
|
+
}
|
|
544
|
+
/**
|
|
545
|
+
* Copy static assets into Void's deploy area without Cloudflare's reserved routing files.
|
|
546
|
+
*
|
|
547
|
+
* Void has already compiled the root `_headers` and `_redirects` files into the thin edge
|
|
548
|
+
* Worker. Wrangler parses those files independently of `.assetsignore`, so uploading the source
|
|
549
|
+
* directory would apply every rule twice and can reject valid Void fallbacks as redirect loops.
|
|
550
|
+
*/
|
|
551
|
+
function stageCloudflareStaticAssets(options) {
|
|
552
|
+
const sourceDirectory = resolve(options.sourceDirectory);
|
|
553
|
+
const destinationDirectory = resolve(options.destinationDirectory);
|
|
554
|
+
if (sourceDirectory === destinationDirectory) throw new Error("Cloudflare static asset staging requires distinct source and destination directories.");
|
|
555
|
+
const reserved = /* @__PURE__ */ new Set([join(sourceDirectory, "_headers"), join(sourceDirectory, "_redirects")]);
|
|
556
|
+
const isInsideDestination = (path) => {
|
|
557
|
+
const fromDestination = relative(destinationDirectory, resolve(path));
|
|
558
|
+
return fromDestination === "" || fromDestination !== ".." && !fromDestination.startsWith("../");
|
|
559
|
+
};
|
|
560
|
+
rmSync(destinationDirectory, {
|
|
561
|
+
recursive: true,
|
|
562
|
+
force: true
|
|
563
|
+
});
|
|
564
|
+
mkdirSync(dirname(destinationDirectory), { recursive: true });
|
|
565
|
+
cpSync(sourceDirectory, destinationDirectory, {
|
|
566
|
+
recursive: true,
|
|
567
|
+
filter(path) {
|
|
568
|
+
const absolute = resolve(path);
|
|
569
|
+
return !reserved.has(absolute) && !isInsideDestination(absolute);
|
|
570
|
+
}
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* Persist the mutable Cloudflare target state without replacing comments,
|
|
575
|
+
* Durable Object migrations, resource ids, or unrelated user configuration.
|
|
576
|
+
*/
|
|
577
|
+
function writeCloudflareStaticDeployConfig(options) {
|
|
578
|
+
const initial = existsSync(options.configPath) ? readFileSync(options.configPath, "utf-8") : "{}\n";
|
|
579
|
+
const hasBom = initial.startsWith("");
|
|
580
|
+
const source = hasBom ? initial.slice(1) : initial;
|
|
581
|
+
assertValidStaticConfig(source, options.configPath);
|
|
582
|
+
const originalComments = extractCommentTokens(source);
|
|
583
|
+
let content = source;
|
|
584
|
+
const upsert = (path, value) => {
|
|
585
|
+
const edits = modify(content, path, value, { formattingOptions: {
|
|
586
|
+
insertSpaces: true,
|
|
587
|
+
tabSize: 2,
|
|
588
|
+
eol: "\n"
|
|
589
|
+
} });
|
|
590
|
+
content = applyEdits(content, edits);
|
|
591
|
+
};
|
|
592
|
+
upsert(["name"], options.name);
|
|
593
|
+
upsert(["account_id"], options.accountId);
|
|
594
|
+
upsert(["main"], options.main);
|
|
595
|
+
upsert(["compatibility_date"], options.compatibilityDate);
|
|
596
|
+
upsert(["no_bundle"], false);
|
|
597
|
+
upsert(["assets", "directory"], options.assetsDirectory);
|
|
598
|
+
upsert(["assets", "binding"], "ASSETS");
|
|
599
|
+
upsert(["assets", "run_worker_first"], true);
|
|
600
|
+
upsert(["assets", "not_found_handling"], options.assetConfig.not_found_handling ?? "none");
|
|
601
|
+
upsert(["assets", "html_handling"], options.assetConfig.html_handling ?? "auto-trailing-slash");
|
|
602
|
+
upsert(["durable_objects", "bindings"], []);
|
|
603
|
+
upsert(["queues", "producers"], []);
|
|
604
|
+
upsert(["queues", "consumers"], []);
|
|
605
|
+
upsert(["triggers", "crons"], []);
|
|
606
|
+
upsert(["containers"], []);
|
|
607
|
+
upsert(["workflows"], []);
|
|
608
|
+
assertValidStaticConfig(content, options.configPath);
|
|
609
|
+
const droppedComment = findDroppedComment(originalComments, extractCommentTokens(content));
|
|
610
|
+
if (droppedComment !== void 0) throw new Error(`deploy: cannot update "${options.configPath}" without dropping comment ${JSON.stringify(droppedComment)}. Move comments out of worker-only configuration and retry.`);
|
|
611
|
+
const finalContent = hasBom ? `\uFEFF${content}` : content;
|
|
612
|
+
if (finalContent !== initial) {
|
|
613
|
+
writeFileSync(options.configPath, finalContent);
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
return false;
|
|
617
|
+
}
|
|
618
|
+
function assertValidStaticConfig(text, configPath) {
|
|
619
|
+
const errors = [];
|
|
620
|
+
const parsed = parse(text, errors, { allowTrailingComma: true });
|
|
621
|
+
const root = parseTree(text, void 0, { allowTrailingComma: true });
|
|
622
|
+
if (errors.length > 0 || !root || typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) throw new Error(`deploy: cannot update "${configPath}" — it is not valid JSON/JSONC. Fix the file and retry.`);
|
|
623
|
+
const walk = (node) => {
|
|
624
|
+
if (node.type === "object" && node.children) {
|
|
625
|
+
const seen = /* @__PURE__ */ new Set();
|
|
626
|
+
for (const property of node.children) {
|
|
627
|
+
const key = property.children?.[0]?.value;
|
|
628
|
+
if (typeof key !== "string") continue;
|
|
629
|
+
if (seen.has(key)) throw new Error(`deploy: cannot update "${configPath}" — duplicate key ${JSON.stringify(key)}. Wrangler reads the last duplicate while JSONC edits update the first. Remove the duplicate and retry.`);
|
|
630
|
+
seen.add(key);
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
node.children?.forEach(walk);
|
|
634
|
+
};
|
|
635
|
+
walk(root);
|
|
636
|
+
}
|
|
637
|
+
function extractCommentTokens(text) {
|
|
638
|
+
const scanner = createScanner(text, false);
|
|
639
|
+
const comments = [];
|
|
640
|
+
for (let kind = scanner.scan(); kind !== SyntaxKind.EOF; kind = scanner.scan()) if (kind === SyntaxKind.LineCommentTrivia || kind === SyntaxKind.BlockCommentTrivia) {
|
|
641
|
+
const offset = scanner.getTokenOffset();
|
|
642
|
+
comments.push(text.slice(offset, offset + scanner.getTokenLength()));
|
|
643
|
+
}
|
|
644
|
+
return comments;
|
|
645
|
+
}
|
|
646
|
+
function findDroppedComment(before, after) {
|
|
647
|
+
const counts = /* @__PURE__ */ new Map();
|
|
648
|
+
for (const token of after) counts.set(token, (counts.get(token) ?? 0) + 1);
|
|
649
|
+
for (const token of before) {
|
|
650
|
+
const remaining = counts.get(token) ?? 0;
|
|
651
|
+
if (remaining === 0) return token;
|
|
652
|
+
counts.set(token, remaining - 1);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
//#endregion
|
|
656
|
+
//#region src/diagnostics.ts
|
|
657
|
+
const MAX_DIAGNOSTIC_LENGTH = 4096;
|
|
658
|
+
const MAX_DIAGNOSTIC_LINES = 24;
|
|
659
|
+
/** Keep the uploader's error, excluding stdout binding tables and redacting common credential formats. */
|
|
660
|
+
function cloudflareCommandDiagnostic(result) {
|
|
661
|
+
const stderr = stripVTControlCharacters(result.stderr).replace(/\r\n?/g, "\n");
|
|
662
|
+
const stdout = stripVTControlCharacters(result.stdout).replace(/\r\n?/g, "\n");
|
|
663
|
+
let message = result.error || stderr;
|
|
664
|
+
for (const output of [stderr, stdout]) {
|
|
665
|
+
const marker = /(?:^|\n)[ \t]*(?:[✘×X][ \t]+)?\[ERROR\]\s*/i.exec(output);
|
|
666
|
+
if (marker) {
|
|
667
|
+
message = output.slice(marker.index + marker[0].length);
|
|
668
|
+
break;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
message = stripVTControlCharacters(message).split(/\n\s*(?:🪵|Logs (?:were )?written|If you think this is a bug)/)[0].trim();
|
|
672
|
+
if (!message) return;
|
|
673
|
+
message = message.replace(/\b(Bearer|Basic)\s+\S+/gi, "$1 [redacted]").replace(/([?&](?:X-Amz-[A-Za-z0-9-]+|signature|sig|token|access_token|key)=)[^&\s]+/gi, "$1[redacted]").replace(/\b([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+:[^\s/@]*@/gi, "$1[redacted]@").replace(/(\b(?:CLOUDFLARE_API_TOKEN|CF_ACCESS_CLIENT_SECRET|access_token|refresh_token|api[_-]?(?:key|token)|password|secret)\b["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)/gi, "$1[redacted]");
|
|
674
|
+
const bounded = message.split("\n").slice(0, MAX_DIAGNOSTIC_LINES).join("\n").slice(0, MAX_DIAGNOSTIC_LENGTH);
|
|
675
|
+
return bounded.length < message.length ? `${bounded}\n[diagnostic truncated]` : bounded;
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/version.ts
|
|
679
|
+
const VERSION_ID_RE = /(?:Worker|Current) Version ID:\s*([0-9a-f]{8}-[0-9a-f-]{27})/i;
|
|
680
|
+
const PREVIEW_URL_RE = /Version Preview(?: Alias)? URL:\s*(https:\/\/\S+)/i;
|
|
681
|
+
const WORKERS_DEV_URL_RE = /(https:\/\/[a-z0-9.-]+\.workers\.dev(?:\/[^\s]*)?)/i;
|
|
682
|
+
const CLOUDFLARE_ERROR_CODE_RE = /\[code:\s*(\d+)\]/i;
|
|
683
|
+
const DURABLE_MIGRATION_VERSION_ERROR = 10211;
|
|
684
|
+
/** Encode migration state into Cloudflare's version annotation for safe rollback diagnostics. */
|
|
685
|
+
function encodeCloudflareVersionMessage(migrations, triggerConfig) {
|
|
686
|
+
return `void:${JSON.stringify(triggerConfig ? {
|
|
687
|
+
schema: 2,
|
|
688
|
+
migrations,
|
|
689
|
+
triggerConfig
|
|
690
|
+
} : {
|
|
691
|
+
schema: 1,
|
|
692
|
+
migrations
|
|
693
|
+
})}`;
|
|
694
|
+
}
|
|
695
|
+
/** Read migration state written by {@link encodeCloudflareVersionMessage}. */
|
|
696
|
+
function parseCloudflareVersionMessage(message) {
|
|
697
|
+
if (!message?.startsWith("void:")) return null;
|
|
698
|
+
try {
|
|
699
|
+
const value = JSON.parse(message.slice(5));
|
|
700
|
+
if (value.schema !== 1 && value.schema !== 2 || !Array.isArray(value.migrations) || !value.migrations.every((migration) => typeof migration === "string")) return null;
|
|
701
|
+
if (value.schema === 1) return {
|
|
702
|
+
schema: 1,
|
|
703
|
+
migrations: value.migrations
|
|
704
|
+
};
|
|
705
|
+
const triggerConfig = parseTriggerConfig(value.triggerConfig);
|
|
706
|
+
return triggerConfig ? {
|
|
707
|
+
schema: 2,
|
|
708
|
+
migrations: value.migrations,
|
|
709
|
+
triggerConfig
|
|
710
|
+
} : null;
|
|
711
|
+
} catch {
|
|
712
|
+
return null;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
/**
|
|
716
|
+
* Capture every configuration surface mutated by `wrangler triggers deploy` — routes,
|
|
717
|
+
* workers.dev / preview flags, crons, queue consumers, workflows, and Email Routing addresses.
|
|
718
|
+
*/
|
|
719
|
+
function readCloudflareTriggerConfig(configPath, sourceConfigPath) {
|
|
720
|
+
const result = triggerConfigFromWrangler(parseRootWranglerConfig(configPath));
|
|
721
|
+
if (!sourceConfigPath) return result;
|
|
722
|
+
const source = parseRootWranglerConfig(sourceConfigPath);
|
|
723
|
+
return {
|
|
724
|
+
...result,
|
|
725
|
+
settingsHash: nonVersionedSettingsHash(source),
|
|
726
|
+
...source.route === void 0 && source.routes === void 0 ? { preserveRoutes: true } : {},
|
|
727
|
+
...source.workers_dev === void 0 ? { preserveWorkersDev: true } : {},
|
|
728
|
+
...source.preview_urls === void 0 ? { preservePreviewUrls: true } : {}
|
|
729
|
+
};
|
|
730
|
+
}
|
|
731
|
+
/** Apply a version's persisted trigger state using an isolated temporary Wrangler config. */
|
|
732
|
+
async function deployCloudflareVersionTriggers(options) {
|
|
733
|
+
if (options.triggerConfig.preserve) return {
|
|
734
|
+
status: 0,
|
|
735
|
+
stdout: "",
|
|
736
|
+
stderr: ""
|
|
737
|
+
};
|
|
738
|
+
const config = triggerWranglerConfig(pickWranglerTriggerContext(options.configPath ? parseRootWranglerConfig(options.configPath) : {}), triggerConfigWithRemovedWorkflowSchedules(options.workerName, options.triggerConfig, options.previousTriggerConfig), options.manageRoutes);
|
|
739
|
+
const directory = mkdtempSync(join(tmpdir(), "void-version-triggers-"));
|
|
740
|
+
const configPath = join(directory, "wrangler.json");
|
|
741
|
+
try {
|
|
742
|
+
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`, { mode: 384 });
|
|
743
|
+
const deployed = await options.runWrangler([
|
|
744
|
+
"triggers",
|
|
745
|
+
"deploy",
|
|
746
|
+
"-c",
|
|
747
|
+
configPath,
|
|
748
|
+
"--env=",
|
|
749
|
+
"--name",
|
|
750
|
+
options.workerName
|
|
751
|
+
], { capture: false });
|
|
752
|
+
if (!isCleanCommandExit(deployed)) return deployed;
|
|
753
|
+
for (const queue of removedQueueConsumers(options.triggerConfig, options.previousTriggerConfig)) {
|
|
754
|
+
const removed = await options.runWrangler([
|
|
755
|
+
"queues",
|
|
756
|
+
"consumer",
|
|
757
|
+
"remove",
|
|
758
|
+
queue,
|
|
759
|
+
options.workerName,
|
|
760
|
+
"-c",
|
|
761
|
+
configPath,
|
|
762
|
+
"--env="
|
|
763
|
+
], { capture: false });
|
|
764
|
+
if (!isCleanCommandExit(removed)) return removed;
|
|
765
|
+
}
|
|
766
|
+
if (options.manageRoutes !== false && !options.triggerConfig.preserveRoutes && hasCustomDomains(options.previousTriggerConfig?.routes) && !hasCustomDomains(options.triggerConfig.routes)) {
|
|
767
|
+
if (!options.publishEmptyCustomDomains) return {
|
|
768
|
+
status: 1,
|
|
769
|
+
stdout: "",
|
|
770
|
+
stderr: "Wrangler cannot publish an empty Custom Domain set and no direct publisher was available."
|
|
771
|
+
};
|
|
772
|
+
const published = await options.publishEmptyCustomDomains();
|
|
773
|
+
if (!isCleanCommandExit(published)) return published;
|
|
774
|
+
}
|
|
775
|
+
return deployed;
|
|
776
|
+
} finally {
|
|
777
|
+
rmSync(directory, {
|
|
778
|
+
recursive: true,
|
|
779
|
+
force: true
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function pickWranglerTriggerContext(source) {
|
|
784
|
+
const result = {};
|
|
785
|
+
for (const key of [
|
|
786
|
+
"account_id",
|
|
787
|
+
"name",
|
|
788
|
+
"legacy_env",
|
|
789
|
+
"send_metrics",
|
|
790
|
+
"compliance_region"
|
|
791
|
+
]) if (Object.hasOwn(source, key)) result[key] = source[key];
|
|
792
|
+
return result;
|
|
793
|
+
}
|
|
794
|
+
/**
|
|
795
|
+
* Reconcile triggers to the version that actually owns production traffic.
|
|
796
|
+
*
|
|
797
|
+
* Worker Version activation and `wrangler triggers deploy` are separate Cloudflare operations.
|
|
798
|
+
* Another deploy or rollback can therefore take ownership between them. Re-read ownership before
|
|
799
|
+
* and after every trigger publication and, when it changes, repair the new owner's immutable
|
|
800
|
+
* schema-2 snapshot. The bounded loop fails closed if traffic never stabilizes.
|
|
801
|
+
*/
|
|
802
|
+
async function stabilizeCloudflareVersionTriggers(options) {
|
|
803
|
+
const commonArgs = [...options.configPath ? ["-c", options.configPath] : [], "--env="];
|
|
804
|
+
let cleanupBaseline = mergeTriggerCleanupBaselines(options.previousTriggerConfig, options.targetTriggerConfig);
|
|
805
|
+
const attempts = Math.max(1, options.attempts ?? 4);
|
|
806
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
807
|
+
const owner = await readCurrentProductionVersion(options, commonArgs);
|
|
808
|
+
if (owner.kind === "failed") return {
|
|
809
|
+
ok: false,
|
|
810
|
+
reason: "status-failed",
|
|
811
|
+
status: owner.status
|
|
812
|
+
};
|
|
813
|
+
if (owner.kind === "split") return {
|
|
814
|
+
ok: false,
|
|
815
|
+
reason: "traffic-split"
|
|
816
|
+
};
|
|
817
|
+
if (!owner.versionId) return {
|
|
818
|
+
ok: false,
|
|
819
|
+
reason: "metadata-unavailable",
|
|
820
|
+
activeVersionId: null
|
|
821
|
+
};
|
|
822
|
+
const activeVersionId = owner.versionId;
|
|
823
|
+
let activeTriggerConfig = activeVersionId === options.targetVersionId ? options.targetTriggerConfig : options.knownTriggerConfigs?.[activeVersionId];
|
|
824
|
+
if (!activeTriggerConfig && activeVersionId !== options.targetVersionId) {
|
|
825
|
+
const metadata = await readCloudflareVersionMetadata(options, commonArgs, activeVersionId);
|
|
826
|
+
activeTriggerConfig = metadata?.schema === 2 ? metadata.triggerConfig : void 0;
|
|
827
|
+
}
|
|
828
|
+
if (!activeTriggerConfig) return {
|
|
829
|
+
ok: false,
|
|
830
|
+
reason: "metadata-unavailable",
|
|
831
|
+
activeVersionId
|
|
832
|
+
};
|
|
833
|
+
const triggers = await deployCloudflareVersionTriggers({
|
|
834
|
+
runWrangler: options.runWrangler,
|
|
835
|
+
workerName: options.workerName,
|
|
836
|
+
configPath: options.configPath,
|
|
837
|
+
triggerConfig: activeTriggerConfig,
|
|
838
|
+
previousTriggerConfig: cleanupBaseline,
|
|
839
|
+
publishEmptyCustomDomains: options.publishEmptyCustomDomains,
|
|
840
|
+
manageRoutes: options.manageRoutes
|
|
841
|
+
});
|
|
842
|
+
if (!isCleanCommandExit(triggers)) return {
|
|
843
|
+
ok: false,
|
|
844
|
+
reason: "triggers-failed",
|
|
845
|
+
status: triggers.status,
|
|
846
|
+
activeVersionId
|
|
847
|
+
};
|
|
848
|
+
cleanupBaseline = mergeTriggerCleanupBaselines(cleanupBaseline, activeTriggerConfig);
|
|
849
|
+
const after = await readCurrentProductionVersion(options, commonArgs);
|
|
850
|
+
if (after.kind === "failed") return {
|
|
851
|
+
ok: false,
|
|
852
|
+
reason: "status-failed",
|
|
853
|
+
status: after.status
|
|
854
|
+
};
|
|
855
|
+
if (after.kind === "split") return {
|
|
856
|
+
ok: false,
|
|
857
|
+
reason: "traffic-split"
|
|
858
|
+
};
|
|
859
|
+
if (after.versionId === activeVersionId) return {
|
|
860
|
+
ok: true,
|
|
861
|
+
activeVersionId,
|
|
862
|
+
targetOwnsTraffic: activeVersionId === options.targetVersionId
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
return {
|
|
866
|
+
ok: false,
|
|
867
|
+
reason: "status-failed"
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
function mergeTriggerCleanupBaselines(...configs) {
|
|
871
|
+
const present = configs.filter((config) => config !== void 0);
|
|
872
|
+
const unique = (values) => {
|
|
873
|
+
const seen = /* @__PURE__ */ new Set();
|
|
874
|
+
return values.filter((value) => {
|
|
875
|
+
const key = JSON.stringify(value);
|
|
876
|
+
if (seen.has(key)) return false;
|
|
877
|
+
seen.add(key);
|
|
878
|
+
return true;
|
|
879
|
+
});
|
|
880
|
+
};
|
|
881
|
+
const withAddresses = present.filter((config) => config.addresses !== void 0);
|
|
882
|
+
return {
|
|
883
|
+
workers_dev: present.some((config) => config.workers_dev),
|
|
884
|
+
preview_urls: present.some((config) => config.preview_urls),
|
|
885
|
+
routes: unique(present.flatMap((config) => config.routes)),
|
|
886
|
+
crons: unique(present.flatMap((config) => config.crons)),
|
|
887
|
+
queueConsumers: unique(present.flatMap((config) => config.queueConsumers)),
|
|
888
|
+
workflows: unique(present.flatMap((config) => config.workflows)),
|
|
889
|
+
...withAddresses.length === 0 ? {} : { addresses: [...new Set(withAddresses.flatMap((config) => config.addresses ?? []))] }
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function removedQueueConsumers(desired, previous) {
|
|
893
|
+
if (!previous) return [];
|
|
894
|
+
const desiredQueues = new Set(queueConsumerNames(desired.queueConsumers));
|
|
895
|
+
return queueConsumerNames(previous.queueConsumers).filter((queue) => !desiredQueues.has(queue)).sort();
|
|
896
|
+
}
|
|
897
|
+
function queueConsumerNames(consumers) {
|
|
898
|
+
return [...new Set(consumers.flatMap((consumer) => isRecord(consumer) && typeof consumer.queue === "string" ? [consumer.queue] : []))];
|
|
899
|
+
}
|
|
900
|
+
function triggerConfigWithQueueConsumerCleanup(previous, cleanup) {
|
|
901
|
+
const queues = [...new Set((cleanup ?? []).filter((queue) => queue.length > 0))];
|
|
902
|
+
if (queues.length === 0) return previous;
|
|
903
|
+
const base = previous ?? {
|
|
904
|
+
workers_dev: false,
|
|
905
|
+
preview_urls: false,
|
|
906
|
+
routes: [],
|
|
907
|
+
crons: [],
|
|
908
|
+
queueConsumers: [],
|
|
909
|
+
workflows: []
|
|
910
|
+
};
|
|
911
|
+
return {
|
|
912
|
+
...base,
|
|
913
|
+
queueConsumers: [...base.queueConsumers, ...queues.filter((queue) => !queueConsumerNames(base.queueConsumers).includes(queue)).map((queue) => ({ queue }))]
|
|
914
|
+
};
|
|
915
|
+
}
|
|
916
|
+
function triggerConfigWithRemovedWorkflowSchedules(workerName, desired, previous) {
|
|
917
|
+
if (!previous) return desired;
|
|
918
|
+
const desiredNames = new Set(desired.workflows.flatMap((workflow) => isRecord(workflow) && typeof workflow.name === "string" ? [workflow.name] : []));
|
|
919
|
+
const removed = previous.workflows.flatMap((workflow) => {
|
|
920
|
+
if (!isRecord(workflow) || typeof workflow.name !== "string" || typeof workflow.class_name !== "string" || desiredNames.has(workflow.name) || typeof workflow.script_name === "string" && workflow.script_name !== workerName) return [];
|
|
921
|
+
return [{
|
|
922
|
+
...workflow,
|
|
923
|
+
schedules: []
|
|
924
|
+
}];
|
|
925
|
+
});
|
|
926
|
+
return removed.length === 0 ? desired : {
|
|
927
|
+
...desired,
|
|
928
|
+
workflows: [...desired.workflows, ...removed]
|
|
929
|
+
};
|
|
930
|
+
}
|
|
931
|
+
function hasCustomDomains(routes) {
|
|
932
|
+
return routes?.some((route) => isRecord(route) && route.custom_domain === true) === true;
|
|
933
|
+
}
|
|
934
|
+
/** Select a configured HTTPS route that is guaranteed to cover /__void/ready. */
|
|
935
|
+
function customRouteReadinessUrl(routes) {
|
|
936
|
+
for (const route of routes) {
|
|
937
|
+
const pattern = typeof route === "string" ? route : isRecord(route) && typeof route.pattern === "string" ? route.pattern : void 0;
|
|
938
|
+
if (!pattern) continue;
|
|
939
|
+
const customDomain = isRecord(route) && route.custom_domain === true;
|
|
940
|
+
const withoutScheme = pattern.replace(/^https?:\/\//i, "");
|
|
941
|
+
const slash = withoutScheme.indexOf("/");
|
|
942
|
+
const hostname = (slash < 0 ? withoutScheme : withoutScheme.slice(0, slash)).trim();
|
|
943
|
+
const path = slash < 0 ? "" : withoutScheme.slice(slash);
|
|
944
|
+
if (!hostname || hostname.includes("*") || !customDomain && path !== "/*" && path !== "*") continue;
|
|
945
|
+
return `https://${hostname}`;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function triggerConfigFromWrangler(config) {
|
|
949
|
+
const triggers = isRecord(config.triggers) ? config.triggers : {};
|
|
950
|
+
const queues = isRecord(config.queues) ? config.queues : {};
|
|
951
|
+
const routes = Array.isArray(config.routes) ? config.routes : config.route === void 0 ? [] : [config.route];
|
|
952
|
+
const workersDev = typeof config.workers_dev === "boolean" ? config.workers_dev : routes.length === 0;
|
|
953
|
+
const addresses = readEmailAddresses(config.addresses);
|
|
954
|
+
return {
|
|
955
|
+
...config.routes === void 0 && config.route === void 0 ? { preserveRoutes: true } : {},
|
|
956
|
+
...config.workers_dev === void 0 ? { preserveWorkersDev: true } : {},
|
|
957
|
+
...config.preview_urls === void 0 ? { preservePreviewUrls: true } : {},
|
|
958
|
+
settingsHash: nonVersionedSettingsHash(config),
|
|
959
|
+
workers_dev: workersDev,
|
|
960
|
+
preview_urls: typeof config.preview_urls === "boolean" ? config.preview_urls : workersDev,
|
|
961
|
+
routes,
|
|
962
|
+
crons: Array.isArray(triggers.crons) ? triggers.crons : [],
|
|
963
|
+
queueConsumers: Array.isArray(queues.consumers) ? queues.consumers : [],
|
|
964
|
+
workflows: Array.isArray(config.workflows) ? config.workflows : [],
|
|
965
|
+
...addresses === void 0 ? {} : { addresses }
|
|
966
|
+
};
|
|
967
|
+
}
|
|
968
|
+
/** A well-formed `addresses` array, or `undefined` when the field is absent or malformed. */
|
|
969
|
+
function readEmailAddresses(value) {
|
|
970
|
+
return Array.isArray(value) && value.every((entry) => typeof entry === "string") ? value : void 0;
|
|
971
|
+
}
|
|
972
|
+
function nonVersionedSettingsHash(config) {
|
|
973
|
+
const settings = Object.fromEntries([
|
|
974
|
+
"logpush",
|
|
975
|
+
"observability",
|
|
976
|
+
"tail_consumers",
|
|
977
|
+
"streaming_tail_consumers"
|
|
978
|
+
].filter((key) => config[key] !== void 0).map((key) => [key, config[key]]));
|
|
979
|
+
return createHash("sha256").update(stableJson(settings)).digest("hex");
|
|
980
|
+
}
|
|
981
|
+
function stableJson(value) {
|
|
982
|
+
if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
|
|
983
|
+
if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
|
|
984
|
+
return JSON.stringify(value);
|
|
985
|
+
}
|
|
986
|
+
function parseTriggerConfig(value) {
|
|
987
|
+
if (value === void 0) return null;
|
|
988
|
+
if (!isRecord(value)) return null;
|
|
989
|
+
if ([
|
|
990
|
+
"preserve",
|
|
991
|
+
"preserveRoutes",
|
|
992
|
+
"preserveWorkersDev",
|
|
993
|
+
"preservePreviewUrls"
|
|
994
|
+
].some((key) => value[key] !== void 0 && typeof value[key] !== "boolean") || value.settingsHash !== void 0 && typeof value.settingsHash !== "string" || typeof value.workers_dev !== "boolean" || typeof value.preview_urls !== "boolean" || !Array.isArray(value.routes) || !Array.isArray(value.crons) || !Array.isArray(value.queueConsumers) || !Array.isArray(value.workflows) || value.addresses !== void 0 && readEmailAddresses(value.addresses) === void 0) return null;
|
|
995
|
+
return {
|
|
996
|
+
...value.preserve === true ? { preserve: true } : {},
|
|
997
|
+
...value.preserveRoutes === true ? { preserveRoutes: true } : {},
|
|
998
|
+
...value.preserveWorkersDev === true ? { preserveWorkersDev: true } : {},
|
|
999
|
+
...value.preservePreviewUrls === true ? { preservePreviewUrls: true } : {},
|
|
1000
|
+
...typeof value.settingsHash === "string" ? { settingsHash: value.settingsHash } : {},
|
|
1001
|
+
workers_dev: value.workers_dev,
|
|
1002
|
+
preview_urls: value.preview_urls,
|
|
1003
|
+
routes: value.routes,
|
|
1004
|
+
crons: value.crons,
|
|
1005
|
+
queueConsumers: value.queueConsumers,
|
|
1006
|
+
workflows: value.workflows,
|
|
1007
|
+
...value.addresses === void 0 ? {} : { addresses: value.addresses }
|
|
1008
|
+
};
|
|
1009
|
+
}
|
|
1010
|
+
function triggerWranglerConfig(base, triggerConfig, manageRoutes = true) {
|
|
1011
|
+
const triggers = isRecord(base.triggers) ? { ...base.triggers } : {};
|
|
1012
|
+
const queues = isRecord(base.queues) ? { ...base.queues } : {};
|
|
1013
|
+
triggers.crons = [...triggerConfig.crons];
|
|
1014
|
+
queues.consumers = [...triggerConfig.queueConsumers];
|
|
1015
|
+
const config = {
|
|
1016
|
+
...base,
|
|
1017
|
+
triggers,
|
|
1018
|
+
queues,
|
|
1019
|
+
workflows: [...triggerConfig.workflows],
|
|
1020
|
+
workers_dev: triggerConfig.workers_dev,
|
|
1021
|
+
preview_urls: triggerConfig.preview_urls
|
|
1022
|
+
};
|
|
1023
|
+
if (manageRoutes && !triggerConfig.preserveRoutes) config.routes = [...triggerConfig.routes];
|
|
1024
|
+
if (triggerConfig.addresses !== void 0) config.addresses = [...triggerConfig.addresses];
|
|
1025
|
+
if (triggerConfig.preserveWorkersDev) delete config.workers_dev;
|
|
1026
|
+
if (triggerConfig.preservePreviewUrls) delete config.preview_urls;
|
|
1027
|
+
delete config.route;
|
|
1028
|
+
return config;
|
|
1029
|
+
}
|
|
1030
|
+
function resolvePreservedTraffic(desired, previous, workerExists) {
|
|
1031
|
+
if ((desired.preserveRoutes || desired.preserveWorkersDev || desired.preservePreviewUrls) && workerExists && !previous) return null;
|
|
1032
|
+
return {
|
|
1033
|
+
...desired.preserve ? { preserve: true } : {},
|
|
1034
|
+
...desired.settingsHash ? { settingsHash: desired.settingsHash } : {},
|
|
1035
|
+
workers_dev: desired.preserveWorkersDev ? previous?.workers_dev ?? desired.workers_dev : desired.workers_dev,
|
|
1036
|
+
preview_urls: desired.preservePreviewUrls ? previous?.preview_urls ?? desired.preview_urls : desired.preview_urls,
|
|
1037
|
+
routes: desired.preserveRoutes ? previous?.routes ?? desired.routes : desired.routes,
|
|
1038
|
+
crons: desired.crons,
|
|
1039
|
+
queueConsumers: desired.queueConsumers,
|
|
1040
|
+
workflows: desired.workflows,
|
|
1041
|
+
...desired.addresses === void 0 ? {} : { addresses: desired.addresses }
|
|
1042
|
+
};
|
|
1043
|
+
}
|
|
1044
|
+
function isRecord(value) {
|
|
1045
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1046
|
+
}
|
|
1047
|
+
/** Upload, probe, and atomically activate a verified Worker Version. */
|
|
1048
|
+
async function activateCloudflareVersion(options) {
|
|
1049
|
+
const candidateSecrets = Object.entries(options.candidateSecrets ?? {});
|
|
1050
|
+
if (candidateSecrets.length === 0) return activateCloudflareVersionWithSecretsFile(options);
|
|
1051
|
+
const directory = mkdtempSync(join(tmpdir(), "void-version-secrets-"));
|
|
1052
|
+
const path = join(directory, "secrets.json");
|
|
1053
|
+
try {
|
|
1054
|
+
writeFileSync(path, `${JSON.stringify(Object.fromEntries(candidateSecrets))}\n`, { mode: 384 });
|
|
1055
|
+
return await activateCloudflareVersionWithSecretsFile({
|
|
1056
|
+
...options,
|
|
1057
|
+
candidateSecretsFilePath: path
|
|
1058
|
+
});
|
|
1059
|
+
} finally {
|
|
1060
|
+
rmSync(directory, {
|
|
1061
|
+
recursive: true,
|
|
1062
|
+
force: true
|
|
1063
|
+
});
|
|
1064
|
+
}
|
|
1065
|
+
}
|
|
1066
|
+
async function activateCloudflareVersionWithSecretsFile(options) {
|
|
1067
|
+
const commonArgs = [...options.configPath ? ["-c", options.configPath] : [], "--env="];
|
|
1068
|
+
if (!options.verifyUploader()) return {
|
|
1069
|
+
ok: false,
|
|
1070
|
+
reason: "uploader-changed"
|
|
1071
|
+
};
|
|
1072
|
+
const previous = await readCurrentProductionVersion(options, commonArgs);
|
|
1073
|
+
if (previous.kind === "failed") return {
|
|
1074
|
+
ok: false,
|
|
1075
|
+
reason: "status-failed",
|
|
1076
|
+
status: previous.status
|
|
1077
|
+
};
|
|
1078
|
+
if (previous.kind === "split") return {
|
|
1079
|
+
ok: false,
|
|
1080
|
+
reason: "traffic-split-unsupported"
|
|
1081
|
+
};
|
|
1082
|
+
if (options.expectedPreviousVersionId !== void 0 && previous.versionId !== options.expectedPreviousVersionId) return {
|
|
1083
|
+
ok: false,
|
|
1084
|
+
reason: "deployment-changed",
|
|
1085
|
+
activeVersionId: previous.versionId,
|
|
1086
|
+
rollback: "not-needed"
|
|
1087
|
+
};
|
|
1088
|
+
if (!options.verifyUploader()) return {
|
|
1089
|
+
ok: false,
|
|
1090
|
+
reason: "uploader-changed"
|
|
1091
|
+
};
|
|
1092
|
+
const previousVersionId = previous.versionId;
|
|
1093
|
+
let inheritanceBaseline;
|
|
1094
|
+
if (options.expectedPreviousVersionId !== void 0 || options.verifyInheritedSecrets === true) {
|
|
1095
|
+
const recent = await readRecentCloudflareVersionIds(options, commonArgs);
|
|
1096
|
+
if (recent === null) return {
|
|
1097
|
+
ok: false,
|
|
1098
|
+
reason: "inheritance-source-unavailable"
|
|
1099
|
+
};
|
|
1100
|
+
const latestVersionId = recent.at(-1)?.id ?? null;
|
|
1101
|
+
if (options.expectedPreviousVersionId !== void 0) {
|
|
1102
|
+
const activeIndex = recent.findIndex((version) => version.id === previous.versionId);
|
|
1103
|
+
const inheritedChain = activeIndex < 0 ? [] : recent.slice(activeIndex);
|
|
1104
|
+
const inheritedCandidates = inheritedChain.slice(1).map((version) => version.id);
|
|
1105
|
+
if (!(inheritedChain.length > 0 && inheritedChain.every((version, index) => index === 0 || version.number === inheritedChain[index - 1].number + 1) && arraysEqual(inheritedCandidates, options.trustedInheritanceVersionIds ?? []))) return {
|
|
1106
|
+
ok: false,
|
|
1107
|
+
reason: "inheritance-source-changed",
|
|
1108
|
+
activeVersionId: recent.at(-1)?.id ?? null,
|
|
1109
|
+
rollback: "not-needed"
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
if (options.verifyInheritedSecrets === true && previous.versionId !== latestVersionId) {
|
|
1113
|
+
const replacesAllRemoteSecrets = await candidateReplacesAllRemoteSecrets(options, commonArgs, previous.versionId, latestVersionId);
|
|
1114
|
+
if (replacesAllRemoteSecrets === null) return {
|
|
1115
|
+
ok: false,
|
|
1116
|
+
reason: "inheritance-source-unavailable"
|
|
1117
|
+
};
|
|
1118
|
+
if (!replacesAllRemoteSecrets) return {
|
|
1119
|
+
ok: false,
|
|
1120
|
+
reason: "inheritance-source-changed",
|
|
1121
|
+
activeVersionId: latestVersionId,
|
|
1122
|
+
rollback: "not-needed"
|
|
1123
|
+
};
|
|
1124
|
+
}
|
|
1125
|
+
inheritanceBaseline = recent;
|
|
1126
|
+
}
|
|
1127
|
+
const desiredMetadata = parseCloudflareVersionMessage(options.message);
|
|
1128
|
+
let desiredTriggerConfig = desiredMetadata?.schema === 2 ? desiredMetadata.triggerConfig : options.configPath ? readCloudflareTriggerConfig(options.configPath) : triggerConfigFromWrangler({});
|
|
1129
|
+
let previousVersion = previousVersionId && desiredMetadata?.schema === 2 ? await readCloudflareVersionInspection(options, commonArgs, previousVersionId) : null;
|
|
1130
|
+
async function previousIsSecretOnlyDraft() {
|
|
1131
|
+
if (!previousVersionId) return false;
|
|
1132
|
+
previousVersion ??= await readCloudflareVersionInspection(options, commonArgs, previousVersionId);
|
|
1133
|
+
return previousVersion?.isSecretOnlyDraft === true;
|
|
1134
|
+
}
|
|
1135
|
+
const previousMetadata = desiredMetadata?.schema === 2 ? previousVersion?.metadata : null;
|
|
1136
|
+
const continuesOpaqueTriggerPreservation = !options.preserveTriggers && !options.previousTrafficConfig && previousMetadata?.schema === 2 && previousMetadata.triggerConfig.preserve === true && desiredTriggerConfig.settingsHash === previousMetadata.triggerConfig.settingsHash && (desiredTriggerConfig.preserveRoutes === true || desiredTriggerConfig.preserveWorkersDev === true || desiredTriggerConfig.preservePreviewUrls === true) && (desiredTriggerConfig.preserveRoutes === true || isDeepStrictEqual(desiredTriggerConfig.routes, previousMetadata.triggerConfig.routes)) && (desiredTriggerConfig.preserveWorkersDev === true || desiredTriggerConfig.workers_dev === previousMetadata.triggerConfig.workers_dev) && (desiredTriggerConfig.preservePreviewUrls === true || desiredTriggerConfig.preview_urls === previousMetadata.triggerConfig.preview_urls) && desiredTriggerConfig.crons.length === 0 && desiredTriggerConfig.queueConsumers.length === 0 && desiredTriggerConfig.workflows.length === 0;
|
|
1137
|
+
const previousTrafficConfig = options.previousTrafficConfig ?? (previousMetadata?.schema === 2 && previousMetadata.triggerConfig.preserve !== true ? {
|
|
1138
|
+
workers_dev: previousMetadata.triggerConfig.workers_dev,
|
|
1139
|
+
preview_urls: previousMetadata.triggerConfig.preview_urls,
|
|
1140
|
+
routes: previousMetadata.triggerConfig.routes
|
|
1141
|
+
} : void 0);
|
|
1142
|
+
if (continuesOpaqueTriggerPreservation) desiredTriggerConfig = {
|
|
1143
|
+
...desiredTriggerConfig,
|
|
1144
|
+
preserve: true
|
|
1145
|
+
};
|
|
1146
|
+
else if (!options.preserveTriggers && desiredMetadata?.schema === 2) {
|
|
1147
|
+
const resolved = resolvePreservedTraffic(desiredTriggerConfig, previousTrafficConfig, previous.versionId !== null && !await previousIsSecretOnlyDraft());
|
|
1148
|
+
if (!resolved) return {
|
|
1149
|
+
ok: false,
|
|
1150
|
+
reason: "traffic-preservation-unavailable",
|
|
1151
|
+
previousVersionId: previous.versionId,
|
|
1152
|
+
rollback: "not-needed"
|
|
1153
|
+
};
|
|
1154
|
+
desiredTriggerConfig = resolved;
|
|
1155
|
+
}
|
|
1156
|
+
const message = desiredMetadata?.schema === 2 ? encodeCloudflareVersionMessage(desiredMetadata.migrations, desiredTriggerConfig) : options.message;
|
|
1157
|
+
const versionedPreviousTriggerConfig = previousMetadata?.schema === 2 ? previousMetadata.triggerConfig : void 0;
|
|
1158
|
+
const versionedPreviousTriggerConfigWithTraffic = versionedPreviousTriggerConfig && previousTrafficConfig ? {
|
|
1159
|
+
...versionedPreviousTriggerConfig,
|
|
1160
|
+
...previousTrafficConfig
|
|
1161
|
+
} : versionedPreviousTriggerConfig;
|
|
1162
|
+
const previousTriggerConfig = triggerConfigWithQueueConsumerCleanup(versionedPreviousTriggerConfigWithTraffic, options.queueConsumerCleanup);
|
|
1163
|
+
const alias = `void-ready-${Date.now().toString(36)}`;
|
|
1164
|
+
const upload = await Promise.resolve(options.runWrangler([
|
|
1165
|
+
"versions",
|
|
1166
|
+
"upload",
|
|
1167
|
+
...commonArgs,
|
|
1168
|
+
"--name",
|
|
1169
|
+
options.workerName,
|
|
1170
|
+
"--message",
|
|
1171
|
+
message,
|
|
1172
|
+
...options.candidateSecretsFilePath ? ["--secrets-file", options.candidateSecretsFilePath] : [],
|
|
1173
|
+
...options.assetsDirectoryOverride ? ["--assets", options.assetsDirectoryOverride] : [],
|
|
1174
|
+
"--preview-alias",
|
|
1175
|
+
alias,
|
|
1176
|
+
"--strict",
|
|
1177
|
+
"--experimental-provision=false",
|
|
1178
|
+
"--experimental-auto-create=false"
|
|
1179
|
+
], { capture: true }));
|
|
1180
|
+
if (!isCleanCommandExit(upload)) {
|
|
1181
|
+
const output = `${upload.stdout}\n${upload.stderr}`;
|
|
1182
|
+
const cloudflareErrorCode = parseCloudflareErrorCode(output);
|
|
1183
|
+
const isBootstrapError = cloudflareErrorCode === DURABLE_MIGRATION_VERSION_ERROR || /cannot upload a new version of a worker that does not yet exist/i.test(output);
|
|
1184
|
+
if (!options.preserveTriggers && isBootstrapError && (previous.versionId === null || options.allowFirstDeploymentBootstrap === true || await previousIsSecretOnlyDraft())) return bootstrapFirstDeployment({
|
|
1185
|
+
...options,
|
|
1186
|
+
message
|
|
1187
|
+
}, commonArgs, previous.versionId);
|
|
1188
|
+
return {
|
|
1189
|
+
ok: false,
|
|
1190
|
+
reason: "upload-failed",
|
|
1191
|
+
status: upload.status,
|
|
1192
|
+
...commandDiagnosticFields(upload),
|
|
1193
|
+
...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
|
|
1194
|
+
...isContainersUnavailable(output) ? { containersUnavailable: true } : {}
|
|
1195
|
+
};
|
|
1196
|
+
}
|
|
1197
|
+
const output = `${upload.stdout}\n${upload.stderr}`;
|
|
1198
|
+
const versionId = output.match(VERSION_ID_RE)?.[1];
|
|
1199
|
+
if (!versionId) return {
|
|
1200
|
+
ok: false,
|
|
1201
|
+
reason: "upload-output-invalid"
|
|
1202
|
+
};
|
|
1203
|
+
if (inheritanceBaseline) {
|
|
1204
|
+
const recent = await readRecentCloudflareVersionIds(options, commonArgs);
|
|
1205
|
+
const previousLatest = inheritanceBaseline.at(-1);
|
|
1206
|
+
const expected = [...inheritanceBaseline.map((version) => version.id), versionId].slice(-10);
|
|
1207
|
+
if (recent === null) return {
|
|
1208
|
+
ok: false,
|
|
1209
|
+
reason: "inheritance-source-unavailable",
|
|
1210
|
+
versionId,
|
|
1211
|
+
previousVersionId: previous.versionId,
|
|
1212
|
+
rollback: "not-needed"
|
|
1213
|
+
};
|
|
1214
|
+
const uploadedVersion = recent.at(-1);
|
|
1215
|
+
if (!uploadedVersion || uploadedVersion.id !== versionId || uploadedVersion.number !== (previousLatest?.number ?? 0) + 1 || !arraysEqual(recent.map((version) => version.id), expected)) return {
|
|
1216
|
+
ok: false,
|
|
1217
|
+
reason: "inheritance-source-changed",
|
|
1218
|
+
versionId,
|
|
1219
|
+
previousVersionId: previous.versionId,
|
|
1220
|
+
activeVersionId: uploadedVersion?.id ?? null,
|
|
1221
|
+
rollback: "not-needed"
|
|
1222
|
+
};
|
|
1223
|
+
}
|
|
1224
|
+
await options.validateUploadedVersion?.(versionId);
|
|
1225
|
+
if (options.recordVerifiedCandidate) {
|
|
1226
|
+
if (!options.validateUploadedVersion) throw new Error("A Worker candidate cannot be recorded without validating its upload.");
|
|
1227
|
+
await options.recordVerifiedCandidate(versionId);
|
|
1228
|
+
}
|
|
1229
|
+
const reportedPreviewUrl = output.match(PREVIEW_URL_RE)?.[1];
|
|
1230
|
+
let authenticatedWorkersDevSubdomain = reportedPreviewUrl ? extractWorkersDevSubdomain(reportedPreviewUrl) : void 0;
|
|
1231
|
+
let readinessUrl = reportedPreviewUrl;
|
|
1232
|
+
let readinessUsesCustomRoute = false;
|
|
1233
|
+
let stagedVersionOverride = false;
|
|
1234
|
+
if (!readinessUrl) {
|
|
1235
|
+
if (!options.preserveTriggers && (previous.versionId === null || options.allowFirstDeploymentBootstrap === true || await previousIsSecretOnlyDraft())) return bootstrapFirstDeployment({
|
|
1236
|
+
...options,
|
|
1237
|
+
message
|
|
1238
|
+
}, commonArgs, previous.versionId);
|
|
1239
|
+
readinessUrl = desiredTriggerConfig.workers_dev ? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain) : customRouteReadinessUrl(desiredTriggerConfig.routes);
|
|
1240
|
+
readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev && readinessUrl !== void 0;
|
|
1241
|
+
if (!readinessUrl) return {
|
|
1242
|
+
ok: false,
|
|
1243
|
+
reason: "preview-unavailable",
|
|
1244
|
+
versionId,
|
|
1245
|
+
previousVersionId: previous.versionId
|
|
1246
|
+
};
|
|
1247
|
+
const fallbackSubdomain = extractWorkersDevSubdomain(readinessUrl);
|
|
1248
|
+
if (fallbackSubdomain && fallbackSubdomain === options.readinessHeadersWorkersDevSubdomain) authenticatedWorkersDevSubdomain = fallbackSubdomain;
|
|
1249
|
+
if (!options.verifyUploader()) return {
|
|
1250
|
+
ok: false,
|
|
1251
|
+
reason: "uploader-changed",
|
|
1252
|
+
versionId,
|
|
1253
|
+
previousVersionId: previous.versionId,
|
|
1254
|
+
rollback: "not-needed"
|
|
1255
|
+
};
|
|
1256
|
+
const stagingOwnerFailure = await verifyReadinessStagingOwner(options, commonArgs, previous.versionId, versionId);
|
|
1257
|
+
if (stagingOwnerFailure) return stagingOwnerFailure;
|
|
1258
|
+
const staged = await deployVersionPercentages(options, commonArgs, [`${previous.versionId}@100`, `${versionId}@0`], message);
|
|
1259
|
+
if (!isCleanCommandExit(staged)) return {
|
|
1260
|
+
ok: false,
|
|
1261
|
+
reason: "readiness-staging-failed",
|
|
1262
|
+
status: staged.status,
|
|
1263
|
+
versionId,
|
|
1264
|
+
previousVersionId: previous.versionId,
|
|
1265
|
+
rollback: "not-needed"
|
|
1266
|
+
};
|
|
1267
|
+
stagedVersionOverride = true;
|
|
1268
|
+
}
|
|
1269
|
+
let ready = await probeReadiness(`${readinessUrl.replace(/\/$/, "")}/__void/ready`, options.workerName, readinessUsesCustomRoute ? void 0 : options.readinessHeaders, authenticatedWorkersDevSubdomain, stagedVersionOverride ? versionId : void 0, options.fetch ?? globalThis.fetch, options.sleep ?? defaultSleep, options.readinessAttempts ?? 6);
|
|
1270
|
+
if (ready === "access-denied" && !stagedVersionOverride && previous.versionId !== null) {
|
|
1271
|
+
const stableReadinessUrl = desiredTriggerConfig.workers_dev ? workersDevReadinessUrl(options.workerName, extractWorkersDevSubdomain(readinessUrl) ?? options.workersDevSubdomain) : customRouteReadinessUrl(desiredTriggerConfig.routes);
|
|
1272
|
+
if (stableReadinessUrl && stableReadinessUrl !== readinessUrl) {
|
|
1273
|
+
if (!options.verifyUploader()) return {
|
|
1274
|
+
ok: false,
|
|
1275
|
+
reason: "uploader-changed",
|
|
1276
|
+
versionId,
|
|
1277
|
+
previousVersionId: previous.versionId,
|
|
1278
|
+
rollback: "not-needed"
|
|
1279
|
+
};
|
|
1280
|
+
const stagingOwnerFailure = await verifyReadinessStagingOwner(options, commonArgs, previous.versionId, versionId);
|
|
1281
|
+
if (stagingOwnerFailure) return stagingOwnerFailure;
|
|
1282
|
+
const staged = await deployVersionPercentages(options, commonArgs, [`${previous.versionId}@100`, `${versionId}@0`], message);
|
|
1283
|
+
if (!isCleanCommandExit(staged)) return {
|
|
1284
|
+
ok: false,
|
|
1285
|
+
reason: "readiness-staging-failed",
|
|
1286
|
+
status: staged.status,
|
|
1287
|
+
versionId,
|
|
1288
|
+
previousVersionId: previous.versionId,
|
|
1289
|
+
rollback: "not-needed"
|
|
1290
|
+
};
|
|
1291
|
+
readinessUrl = stableReadinessUrl;
|
|
1292
|
+
authenticatedWorkersDevSubdomain = extractWorkersDevSubdomain(stableReadinessUrl);
|
|
1293
|
+
readinessUsesCustomRoute = !desiredTriggerConfig.workers_dev;
|
|
1294
|
+
stagedVersionOverride = true;
|
|
1295
|
+
ready = await probeReadiness(`${readinessUrl.replace(/\/$/, "")}/__void/ready`, options.workerName, readinessUsesCustomRoute ? void 0 : options.readinessHeaders, authenticatedWorkersDevSubdomain, versionId, options.fetch ?? globalThis.fetch, options.sleep ?? defaultSleep, options.readinessAttempts ?? 6);
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
if (ready !== "ready") return {
|
|
1299
|
+
ok: false,
|
|
1300
|
+
reason: ready === "access-denied" ? "readiness-access-denied" : "readiness-failed",
|
|
1301
|
+
versionId,
|
|
1302
|
+
previousVersionId: previous.versionId,
|
|
1303
|
+
rollback: stagedVersionOverride ? await restorePreviousVersion(options, commonArgs, previous.versionId, "readiness failed", [stagedVersionPercentages(previous.versionId, versionId)]) : "not-needed"
|
|
1304
|
+
};
|
|
1305
|
+
if (!options.verifyUploader()) return {
|
|
1306
|
+
ok: false,
|
|
1307
|
+
reason: "uploader-changed",
|
|
1308
|
+
versionId,
|
|
1309
|
+
previousVersionId: previous.versionId,
|
|
1310
|
+
rollback: stagedVersionOverride ? await restorePreviousVersion(options, commonArgs, previous.versionId, "uploader verification failed", [stagedVersionPercentages(previous.versionId, versionId)]) : "not-needed"
|
|
1311
|
+
};
|
|
1312
|
+
const ownerBeforeActivation = await readCurrentProductionVersion(options, commonArgs);
|
|
1313
|
+
if (ownerBeforeActivation.kind === "failed") return {
|
|
1314
|
+
ok: false,
|
|
1315
|
+
reason: "status-failed",
|
|
1316
|
+
status: ownerBeforeActivation.status,
|
|
1317
|
+
versionId,
|
|
1318
|
+
previousVersionId: previous.versionId
|
|
1319
|
+
};
|
|
1320
|
+
if (ownerBeforeActivation.kind === "split") return {
|
|
1321
|
+
ok: false,
|
|
1322
|
+
reason: "traffic-split-unsupported",
|
|
1323
|
+
versionId,
|
|
1324
|
+
previousVersionId: previous.versionId
|
|
1325
|
+
};
|
|
1326
|
+
const expectedTrafficBeforeActivation = stagedVersionOverride ? stagedVersionPercentages(previous.versionId, versionId) : previous.versionId ? [{
|
|
1327
|
+
versionId: previous.versionId,
|
|
1328
|
+
percentage: 100
|
|
1329
|
+
}] : [];
|
|
1330
|
+
if (ownerBeforeActivation.versionId !== previous.versionId || !productionVersionPercentagesEqual(ownerBeforeActivation.versions, expectedTrafficBeforeActivation)) return {
|
|
1331
|
+
ok: false,
|
|
1332
|
+
reason: "deployment-changed",
|
|
1333
|
+
versionId,
|
|
1334
|
+
previousVersionId: previous.versionId,
|
|
1335
|
+
activeVersionId: ownerBeforeActivation.versionId,
|
|
1336
|
+
rollback: "not-needed",
|
|
1337
|
+
triggerRepair: "succeeded"
|
|
1338
|
+
};
|
|
1339
|
+
const activate = await deployVersionPercentages(options, commonArgs, [`${versionId}@100`], message);
|
|
1340
|
+
if (!isCleanCommandExit(activate)) return {
|
|
1341
|
+
ok: false,
|
|
1342
|
+
reason: "activation-failed",
|
|
1343
|
+
status: activate.status,
|
|
1344
|
+
versionId,
|
|
1345
|
+
previousVersionId: previous.versionId,
|
|
1346
|
+
rollback: stagedVersionOverride ? await restorePreviousVersion(options, commonArgs, previous.versionId, "version activation failed", [stagedVersionPercentages(previous.versionId, versionId), [{
|
|
1347
|
+
versionId,
|
|
1348
|
+
percentage: 100
|
|
1349
|
+
}]]) : "not-needed"
|
|
1350
|
+
};
|
|
1351
|
+
if (!options.verifyUploader()) return {
|
|
1352
|
+
ok: false,
|
|
1353
|
+
reason: "uploader-changed",
|
|
1354
|
+
versionId,
|
|
1355
|
+
previousVersionId: previous.versionId,
|
|
1356
|
+
rollback: previous.versionId ? "unsafe" : "unavailable"
|
|
1357
|
+
};
|
|
1358
|
+
if (options.preserveTriggers) {
|
|
1359
|
+
const owner = await readCurrentProductionVersion(options, commonArgs);
|
|
1360
|
+
if (owner.kind !== "ok" || owner.versionId !== versionId) return {
|
|
1361
|
+
ok: false,
|
|
1362
|
+
reason: "deployment-changed",
|
|
1363
|
+
versionId,
|
|
1364
|
+
previousVersionId: previous.versionId,
|
|
1365
|
+
...owner.kind === "ok" ? { activeVersionId: owner.versionId } : {},
|
|
1366
|
+
rollback: "not-needed"
|
|
1367
|
+
};
|
|
1368
|
+
return {
|
|
1369
|
+
ok: true,
|
|
1370
|
+
versionId,
|
|
1371
|
+
previousVersionId: previous.versionId,
|
|
1372
|
+
previewUrl: publishedCloudflareUrl(options.workerName, desiredTriggerConfig, readinessUrl),
|
|
1373
|
+
preservedTriggers: true,
|
|
1374
|
+
...workersDevSubdomainResult(readinessUrl)
|
|
1375
|
+
};
|
|
1376
|
+
}
|
|
1377
|
+
const knownTriggerConfigs = { [versionId]: desiredTriggerConfig };
|
|
1378
|
+
if (previous.versionId && previousTriggerConfig) knownTriggerConfigs[previous.versionId] = previousTriggerConfig;
|
|
1379
|
+
const triggers = await stabilizeCloudflareVersionTriggers({
|
|
1380
|
+
runWrangler: options.runWrangler,
|
|
1381
|
+
workerName: options.workerName,
|
|
1382
|
+
configPath: options.configPath,
|
|
1383
|
+
targetVersionId: versionId,
|
|
1384
|
+
targetTriggerConfig: desiredTriggerConfig,
|
|
1385
|
+
previousTriggerConfig,
|
|
1386
|
+
knownTriggerConfigs,
|
|
1387
|
+
publishEmptyCustomDomains: options.publishEmptyCustomDomains
|
|
1388
|
+
});
|
|
1389
|
+
if (!triggers.ok) {
|
|
1390
|
+
if (triggers.activeVersionId && triggers.activeVersionId !== versionId) return {
|
|
1391
|
+
ok: false,
|
|
1392
|
+
reason: "deployment-changed",
|
|
1393
|
+
status: triggers.status,
|
|
1394
|
+
versionId,
|
|
1395
|
+
previousVersionId: previous.versionId,
|
|
1396
|
+
activeVersionId: triggers.activeVersionId,
|
|
1397
|
+
rollback: "not-needed",
|
|
1398
|
+
triggerRepair: triggers.reason === "metadata-unavailable" ? "unavailable" : "failed"
|
|
1399
|
+
};
|
|
1400
|
+
if (!triggers.activeVersionId) return {
|
|
1401
|
+
ok: false,
|
|
1402
|
+
reason: "triggers-failed",
|
|
1403
|
+
status: triggers.status,
|
|
1404
|
+
versionId,
|
|
1405
|
+
previousVersionId: previous.versionId,
|
|
1406
|
+
rollback: "unsafe"
|
|
1407
|
+
};
|
|
1408
|
+
const ownerBeforeCompensation = await readCurrentProductionVersion(options, commonArgs);
|
|
1409
|
+
if (ownerBeforeCompensation.kind !== "ok" || ownerBeforeCompensation.versionId === null) return {
|
|
1410
|
+
ok: false,
|
|
1411
|
+
reason: "triggers-failed",
|
|
1412
|
+
status: triggers.status,
|
|
1413
|
+
versionId,
|
|
1414
|
+
previousVersionId: previous.versionId,
|
|
1415
|
+
rollback: "unsafe"
|
|
1416
|
+
};
|
|
1417
|
+
if (ownerBeforeCompensation.versionId !== versionId) {
|
|
1418
|
+
const repair = await stabilizeCloudflareVersionTriggers({
|
|
1419
|
+
runWrangler: options.runWrangler,
|
|
1420
|
+
workerName: options.workerName,
|
|
1421
|
+
configPath: options.configPath,
|
|
1422
|
+
targetVersionId: versionId,
|
|
1423
|
+
targetTriggerConfig: desiredTriggerConfig,
|
|
1424
|
+
previousTriggerConfig,
|
|
1425
|
+
knownTriggerConfigs,
|
|
1426
|
+
publishEmptyCustomDomains: options.publishEmptyCustomDomains
|
|
1427
|
+
});
|
|
1428
|
+
return {
|
|
1429
|
+
ok: false,
|
|
1430
|
+
reason: "deployment-changed",
|
|
1431
|
+
status: triggers.status,
|
|
1432
|
+
versionId,
|
|
1433
|
+
previousVersionId: previous.versionId,
|
|
1434
|
+
activeVersionId: repair.activeVersionId ?? ownerBeforeCompensation.versionId,
|
|
1435
|
+
rollback: "not-needed",
|
|
1436
|
+
triggerRepair: repair.ok ? "succeeded" : repair.reason === "metadata-unavailable" ? "unavailable" : "failed"
|
|
1437
|
+
};
|
|
1438
|
+
}
|
|
1439
|
+
const rollback = await restorePreviousVersionAndTriggers(options, commonArgs, previous.versionId, "trigger activation failed", desiredTriggerConfig, versionId, versionedPreviousTriggerConfigWithTraffic);
|
|
1440
|
+
return {
|
|
1441
|
+
ok: false,
|
|
1442
|
+
reason: "triggers-failed",
|
|
1443
|
+
status: triggers.status,
|
|
1444
|
+
versionId,
|
|
1445
|
+
previousVersionId: previous.versionId,
|
|
1446
|
+
rollback
|
|
1447
|
+
};
|
|
1448
|
+
}
|
|
1449
|
+
if (!triggers.targetOwnsTraffic) return {
|
|
1450
|
+
ok: false,
|
|
1451
|
+
reason: "deployment-changed",
|
|
1452
|
+
versionId,
|
|
1453
|
+
previousVersionId: previous.versionId,
|
|
1454
|
+
activeVersionId: triggers.activeVersionId,
|
|
1455
|
+
rollback: "not-needed",
|
|
1456
|
+
triggerRepair: "succeeded"
|
|
1457
|
+
};
|
|
1458
|
+
return {
|
|
1459
|
+
ok: true,
|
|
1460
|
+
versionId,
|
|
1461
|
+
previewUrl: publishedCloudflareUrl(options.workerName, desiredTriggerConfig, readinessUrl),
|
|
1462
|
+
previousVersionId: previous.versionId,
|
|
1463
|
+
...desiredTriggerConfig.preserve ? { preservedTriggers: true } : {},
|
|
1464
|
+
...!desiredTriggerConfig.preserve ? { trafficConfig: {
|
|
1465
|
+
workers_dev: desiredTriggerConfig.workers_dev,
|
|
1466
|
+
preview_urls: desiredTriggerConfig.preview_urls,
|
|
1467
|
+
routes: desiredTriggerConfig.routes
|
|
1468
|
+
} } : {},
|
|
1469
|
+
...workersDevSubdomainResult(readinessUrl)
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
async function restorePreviousVersionAndTriggers(options, commonArgs, previousVersionId, cause, failedTriggerConfig, failedVersionId, capturedTriggerConfig) {
|
|
1473
|
+
const traffic = await restorePreviousVersion(options, commonArgs, previousVersionId, cause, [[{
|
|
1474
|
+
versionId: failedVersionId,
|
|
1475
|
+
percentage: 100
|
|
1476
|
+
}]]);
|
|
1477
|
+
if (traffic !== "succeeded" || !previousVersionId) return traffic;
|
|
1478
|
+
if (!options.verifyUploader()) return "unsafe";
|
|
1479
|
+
let targetTriggerConfig = capturedTriggerConfig;
|
|
1480
|
+
if (!targetTriggerConfig) {
|
|
1481
|
+
const metadata = await readCloudflareVersionMetadata(options, commonArgs, previousVersionId);
|
|
1482
|
+
if (metadata?.schema !== 2) return "unavailable";
|
|
1483
|
+
targetTriggerConfig = metadata.triggerConfig;
|
|
1484
|
+
}
|
|
1485
|
+
if (targetTriggerConfig.preserve) return "unavailable";
|
|
1486
|
+
if (!options.verifyUploader()) return "unsafe";
|
|
1487
|
+
const triggers = await stabilizeCloudflareVersionTriggers({
|
|
1488
|
+
runWrangler: options.runWrangler,
|
|
1489
|
+
workerName: options.workerName,
|
|
1490
|
+
configPath: options.configPath,
|
|
1491
|
+
targetVersionId: previousVersionId,
|
|
1492
|
+
targetTriggerConfig,
|
|
1493
|
+
previousTriggerConfig: failedTriggerConfig,
|
|
1494
|
+
knownTriggerConfigs: { [previousVersionId]: targetTriggerConfig },
|
|
1495
|
+
publishEmptyCustomDomains: options.publishEmptyCustomDomains
|
|
1496
|
+
});
|
|
1497
|
+
return triggers.ok && triggers.targetOwnsTraffic ? "succeeded" : "failed";
|
|
1498
|
+
}
|
|
1499
|
+
async function readCloudflareVersionMetadata(options, commonArgs, versionId) {
|
|
1500
|
+
return (await readCloudflareVersionInspection(options, commonArgs, versionId))?.metadata ?? null;
|
|
1501
|
+
}
|
|
1502
|
+
async function readCloudflareVersionInspection(options, commonArgs, versionId) {
|
|
1503
|
+
const result = await Promise.resolve(options.runWrangler([
|
|
1504
|
+
"versions",
|
|
1505
|
+
"list",
|
|
1506
|
+
...commonArgs,
|
|
1507
|
+
"--name",
|
|
1508
|
+
options.workerName,
|
|
1509
|
+
"--json"
|
|
1510
|
+
], { capture: true }));
|
|
1511
|
+
if (!isCleanCommandExit(result)) return null;
|
|
1512
|
+
try {
|
|
1513
|
+
const versions = JSON.parse(result.stdout);
|
|
1514
|
+
if (!Array.isArray(versions)) return null;
|
|
1515
|
+
for (const entry of versions) {
|
|
1516
|
+
if (!isRecord(entry) || entry.id !== versionId) continue;
|
|
1517
|
+
const message = (isRecord(entry.annotations) ? entry.annotations : void 0)?.["workers/message"];
|
|
1518
|
+
return {
|
|
1519
|
+
metadata: typeof message === "string" ? parseCloudflareVersionMessage(message) : null,
|
|
1520
|
+
isSecretOnlyDraft: isWranglerSecretOnlyDraft(entry)
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
} catch {
|
|
1524
|
+
return null;
|
|
1525
|
+
}
|
|
1526
|
+
return null;
|
|
1527
|
+
}
|
|
1528
|
+
async function readRecentCloudflareVersionIds(options, commonArgs) {
|
|
1529
|
+
const result = await Promise.resolve(options.runWrangler([
|
|
1530
|
+
"versions",
|
|
1531
|
+
"list",
|
|
1532
|
+
...commonArgs,
|
|
1533
|
+
"--name",
|
|
1534
|
+
options.workerName,
|
|
1535
|
+
"--json"
|
|
1536
|
+
], { capture: true }));
|
|
1537
|
+
if (!isCleanCommandExit(result)) {
|
|
1538
|
+
if (isMissingWorkerOutput(`${result.stdout}\n${result.stderr}`)) return [];
|
|
1539
|
+
return null;
|
|
1540
|
+
}
|
|
1541
|
+
try {
|
|
1542
|
+
const versions = JSON.parse(result.stdout);
|
|
1543
|
+
if (!Array.isArray(versions) || !versions.every((version) => isRecord(version) && typeof version.id === "string" && typeof version.number === "number" && Number.isSafeInteger(version.number))) return null;
|
|
1544
|
+
return versions.map((version) => ({
|
|
1545
|
+
id: version.id,
|
|
1546
|
+
number: version.number
|
|
1547
|
+
}));
|
|
1548
|
+
} catch {
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
}
|
|
1552
|
+
function arraysEqual(left, right) {
|
|
1553
|
+
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
1554
|
+
}
|
|
1555
|
+
async function candidateReplacesAllRemoteSecrets(options, commonArgs, activeVersionId, latestVersionId) {
|
|
1556
|
+
const versionIds = [...new Set([activeVersionId, latestVersionId].filter(Boolean))];
|
|
1557
|
+
const secretBindings = [];
|
|
1558
|
+
for (const versionId of versionIds) {
|
|
1559
|
+
const bindings = await readCloudflareVersionSecretBindings(options, commonArgs, versionId);
|
|
1560
|
+
if (bindings === null) return null;
|
|
1561
|
+
secretBindings.push(bindings);
|
|
1562
|
+
}
|
|
1563
|
+
return secretBindings.every((bindings) => bindings.every((binding) => binding.type === "secret_text" && Object.hasOwn(options.candidateSecrets ?? {}, binding.name)));
|
|
1564
|
+
}
|
|
1565
|
+
async function readCloudflareVersionSecretBindings(options, commonArgs, versionId) {
|
|
1566
|
+
const result = await Promise.resolve(options.runWrangler([
|
|
1567
|
+
"versions",
|
|
1568
|
+
"view",
|
|
1569
|
+
versionId,
|
|
1570
|
+
...commonArgs,
|
|
1571
|
+
"--name",
|
|
1572
|
+
options.workerName,
|
|
1573
|
+
"--json"
|
|
1574
|
+
], { capture: true }));
|
|
1575
|
+
if (!isCleanCommandExit(result)) return null;
|
|
1576
|
+
try {
|
|
1577
|
+
const version = JSON.parse(result.stdout);
|
|
1578
|
+
if (!isRecord(version) || version.id !== versionId || !isRecord(version.resources)) return null;
|
|
1579
|
+
const bindings = version.resources.bindings;
|
|
1580
|
+
if (!Array.isArray(bindings) || !bindings.every(isRecord)) return null;
|
|
1581
|
+
const secrets = [];
|
|
1582
|
+
for (const binding of bindings) {
|
|
1583
|
+
if (binding.type !== "secret_text" && binding.type !== "secret_key") continue;
|
|
1584
|
+
if (typeof binding.name !== "string") return null;
|
|
1585
|
+
secrets.push({
|
|
1586
|
+
name: binding.name,
|
|
1587
|
+
type: binding.type
|
|
1588
|
+
});
|
|
1589
|
+
}
|
|
1590
|
+
return secrets;
|
|
1591
|
+
} catch {
|
|
1592
|
+
return null;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
/**
|
|
1596
|
+
* `wrangler secret put` creates a fetch-only draft before attaching a first secret to a Worker.
|
|
1597
|
+
* Accept that draft as first-deployment proof only when its complete deployable shape contains no
|
|
1598
|
+
* application code configuration or non-secret resource that an ordinary deploy could own.
|
|
1599
|
+
*/
|
|
1600
|
+
function isWranglerSecretOnlyDraft(entry) {
|
|
1601
|
+
if (!isRecord(entry.annotations)) return false;
|
|
1602
|
+
const annotationKeys = Object.keys(entry.annotations);
|
|
1603
|
+
if (entry.annotations["workers/triggered_by"] !== "secret" || annotationKeys.some((key) => key !== "workers/triggered_by")) return false;
|
|
1604
|
+
if (!isRecord(entry.resources)) return false;
|
|
1605
|
+
const { bindings, script, script_runtime: scriptRuntime } = entry.resources;
|
|
1606
|
+
if (!Array.isArray(bindings) || bindings.length === 0 || !bindings.every((binding) => isRecord(binding) && typeof binding.name === "string" && binding.name.length > 0 && binding.type === "secret_text" && Object.keys(binding).every((key) => key === "name" || key === "type"))) return false;
|
|
1607
|
+
if (!isRecord(script) || !Array.isArray(script.handlers) || script.handlers.length !== 1 || script.handlers[0] !== "fetch" || script.placement_mode !== void 0) return false;
|
|
1608
|
+
if (!isRecord(scriptRuntime)) return false;
|
|
1609
|
+
const runtimeKeys = Object.keys(scriptRuntime);
|
|
1610
|
+
return scriptRuntime.usage_model === "standard" && isRecord(scriptRuntime.limits) && Object.keys(scriptRuntime.limits).length === 0 && runtimeKeys.every((key) => key === "usage_model" || key === "limits");
|
|
1611
|
+
}
|
|
1612
|
+
/**
|
|
1613
|
+
* The Versions API cannot create a Worker or its initial Durable Object migration history, and
|
|
1614
|
+
* some accounts do not expose a version preview until their first ordinary deployment. A
|
|
1615
|
+
* brand-new Worker can therefore need one ordinary deploy before later releases use preview-first
|
|
1616
|
+
* version activation. This fallback requires prior proof that no application had been deployed.
|
|
1617
|
+
*/
|
|
1618
|
+
async function bootstrapFirstDeployment(options, commonArgs, previousVersionId) {
|
|
1619
|
+
if (!options.verifyUploader()) return {
|
|
1620
|
+
ok: false,
|
|
1621
|
+
reason: "uploader-changed"
|
|
1622
|
+
};
|
|
1623
|
+
const bootstrap = await Promise.resolve(options.runWrangler([
|
|
1624
|
+
"deploy",
|
|
1625
|
+
...commonArgs,
|
|
1626
|
+
"--name",
|
|
1627
|
+
options.workerName,
|
|
1628
|
+
"--message",
|
|
1629
|
+
options.message,
|
|
1630
|
+
...options.candidateSecretsFilePath ? ["--secrets-file", options.candidateSecretsFilePath] : [],
|
|
1631
|
+
...options.assetsDirectoryOverride ? ["--assets", options.assetsDirectoryOverride] : [],
|
|
1632
|
+
"--strict",
|
|
1633
|
+
"--experimental-provision=false",
|
|
1634
|
+
"--experimental-auto-create=false",
|
|
1635
|
+
"--autoconfig=false"
|
|
1636
|
+
], { capture: true }));
|
|
1637
|
+
const output = `${bootstrap.stdout}\n${bootstrap.stderr}`;
|
|
1638
|
+
if (!isCleanCommandExit(bootstrap)) {
|
|
1639
|
+
const cloudflareErrorCode = parseCloudflareErrorCode(output);
|
|
1640
|
+
return {
|
|
1641
|
+
ok: false,
|
|
1642
|
+
reason: "bootstrap-failed",
|
|
1643
|
+
status: bootstrap.status,
|
|
1644
|
+
...commandDiagnosticFields(bootstrap),
|
|
1645
|
+
...cloudflareErrorCode === void 0 ? {} : { cloudflareErrorCode },
|
|
1646
|
+
...isContainersUnavailable(output) ? { containersUnavailable: true } : {}
|
|
1647
|
+
};
|
|
1648
|
+
}
|
|
1649
|
+
const versionId = output.match(VERSION_ID_RE)?.[1];
|
|
1650
|
+
const desiredMetadata = parseCloudflareVersionMessage(options.message);
|
|
1651
|
+
const desiredTriggerConfig = desiredMetadata?.schema === 2 ? desiredMetadata.triggerConfig : options.configPath ? readCloudflareTriggerConfig(options.configPath) : triggerConfigFromWrangler({});
|
|
1652
|
+
const reportedUrl = output.match(PREVIEW_URL_RE)?.[1] ?? output.match(WORKERS_DEV_URL_RE)?.[1];
|
|
1653
|
+
const reportedOrConfiguredUrl = reportedUrl ?? (desiredTriggerConfig.workers_dev ? workersDevReadinessUrl(options.workerName, options.workersDevSubdomain) : customRouteReadinessUrl(desiredTriggerConfig.routes));
|
|
1654
|
+
if (!versionId || !reportedOrConfiguredUrl) return {
|
|
1655
|
+
ok: false,
|
|
1656
|
+
reason: "bootstrap-output-invalid",
|
|
1657
|
+
previousVersionId,
|
|
1658
|
+
...versionId ? { versionId } : {}
|
|
1659
|
+
};
|
|
1660
|
+
const previewUrl = publishedCloudflareUrl(options.workerName, desiredTriggerConfig, reportedOrConfiguredUrl);
|
|
1661
|
+
const ready = await probeReadiness(`${previewUrl.replace(/\/$/, "")}/__void/ready`, options.workerName, reportedUrl || desiredTriggerConfig.workers_dev ? options.readinessHeaders : void 0, extractWorkersDevSubdomain(previewUrl), void 0, options.fetch ?? globalThis.fetch, options.sleep ?? defaultSleep, options.readinessAttempts ?? 30, 6e4);
|
|
1662
|
+
if (ready !== "ready") return {
|
|
1663
|
+
ok: false,
|
|
1664
|
+
reason: ready === "access-denied" ? "bootstrap-readiness-access-denied" : "bootstrap-readiness-failed",
|
|
1665
|
+
versionId,
|
|
1666
|
+
previousVersionId,
|
|
1667
|
+
rollback: "unavailable"
|
|
1668
|
+
};
|
|
1669
|
+
return {
|
|
1670
|
+
ok: true,
|
|
1671
|
+
versionId,
|
|
1672
|
+
previewUrl,
|
|
1673
|
+
previousVersionId,
|
|
1674
|
+
...workersDevSubdomainResult(previewUrl)
|
|
1675
|
+
};
|
|
1676
|
+
}
|
|
1677
|
+
function parseCloudflareErrorCode(output) {
|
|
1678
|
+
const value = output.match(CLOUDFLARE_ERROR_CODE_RE)?.[1];
|
|
1679
|
+
return value === void 0 ? void 0 : Number(value);
|
|
1680
|
+
}
|
|
1681
|
+
function commandDiagnosticFields(result) {
|
|
1682
|
+
const diagnostic = cloudflareCommandDiagnostic(result);
|
|
1683
|
+
return diagnostic ? { diagnostic } : {};
|
|
1684
|
+
}
|
|
1685
|
+
function isMissingWorkerOutput(output) {
|
|
1686
|
+
return parseCloudflareErrorCode(output) === 10007 || /no deployments|\bworker\b.*\b(?:does not exist|not found)\b/i.test(output);
|
|
1687
|
+
}
|
|
1688
|
+
/** Recognize the Containers account-entitlement failure Wrangler exposes during upload. */
|
|
1689
|
+
function isContainersUnavailable(output) {
|
|
1690
|
+
return /\/containers\/me(?:\b|\?)/i.test(output) && /\b403\b/.test(output) || /Cloudflare Containers[^\n]*(?:Workers Paid|not (?:enabled|available)|subscription)/i.test(output);
|
|
1691
|
+
}
|
|
1692
|
+
async function readCurrentProductionVersion(options, commonArgs) {
|
|
1693
|
+
const result = await Promise.resolve(options.runWrangler([
|
|
1694
|
+
"deployments",
|
|
1695
|
+
"status",
|
|
1696
|
+
...commonArgs,
|
|
1697
|
+
"--name",
|
|
1698
|
+
options.workerName,
|
|
1699
|
+
"--json"
|
|
1700
|
+
], { capture: true }));
|
|
1701
|
+
if (!isCleanCommandExit(result)) {
|
|
1702
|
+
if (isMissingWorkerOutput(`${result.stdout}\n${result.stderr}`)) return {
|
|
1703
|
+
kind: "ok",
|
|
1704
|
+
versionId: null,
|
|
1705
|
+
versions: []
|
|
1706
|
+
};
|
|
1707
|
+
return {
|
|
1708
|
+
kind: "failed",
|
|
1709
|
+
status: result.status
|
|
1710
|
+
};
|
|
1711
|
+
}
|
|
1712
|
+
try {
|
|
1713
|
+
const versions = (JSON.parse(result.stdout).versions ?? []).map((version) => ({
|
|
1714
|
+
versionId: typeof version.version_id === "string" ? version.version_id : "",
|
|
1715
|
+
percentage: Number(version.percentage)
|
|
1716
|
+
}));
|
|
1717
|
+
if (versions.some((version) => version.versionId.length === 0 || !Number.isFinite(version.percentage) || version.percentage < 0 || version.percentage > 100)) return {
|
|
1718
|
+
kind: "failed",
|
|
1719
|
+
status: result.status
|
|
1720
|
+
};
|
|
1721
|
+
const activeVersions = versions.filter((version) => version.percentage > 0);
|
|
1722
|
+
if (activeVersions.length > 1 || activeVersions.length === 1 && activeVersions[0]?.percentage !== 100) return { kind: "split" };
|
|
1723
|
+
return {
|
|
1724
|
+
kind: "ok",
|
|
1725
|
+
versionId: activeVersions[0]?.versionId ?? null,
|
|
1726
|
+
versions
|
|
1727
|
+
};
|
|
1728
|
+
} catch {
|
|
1729
|
+
return {
|
|
1730
|
+
kind: "failed",
|
|
1731
|
+
status: result.status
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
function productionVersionPercentagesEqual(actual, expected) {
|
|
1736
|
+
return actual.length === expected.length && expected.every((entry) => actual.some((candidate) => candidate.versionId === entry.versionId && candidate.percentage === entry.percentage));
|
|
1737
|
+
}
|
|
1738
|
+
function stagedVersionPercentages(previousVersionId, versionId) {
|
|
1739
|
+
return previousVersionId ? [{
|
|
1740
|
+
versionId: previousVersionId,
|
|
1741
|
+
percentage: 100
|
|
1742
|
+
}, {
|
|
1743
|
+
versionId,
|
|
1744
|
+
percentage: 0
|
|
1745
|
+
}] : [];
|
|
1746
|
+
}
|
|
1747
|
+
async function verifyReadinessStagingOwner(options, commonArgs, previousVersionId, versionId) {
|
|
1748
|
+
const owner = await readCurrentProductionVersion(options, commonArgs);
|
|
1749
|
+
if (owner.kind === "failed") return {
|
|
1750
|
+
ok: false,
|
|
1751
|
+
reason: "status-failed",
|
|
1752
|
+
status: owner.status,
|
|
1753
|
+
versionId,
|
|
1754
|
+
previousVersionId,
|
|
1755
|
+
rollback: "not-needed"
|
|
1756
|
+
};
|
|
1757
|
+
if (owner.kind === "split") return {
|
|
1758
|
+
ok: false,
|
|
1759
|
+
reason: "traffic-split-unsupported",
|
|
1760
|
+
versionId,
|
|
1761
|
+
previousVersionId,
|
|
1762
|
+
rollback: "not-needed"
|
|
1763
|
+
};
|
|
1764
|
+
const expected = previousVersionId ? [{
|
|
1765
|
+
versionId: previousVersionId,
|
|
1766
|
+
percentage: 100
|
|
1767
|
+
}] : [];
|
|
1768
|
+
if (owner.versionId !== previousVersionId || !productionVersionPercentagesEqual(owner.versions, expected)) return {
|
|
1769
|
+
ok: false,
|
|
1770
|
+
reason: "deployment-changed",
|
|
1771
|
+
versionId,
|
|
1772
|
+
previousVersionId,
|
|
1773
|
+
activeVersionId: owner.versionId,
|
|
1774
|
+
rollback: "not-needed"
|
|
1775
|
+
};
|
|
1776
|
+
return null;
|
|
1777
|
+
}
|
|
1778
|
+
async function probeReadiness(url, workerName, readinessHeaders, authenticatedWorkersDevSubdomain, versionOverride, fetcher, sleep, attempts, timeoutMs = Number.POSITIVE_INFINITY) {
|
|
1779
|
+
const headers = guardedReadinessHeaders(url, workerName, readinessHeaders, authenticatedWorkersDevSubdomain);
|
|
1780
|
+
if (headers === null) return "failed";
|
|
1781
|
+
const deadline = Date.now() + timeoutMs;
|
|
1782
|
+
for (let attempt = 0; attempt < Math.max(1, attempts); attempt++) {
|
|
1783
|
+
const remaining = deadline - Date.now();
|
|
1784
|
+
if (remaining <= 0) break;
|
|
1785
|
+
let response;
|
|
1786
|
+
try {
|
|
1787
|
+
response = await fetcher(url, {
|
|
1788
|
+
headers: {
|
|
1789
|
+
"user-agent": "void-deploy-readiness/1",
|
|
1790
|
+
...headers,
|
|
1791
|
+
...versionOverride ? { "Cloudflare-Workers-Version-Overrides": `${workerName}="${versionOverride}"` } : {}
|
|
1792
|
+
},
|
|
1793
|
+
redirect: "manual",
|
|
1794
|
+
signal: AbortSignal.timeout(Math.max(1, Math.min(1e4, remaining)))
|
|
1795
|
+
});
|
|
1796
|
+
if (response.ok) {
|
|
1797
|
+
if ((await response.json()).ready === true) return "ready";
|
|
1798
|
+
} else if (await isCloudflareAccessDenial(response)) return "access-denied";
|
|
1799
|
+
} catch {} finally {
|
|
1800
|
+
if (response && !response.bodyUsed) await response.body?.cancel().catch(() => {});
|
|
1801
|
+
}
|
|
1802
|
+
if (attempt + 1 < attempts) {
|
|
1803
|
+
const delay = Math.min(250 * 2 ** attempt, 2e3, deadline - Date.now());
|
|
1804
|
+
if (delay > 0) await sleep(delay);
|
|
1805
|
+
}
|
|
1806
|
+
}
|
|
1807
|
+
return "failed";
|
|
1808
|
+
}
|
|
1809
|
+
/** Never forward Access headers to a hostname that cannot be this Worker. */
|
|
1810
|
+
function guardedReadinessHeaders(url, workerName, headers, authenticatedWorkersDevSubdomain) {
|
|
1811
|
+
if (!headers || Object.keys(headers).length === 0) return {};
|
|
1812
|
+
try {
|
|
1813
|
+
const parsed = new URL(url);
|
|
1814
|
+
const firstLabel = parsed.hostname.toLowerCase().split(".")[0] ?? "";
|
|
1815
|
+
const normalizedWorkerName = workerName.toLowerCase();
|
|
1816
|
+
const subdomain = extractWorkersDevSubdomain(url);
|
|
1817
|
+
if (parsed.protocol !== "https:" || !subdomain || subdomain !== authenticatedWorkersDevSubdomain || firstLabel !== normalizedWorkerName && !firstLabel.endsWith(`-${normalizedWorkerName}`)) return null;
|
|
1818
|
+
return headers;
|
|
1819
|
+
} catch {
|
|
1820
|
+
return null;
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1823
|
+
async function isCloudflareAccessDenial(response) {
|
|
1824
|
+
if (response.status >= 300 && response.status < 400 && response.headers.get("location")?.includes("/cdn-cgi/access/")) return true;
|
|
1825
|
+
if (response.status !== 403) return false;
|
|
1826
|
+
if (response.headers.get("cf-mitigated") === "challenge") return true;
|
|
1827
|
+
try {
|
|
1828
|
+
return /(?:error\s+code:\s*)?1050\b/i.test(await response.text());
|
|
1829
|
+
} catch {
|
|
1830
|
+
return false;
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
async function deployVersionPercentages(options, commonArgs, percentages, message) {
|
|
1834
|
+
const metadata = parseCloudflareVersionMessage(message);
|
|
1835
|
+
if ((options.preserveTriggers || metadata?.schema === 2 && metadata.triggerConfig.preserve) && options.configPath) return deployCloudflareVersionTraffic({
|
|
1836
|
+
...options,
|
|
1837
|
+
configPath: options.configPath,
|
|
1838
|
+
percentages,
|
|
1839
|
+
message
|
|
1840
|
+
});
|
|
1841
|
+
return options.runWrangler([
|
|
1842
|
+
"versions",
|
|
1843
|
+
"deploy",
|
|
1844
|
+
...percentages,
|
|
1845
|
+
...commonArgs,
|
|
1846
|
+
"--name",
|
|
1847
|
+
options.workerName,
|
|
1848
|
+
"--message",
|
|
1849
|
+
message,
|
|
1850
|
+
"--yes"
|
|
1851
|
+
], { capture: false });
|
|
1852
|
+
}
|
|
1853
|
+
/** Change version traffic without synchronizing Worker settings from local config. */
|
|
1854
|
+
async function deployCloudflareVersionTraffic(options) {
|
|
1855
|
+
const config = pickWranglerTriggerContext(parseRootWranglerConfig(options.configPath));
|
|
1856
|
+
const directory = mkdtempSync(join(tmpdir(), "void-version-traffic-"));
|
|
1857
|
+
const path = join(directory, "wrangler.json");
|
|
1858
|
+
try {
|
|
1859
|
+
writeFileSync(path, JSON.stringify(config), { mode: 384 });
|
|
1860
|
+
return await options.runWrangler([
|
|
1861
|
+
"versions",
|
|
1862
|
+
"deploy",
|
|
1863
|
+
...options.percentages,
|
|
1864
|
+
"-c",
|
|
1865
|
+
path,
|
|
1866
|
+
"--env=",
|
|
1867
|
+
"--name",
|
|
1868
|
+
options.workerName,
|
|
1869
|
+
"--message",
|
|
1870
|
+
options.message,
|
|
1871
|
+
"--yes"
|
|
1872
|
+
], { capture: false });
|
|
1873
|
+
} finally {
|
|
1874
|
+
rmSync(directory, {
|
|
1875
|
+
recursive: true,
|
|
1876
|
+
force: true
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
}
|
|
1880
|
+
async function restorePreviousVersion(options, commonArgs, previousVersionId, cause, expectedOwnedTraffic) {
|
|
1881
|
+
if (!previousVersionId) return "unavailable";
|
|
1882
|
+
if (!options.verifyUploader()) return "unsafe";
|
|
1883
|
+
const owner = await readCurrentProductionVersion(options, commonArgs);
|
|
1884
|
+
if (owner.kind !== "ok" || !expectedOwnedTraffic.some((expected) => productionVersionPercentagesEqual(owner.versions, expected))) return "unsafe";
|
|
1885
|
+
return isCleanCommandExit(await deployVersionPercentages(options, commonArgs, [`${previousVersionId}@100`], `Void automatic rollback after ${cause}.`)) ? "succeeded" : "failed";
|
|
1886
|
+
}
|
|
1887
|
+
function workersDevReadinessUrl(workerName, value) {
|
|
1888
|
+
const subdomain = normalizeWorkersDevSubdomain(value);
|
|
1889
|
+
if (!subdomain || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(workerName)) return;
|
|
1890
|
+
return `https://${workerName}.${subdomain}.workers.dev`;
|
|
1891
|
+
}
|
|
1892
|
+
/** Resolve the stable URL that owns production traffic after triggers are published. */
|
|
1893
|
+
function publishedCloudflareUrl(workerName, triggerConfig, fallbackUrl) {
|
|
1894
|
+
if (triggerConfig.workers_dev) return workersDevReadinessUrl(workerName, extractWorkersDevSubdomain(fallbackUrl)) ?? fallbackUrl;
|
|
1895
|
+
return customRouteReadinessUrl(triggerConfig.routes) ?? fallbackUrl;
|
|
1896
|
+
}
|
|
1897
|
+
/** Normalize an account workers.dev subdomain supplied by config or the environment. */
|
|
1898
|
+
function normalizeWorkersDevSubdomain(value) {
|
|
1899
|
+
const normalized = value?.trim().toLowerCase().replace(/\.workers\.dev\/?$/, "");
|
|
1900
|
+
if (!normalized || !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.fed)?$/.test(normalized)) return;
|
|
1901
|
+
return normalized;
|
|
1902
|
+
}
|
|
1903
|
+
/** Extract the account subdomain from a Wrangler-reported workers.dev URL. */
|
|
1904
|
+
function extractWorkersDevSubdomain(url) {
|
|
1905
|
+
try {
|
|
1906
|
+
const labels = new URL(url).hostname.toLowerCase().split(".");
|
|
1907
|
+
if (labels.at(-2) !== "workers" || labels.at(-1) !== "dev") return;
|
|
1908
|
+
if (labels.length === 4) return normalizeWorkersDevSubdomain(labels[1]);
|
|
1909
|
+
if (labels.length === 5 && labels[2] === "fed") return normalizeWorkersDevSubdomain(`${labels[1]}.fed`);
|
|
1910
|
+
return;
|
|
1911
|
+
} catch {
|
|
1912
|
+
return;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
function workersDevSubdomainResult(url) {
|
|
1916
|
+
const workersDevSubdomain = extractWorkersDevSubdomain(url);
|
|
1917
|
+
return workersDevSubdomain ? { workersDevSubdomain } : {};
|
|
1918
|
+
}
|
|
1919
|
+
function isCleanCommandExit(result) {
|
|
1920
|
+
return result.status === 0 && result.signal == null && (result.error == null || result.error === "");
|
|
1921
|
+
}
|
|
1922
|
+
function defaultSleep(milliseconds) {
|
|
1923
|
+
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
1924
|
+
}
|
|
1925
|
+
//#endregion
|
|
1926
|
+
export { activateCloudflareVersion, assertCloudflareBindingsPreserved, assertCloudflareDurableObjectLifecycleSupported, assertExistingCloudflareConfig, deployCloudflareVersionTraffic, deployCloudflareVersionTriggers, encodeCloudflareVersionMessage, executeCloudflareDeploy, extractWorkersDevSubdomain, normalizeWorkersDevSubdomain, parseCloudflareVersionMessage, parseRootWranglerConfig, readBindingFieldPresence, readCloudflareTriggerConfig, readCloudflareVersionMigrationTag, readCloudflareWorkerMigrationTag, readConfigEntryString, readExistingCloudflareWorker, readStoredResourceName, readWranglerTopLevelString, renderCloudflareStaticEntry, resolveD1MigrateTarget, stabilizeCloudflareVersionTriggers, stageCloudflareStaticAssets, writeCloudflareFrameworkDeployConfig, writeCloudflareStaticDeployConfig };
|