alchemy 0.41.2 → 0.42.1
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/README.md +2 -0
- package/bin/alchemy.mjs +99 -44
- package/lib/build-date.d.ts +1 -1
- package/lib/build-date.js +1 -1
- package/lib/cloudflare/container.d.ts +6 -0
- package/lib/cloudflare/container.d.ts.map +1 -1
- package/lib/cloudflare/container.js +32 -25
- package/lib/cloudflare/container.js.map +1 -1
- package/lib/cloudflare/index.d.ts +2 -0
- package/lib/cloudflare/index.d.ts.map +1 -1
- package/lib/cloudflare/index.js +2 -0
- package/lib/cloudflare/index.js.map +1 -1
- package/lib/cloudflare/orange.d.ts +37 -0
- package/lib/cloudflare/orange.d.ts.map +1 -0
- package/lib/cloudflare/orange.js +48 -0
- package/lib/cloudflare/orange.js.map +1 -0
- package/lib/cloudflare/redirect-rule.d.ts +168 -0
- package/lib/cloudflare/redirect-rule.d.ts.map +1 -0
- package/lib/cloudflare/redirect-rule.js +380 -0
- package/lib/cloudflare/redirect-rule.js.map +1 -0
- package/lib/cloudflare/website.js +2 -2
- package/lib/cloudflare/website.js.map +1 -1
- package/lib/cloudflare/worker/miniflare-worker-options.d.ts.map +1 -1
- package/lib/cloudflare/worker/miniflare-worker-options.js +11 -12
- package/lib/cloudflare/worker/miniflare-worker-options.js.map +1 -1
- package/lib/cloudflare/worker.d.ts +1 -1
- package/lib/fs/file-system-state-store.d.ts.map +1 -1
- package/lib/fs/file-system-state-store.js +8 -1
- package/lib/fs/file-system-state-store.js.map +1 -1
- package/lib/scope.js +1 -1
- package/lib/scope.js.map +1 -1
- package/package.json +2 -2
- package/src/build-date.ts +1 -1
- package/src/cloudflare/container.ts +44 -30
- package/src/cloudflare/index.ts +2 -0
- package/src/cloudflare/orange.ts +61 -0
- package/src/cloudflare/redirect-rule.ts +642 -0
- package/src/cloudflare/website.ts +2 -2
- package/src/cloudflare/worker/miniflare-worker-options.ts +14 -12
- package/src/fs/file-system-state-store.ts +8 -1
- package/src/scope.ts +1 -1
- package/templates/astro/package.json +1 -1
- package/templates/nuxt/package.json +1 -1
- package/templates/react-router/package.json +1 -1
- package/templates/rwsdk/package.json +1 -1
- package/templates/sveltekit/package.json +1 -1
- package/templates/tanstack-start/package.json +1 -1
- package/templates/typescript/package.json +1 -1
- package/templates/vite/package.json +1 -1
|
@@ -0,0 +1,642 @@
|
|
|
1
|
+
import type { Context } from "../context.ts";
|
|
2
|
+
import { Resource } from "../resource.ts";
|
|
3
|
+
import {
|
|
4
|
+
createCloudflareApi,
|
|
5
|
+
type CloudflareApi,
|
|
6
|
+
type CloudflareApiOptions,
|
|
7
|
+
} from "./api.ts";
|
|
8
|
+
import type { CloudflareResponse } from "./response.ts";
|
|
9
|
+
import type { Zone } from "./zone.ts";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Properties for creating or updating a RedirectRule
|
|
13
|
+
*/
|
|
14
|
+
export interface RedirectRuleProps extends CloudflareApiOptions {
|
|
15
|
+
/**
|
|
16
|
+
* The zone where the redirect rule will be applied
|
|
17
|
+
* Can be a zone ID string or a Zone resource
|
|
18
|
+
*/
|
|
19
|
+
zone: string | Zone;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* For wildcard redirects: the URL pattern to match
|
|
23
|
+
* Example: "https://*.example.com/files/*"
|
|
24
|
+
* This is mutually exclusive with `expression`
|
|
25
|
+
*/
|
|
26
|
+
requestUrl?: string;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* For dynamic redirects: a Cloudflare Rules expression
|
|
30
|
+
* Example: 'http.request.uri.path matches "/autodiscover\\.(xml|src)$"'
|
|
31
|
+
* This is mutually exclusive with `requestUrl`
|
|
32
|
+
* @see https://developers.cloudflare.com/ruleset-engine/rules-language/expressions/
|
|
33
|
+
*/
|
|
34
|
+
expression?: string;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The target URL for the redirect
|
|
38
|
+
* Can include placeholders like ${1}, ${2} for wildcard matches
|
|
39
|
+
* Example: "https://example.com/${1}/files/${2}"
|
|
40
|
+
*/
|
|
41
|
+
targetUrl: string;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* HTTP status code for the redirect
|
|
45
|
+
* @default 301
|
|
46
|
+
*/
|
|
47
|
+
statusCode?: 301 | 302 | 303 | 307 | 308;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Whether to preserve query string parameters
|
|
51
|
+
* @default true
|
|
52
|
+
*/
|
|
53
|
+
preserveQueryString?: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Cloudflare Ruleset response format
|
|
58
|
+
*/
|
|
59
|
+
interface CloudflareRuleset {
|
|
60
|
+
id: string;
|
|
61
|
+
name: string;
|
|
62
|
+
description?: string;
|
|
63
|
+
kind: string;
|
|
64
|
+
version: string;
|
|
65
|
+
rules: CloudflareRule[];
|
|
66
|
+
last_updated: string;
|
|
67
|
+
phase: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Cloudflare Rule response format
|
|
72
|
+
*/
|
|
73
|
+
interface CloudflareRule {
|
|
74
|
+
id: string;
|
|
75
|
+
version: string;
|
|
76
|
+
action: string;
|
|
77
|
+
expression: string;
|
|
78
|
+
description?: string;
|
|
79
|
+
last_updated: string;
|
|
80
|
+
ref: string;
|
|
81
|
+
enabled: boolean;
|
|
82
|
+
action_parameters?: {
|
|
83
|
+
from_value?: {
|
|
84
|
+
status_code?: number;
|
|
85
|
+
target_url?: {
|
|
86
|
+
value?: string;
|
|
87
|
+
expression?: string;
|
|
88
|
+
};
|
|
89
|
+
preserve_query_string?: boolean;
|
|
90
|
+
};
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Output returned after RedirectRule creation/update
|
|
96
|
+
*/
|
|
97
|
+
export interface RedirectRule extends Resource<"cloudflare::RedirectRule"> {
|
|
98
|
+
/**
|
|
99
|
+
* The ID of the redirect rule
|
|
100
|
+
*/
|
|
101
|
+
ruleId: string;
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The ID of the ruleset containing this rule
|
|
105
|
+
*/
|
|
106
|
+
rulesetId: string;
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The zone ID where the rule is applied
|
|
110
|
+
*/
|
|
111
|
+
zoneId: string;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The request URL pattern (for wildcard redirects)
|
|
115
|
+
*/
|
|
116
|
+
requestUrl?: string;
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The expression (for dynamic redirects)
|
|
120
|
+
*/
|
|
121
|
+
expression?: string;
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The target URL for the redirect
|
|
125
|
+
*/
|
|
126
|
+
targetUrl: string;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* HTTP status code for the redirect
|
|
130
|
+
*/
|
|
131
|
+
statusCode: number;
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Whether query string parameters are preserved
|
|
135
|
+
*/
|
|
136
|
+
preserveQueryString: boolean;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Whether the rule is enabled
|
|
140
|
+
*/
|
|
141
|
+
enabled: boolean;
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Time when the rule was last updated
|
|
145
|
+
*/
|
|
146
|
+
lastUpdated: string;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A Cloudflare Redirect Rule enables URL redirects and rewrites using Cloudflare's Rules engine.
|
|
151
|
+
* Supports wildcard redirects, static redirects, and dynamic redirects with expressions.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* ## Wildcard Redirect
|
|
155
|
+
*
|
|
156
|
+
* Redirect from a wildcard pattern to a target URL with placeholders.
|
|
157
|
+
*
|
|
158
|
+
* ```ts
|
|
159
|
+
* const wildcardRedirect = await RedirectRule("my-wildcard-redirect", {
|
|
160
|
+
* zone: "example.com",
|
|
161
|
+
* requestUrl: "https://*.example.com/files/*",
|
|
162
|
+
* targetUrl: "https://example.com/${1}/files/${2}",
|
|
163
|
+
* statusCode: 301,
|
|
164
|
+
* preserveQueryString: true
|
|
165
|
+
* });
|
|
166
|
+
* ```
|
|
167
|
+
*
|
|
168
|
+
* @example
|
|
169
|
+
* ## Static Redirect
|
|
170
|
+
*
|
|
171
|
+
* Simple redirect from any request to a static target URL.
|
|
172
|
+
*
|
|
173
|
+
* ```ts
|
|
174
|
+
* const staticRedirect = await RedirectRule("my-static-redirect", {
|
|
175
|
+
* zone: "example.com",
|
|
176
|
+
* targetUrl: "https://example.com/",
|
|
177
|
+
* statusCode: 301,
|
|
178
|
+
* preserveQueryString: true
|
|
179
|
+
* });
|
|
180
|
+
* ```
|
|
181
|
+
*
|
|
182
|
+
* @example
|
|
183
|
+
* ## Dynamic Redirect with Expression
|
|
184
|
+
*
|
|
185
|
+
* Complex redirect using Cloudflare's Rules language for advanced matching.
|
|
186
|
+
*
|
|
187
|
+
* ```ts
|
|
188
|
+
* const dynamicRedirect = await RedirectRule("my-dynamic-redirect", {
|
|
189
|
+
* zone: "example.com",
|
|
190
|
+
* expression: 'http.request.uri.path matches "/autodiscover\\.(xml|src)$"',
|
|
191
|
+
* targetUrl: "https://example.com/not-found",
|
|
192
|
+
* statusCode: 301,
|
|
193
|
+
* preserveQueryString: true
|
|
194
|
+
* });
|
|
195
|
+
* ```
|
|
196
|
+
*
|
|
197
|
+
* @see https://developers.cloudflare.com/rules/url-forwarding/single-redirects/
|
|
198
|
+
*/
|
|
199
|
+
export const RedirectRule = Resource(
|
|
200
|
+
"cloudflare::RedirectRule",
|
|
201
|
+
async function (
|
|
202
|
+
this: Context<RedirectRule>,
|
|
203
|
+
_id: string,
|
|
204
|
+
props: RedirectRuleProps,
|
|
205
|
+
): Promise<RedirectRule> {
|
|
206
|
+
// Create Cloudflare API client
|
|
207
|
+
const api = await createCloudflareApi(props);
|
|
208
|
+
|
|
209
|
+
// Get zone ID
|
|
210
|
+
const zoneId = typeof props.zone === "string" ? props.zone : props.zone.id;
|
|
211
|
+
|
|
212
|
+
if (this.phase === "delete") {
|
|
213
|
+
if (this.output?.ruleId && this.output?.rulesetId) {
|
|
214
|
+
// Let delete errors propagate instead of swallowing them
|
|
215
|
+
await deleteRedirectRule(
|
|
216
|
+
api,
|
|
217
|
+
zoneId,
|
|
218
|
+
this.output.rulesetId,
|
|
219
|
+
this.output.ruleId,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return this.destroy();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Validate props
|
|
226
|
+
if (props.requestUrl && props.expression) {
|
|
227
|
+
throw new Error(
|
|
228
|
+
"Cannot specify both requestUrl and expression. Use requestUrl for wildcard redirects or expression for dynamic redirects.",
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const statusCode = props.statusCode ?? 301;
|
|
233
|
+
const preserveQueryString = props.preserveQueryString ?? true;
|
|
234
|
+
|
|
235
|
+
// Build the rule expression
|
|
236
|
+
let ruleExpression: string;
|
|
237
|
+
if (props.requestUrl) {
|
|
238
|
+
// Convert wildcard URL to Cloudflare expression
|
|
239
|
+
ruleExpression = convertWildcardUrlToExpression(props.requestUrl);
|
|
240
|
+
} else if (props.expression) {
|
|
241
|
+
ruleExpression = props.expression;
|
|
242
|
+
} else {
|
|
243
|
+
// Static redirect - match all requests
|
|
244
|
+
ruleExpression = "true";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (
|
|
248
|
+
this.phase === "update" &&
|
|
249
|
+
this.output?.ruleId &&
|
|
250
|
+
this.output?.rulesetId
|
|
251
|
+
) {
|
|
252
|
+
// Update existing rule
|
|
253
|
+
const updatedRule = await updateRedirectRule(
|
|
254
|
+
api,
|
|
255
|
+
zoneId,
|
|
256
|
+
this.output.rulesetId,
|
|
257
|
+
this.output.ruleId,
|
|
258
|
+
{
|
|
259
|
+
expression: ruleExpression,
|
|
260
|
+
targetUrl: props.targetUrl,
|
|
261
|
+
statusCode,
|
|
262
|
+
preserveQueryString,
|
|
263
|
+
},
|
|
264
|
+
);
|
|
265
|
+
|
|
266
|
+
return this({
|
|
267
|
+
ruleId: updatedRule.id,
|
|
268
|
+
rulesetId: this.output.rulesetId,
|
|
269
|
+
zoneId,
|
|
270
|
+
requestUrl: props.requestUrl,
|
|
271
|
+
expression: props.expression,
|
|
272
|
+
targetUrl: props.targetUrl,
|
|
273
|
+
statusCode,
|
|
274
|
+
preserveQueryString,
|
|
275
|
+
enabled: updatedRule.enabled ?? true,
|
|
276
|
+
lastUpdated: updatedRule.last_updated,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Get or create the redirect ruleset for this zone
|
|
281
|
+
const rulesetId = await getOrCreateRedirectRuleset(api, zoneId);
|
|
282
|
+
|
|
283
|
+
// Create the rule
|
|
284
|
+
const createdRule = await createRedirectRule(api, zoneId, rulesetId, {
|
|
285
|
+
expression: ruleExpression,
|
|
286
|
+
targetUrl: props.targetUrl,
|
|
287
|
+
statusCode,
|
|
288
|
+
preserveQueryString,
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
return this({
|
|
292
|
+
ruleId: createdRule.id,
|
|
293
|
+
rulesetId,
|
|
294
|
+
zoneId,
|
|
295
|
+
requestUrl: props.requestUrl,
|
|
296
|
+
expression: props.expression,
|
|
297
|
+
targetUrl: props.targetUrl,
|
|
298
|
+
statusCode,
|
|
299
|
+
preserveQueryString,
|
|
300
|
+
enabled: createdRule.enabled ?? true,
|
|
301
|
+
lastUpdated: createdRule.last_updated,
|
|
302
|
+
});
|
|
303
|
+
},
|
|
304
|
+
);
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Get existing redirect ruleset for a zone
|
|
308
|
+
*/
|
|
309
|
+
async function getRedirectRuleset(
|
|
310
|
+
api: CloudflareApi,
|
|
311
|
+
zoneId: string,
|
|
312
|
+
): Promise<string | null> {
|
|
313
|
+
const response = await api.get(`/zones/${zoneId}/rulesets`);
|
|
314
|
+
|
|
315
|
+
if (!response.ok) {
|
|
316
|
+
return null;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const result = (await response.json()) as CloudflareResponse<
|
|
320
|
+
CloudflareRuleset[]
|
|
321
|
+
>;
|
|
322
|
+
const redirectRuleset = result.result.find(
|
|
323
|
+
(ruleset) => ruleset.phase === "http_request_dynamic_redirect",
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
return redirectRuleset?.id || null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Create a new redirect ruleset for a zone
|
|
331
|
+
*/
|
|
332
|
+
async function createRedirectRuleset(
|
|
333
|
+
api: CloudflareApi,
|
|
334
|
+
zoneId: string,
|
|
335
|
+
): Promise<string> {
|
|
336
|
+
const response = await api.post(`/zones/${zoneId}/rulesets`, {
|
|
337
|
+
name: "Zone-level redirect ruleset",
|
|
338
|
+
description: "Redirect rules for the zone",
|
|
339
|
+
kind: "zone",
|
|
340
|
+
phase: "http_request_dynamic_redirect",
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
if (!response.ok) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`Failed to create redirect ruleset: ${response.statusText}`,
|
|
346
|
+
);
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
const result =
|
|
350
|
+
(await response.json()) as CloudflareResponse<CloudflareRuleset>;
|
|
351
|
+
return result.result.id;
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get or create the redirect ruleset for a zone
|
|
356
|
+
*/
|
|
357
|
+
async function getOrCreateRedirectRuleset(
|
|
358
|
+
api: CloudflareApi,
|
|
359
|
+
zoneId: string,
|
|
360
|
+
): Promise<string> {
|
|
361
|
+
const existingRulesetId = await getRedirectRuleset(api, zoneId);
|
|
362
|
+
if (existingRulesetId) {
|
|
363
|
+
return existingRulesetId;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
return await createRedirectRuleset(api, zoneId);
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Create a new redirect rule by updating the ruleset
|
|
371
|
+
*/
|
|
372
|
+
async function createRedirectRule(
|
|
373
|
+
api: CloudflareApi,
|
|
374
|
+
zoneId: string,
|
|
375
|
+
rulesetId: string,
|
|
376
|
+
ruleData: {
|
|
377
|
+
expression: string;
|
|
378
|
+
targetUrl: string;
|
|
379
|
+
statusCode: number;
|
|
380
|
+
preserveQueryString: boolean;
|
|
381
|
+
},
|
|
382
|
+
): Promise<CloudflareRule> {
|
|
383
|
+
// Get current ruleset
|
|
384
|
+
const ruleset = await getRuleset(api, zoneId, rulesetId);
|
|
385
|
+
if (!ruleset) {
|
|
386
|
+
throw new Error(`Ruleset ${rulesetId} not found`);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// Create new rule object
|
|
390
|
+
const newRule = {
|
|
391
|
+
action: "redirect" as const,
|
|
392
|
+
expression: ruleData.expression,
|
|
393
|
+
action_parameters: {
|
|
394
|
+
from_value: {
|
|
395
|
+
status_code: ruleData.statusCode,
|
|
396
|
+
target_url: {
|
|
397
|
+
value: ruleData.targetUrl,
|
|
398
|
+
},
|
|
399
|
+
preserve_query_string: ruleData.preserveQueryString,
|
|
400
|
+
},
|
|
401
|
+
},
|
|
402
|
+
enabled: true,
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
// Update ruleset with new rule
|
|
406
|
+
const response = await api.put(`/zones/${zoneId}/rulesets/${rulesetId}`, {
|
|
407
|
+
name: ruleset.name,
|
|
408
|
+
description: ruleset.description,
|
|
409
|
+
kind: ruleset.kind,
|
|
410
|
+
phase: ruleset.phase,
|
|
411
|
+
rules: [...ruleset.rules, newRule],
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
if (!response.ok) {
|
|
415
|
+
const errorBody = await response.text();
|
|
416
|
+
throw new Error(
|
|
417
|
+
`Failed to create redirect rule: ${response.statusText}\nResponse: ${errorBody}`,
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
const result =
|
|
422
|
+
(await response.json()) as CloudflareResponse<CloudflareRuleset>;
|
|
423
|
+
// Return the last rule (the one we just added)
|
|
424
|
+
const createdRule = result.result.rules[result.result.rules.length - 1];
|
|
425
|
+
return createdRule;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Update an existing redirect rule by updating the ruleset
|
|
430
|
+
*/
|
|
431
|
+
async function updateRedirectRule(
|
|
432
|
+
api: CloudflareApi,
|
|
433
|
+
zoneId: string,
|
|
434
|
+
rulesetId: string,
|
|
435
|
+
ruleId: string,
|
|
436
|
+
ruleData: {
|
|
437
|
+
expression: string;
|
|
438
|
+
targetUrl: string;
|
|
439
|
+
statusCode: number;
|
|
440
|
+
preserveQueryString: boolean;
|
|
441
|
+
},
|
|
442
|
+
): Promise<CloudflareRule> {
|
|
443
|
+
// Get current ruleset
|
|
444
|
+
const ruleset = await getRuleset(api, zoneId, rulesetId);
|
|
445
|
+
if (!ruleset) {
|
|
446
|
+
throw new Error(`Ruleset ${rulesetId} not found`);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Find and update the rule
|
|
450
|
+
const updatedRules = ruleset.rules.map((rule) => {
|
|
451
|
+
if (rule.id === ruleId) {
|
|
452
|
+
return {
|
|
453
|
+
...rule,
|
|
454
|
+
action: "redirect" as const,
|
|
455
|
+
expression: ruleData.expression,
|
|
456
|
+
action_parameters: {
|
|
457
|
+
from_value: {
|
|
458
|
+
status_code: ruleData.statusCode,
|
|
459
|
+
target_url: {
|
|
460
|
+
value: ruleData.targetUrl,
|
|
461
|
+
},
|
|
462
|
+
preserve_query_string: ruleData.preserveQueryString,
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
enabled: true,
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
return rule;
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// Update ruleset with modified rules
|
|
472
|
+
const response = await api.put(`/zones/${zoneId}/rulesets/${rulesetId}`, {
|
|
473
|
+
name: ruleset.name,
|
|
474
|
+
description: ruleset.description,
|
|
475
|
+
kind: ruleset.kind,
|
|
476
|
+
phase: ruleset.phase,
|
|
477
|
+
rules: updatedRules,
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
if (!response.ok) {
|
|
481
|
+
throw new Error(`Failed to update redirect rule: ${response.statusText}`);
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const result =
|
|
485
|
+
(await response.json()) as CloudflareResponse<CloudflareRuleset>;
|
|
486
|
+
// Find and return the updated rule
|
|
487
|
+
const updatedRule = result.result.rules.find((rule) => rule.id === ruleId);
|
|
488
|
+
if (!updatedRule) {
|
|
489
|
+
throw new Error(`Updated rule ${ruleId} not found in response`);
|
|
490
|
+
}
|
|
491
|
+
return updatedRule;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Get a ruleset with its rules
|
|
496
|
+
*/
|
|
497
|
+
async function getRuleset(
|
|
498
|
+
api: CloudflareApi,
|
|
499
|
+
zoneId: string,
|
|
500
|
+
rulesetId: string,
|
|
501
|
+
): Promise<CloudflareRuleset | null> {
|
|
502
|
+
const response = await api.get(`/zones/${zoneId}/rulesets/${rulesetId}`);
|
|
503
|
+
|
|
504
|
+
if (!response.ok) {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const result =
|
|
509
|
+
(await response.json()) as CloudflareResponse<CloudflareRuleset>;
|
|
510
|
+
return result.result;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Find a specific rule in a ruleset
|
|
515
|
+
*/
|
|
516
|
+
export async function findRuleInRuleset(
|
|
517
|
+
api: CloudflareApi,
|
|
518
|
+
zoneId: string,
|
|
519
|
+
rulesetId: string,
|
|
520
|
+
ruleId: string,
|
|
521
|
+
): Promise<CloudflareRule | null> {
|
|
522
|
+
const response = await api.get(`/zones/${zoneId}/rulesets/${rulesetId}`);
|
|
523
|
+
|
|
524
|
+
if (!response.ok) {
|
|
525
|
+
throw new Error(
|
|
526
|
+
`Failed to get ruleset: ${response.status} ${response.statusText}`,
|
|
527
|
+
);
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
const rulesetData =
|
|
531
|
+
(await response.json()) as CloudflareResponse<CloudflareRuleset>;
|
|
532
|
+
const rule = rulesetData.result.rules.find((r) => r.id === ruleId);
|
|
533
|
+
|
|
534
|
+
return rule || null;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
/**
|
|
538
|
+
* Delete a redirect rule by updating the ruleset to exclude it
|
|
539
|
+
*/
|
|
540
|
+
async function deleteRedirectRule(
|
|
541
|
+
api: CloudflareApi,
|
|
542
|
+
zoneId: string,
|
|
543
|
+
rulesetId: string,
|
|
544
|
+
ruleId: string,
|
|
545
|
+
): Promise<void> {
|
|
546
|
+
const ruleset = await getRuleset(api, zoneId, rulesetId);
|
|
547
|
+
|
|
548
|
+
if (!ruleset) {
|
|
549
|
+
throw new Error(`Ruleset ${rulesetId} not found for deletion`);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Filter out the rule to delete
|
|
553
|
+
const updatedRules = ruleset.rules.filter((rule) => rule.id !== ruleId);
|
|
554
|
+
|
|
555
|
+
// Update the ruleset with the filtered rules
|
|
556
|
+
const response = await api.put(`/zones/${zoneId}/rulesets/${rulesetId}`, {
|
|
557
|
+
name: ruleset.name,
|
|
558
|
+
description: ruleset.description,
|
|
559
|
+
kind: ruleset.kind,
|
|
560
|
+
phase: ruleset.phase,
|
|
561
|
+
rules: updatedRules,
|
|
562
|
+
});
|
|
563
|
+
|
|
564
|
+
if (!response.ok) {
|
|
565
|
+
throw new Error(`Failed to delete redirect rule: ${response.statusText}`);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Convert a wildcard URL pattern to a Cloudflare Rules expression
|
|
571
|
+
* Uses operators available on Free plans (no regex matching)
|
|
572
|
+
*/
|
|
573
|
+
function convertWildcardUrlToExpression(wildcardUrl: string): string {
|
|
574
|
+
// Parse the URL to extract components
|
|
575
|
+
const url = new URL(wildcardUrl);
|
|
576
|
+
const hostname = url.hostname;
|
|
577
|
+
const pathname = url.pathname;
|
|
578
|
+
|
|
579
|
+
let expression = "";
|
|
580
|
+
|
|
581
|
+
// Handle hostname wildcards
|
|
582
|
+
if (hostname.includes("*")) {
|
|
583
|
+
// For simple wildcard patterns, use contains or ends_with operators
|
|
584
|
+
if (hostname.startsWith("*")) {
|
|
585
|
+
// *.example.com -> http.host ends_with ".example.com"
|
|
586
|
+
const suffix = hostname.substring(1); // Remove the *
|
|
587
|
+
expression += `http.host ends_with "${suffix}"`;
|
|
588
|
+
} else if (hostname.endsWith("*")) {
|
|
589
|
+
// subdomain.* -> http.host starts_with "subdomain."
|
|
590
|
+
const prefix = hostname.substring(0, hostname.length - 1); // Remove the *
|
|
591
|
+
expression += `http.host starts_with "${prefix}"`;
|
|
592
|
+
} else {
|
|
593
|
+
// More complex wildcards - fallback to a broader match
|
|
594
|
+
const parts = hostname.split("*");
|
|
595
|
+
if (parts.length === 2) {
|
|
596
|
+
expression += `http.host starts_with "${parts[0]}" and http.host ends_with "${parts[1]}"`;
|
|
597
|
+
} else {
|
|
598
|
+
// Fallback to domain contains for complex patterns
|
|
599
|
+
const baseDomain = hostname.replace(/^\*\./, "").replace(/\.\*$/, "");
|
|
600
|
+
expression += `http.host contains "${baseDomain}"`;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
} else {
|
|
604
|
+
expression += `http.host == "${hostname}"`;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// Handle pathname wildcards
|
|
608
|
+
if (pathname.includes("*")) {
|
|
609
|
+
if (pathname.endsWith("*")) {
|
|
610
|
+
// /files/* -> starts_with "/files/"
|
|
611
|
+
const prefix = pathname.substring(0, pathname.length - 1); // Remove the *
|
|
612
|
+
expression += ` and http.request.uri.path starts_with "${prefix}"`;
|
|
613
|
+
} else if (pathname.startsWith("*")) {
|
|
614
|
+
// *.html -> ends_with ".html"
|
|
615
|
+
const suffix = pathname.substring(1); // Remove the *
|
|
616
|
+
expression += ` and http.request.uri.path ends_with "${suffix}"`;
|
|
617
|
+
} else {
|
|
618
|
+
// More complex wildcards - use contains
|
|
619
|
+
const parts = pathname.split("*");
|
|
620
|
+
if (parts.length === 2 && parts[0] && parts[1]) {
|
|
621
|
+
expression += ` and http.request.uri.path starts_with "${parts[0]}" and http.request.uri.path ends_with "${parts[1]}"`;
|
|
622
|
+
} else {
|
|
623
|
+
// Fallback to contains for the non-wildcard part
|
|
624
|
+
const nonWildcardPart = parts.find((part) => part.length > 0) || "";
|
|
625
|
+
if (nonWildcardPart) {
|
|
626
|
+
expression += ` and http.request.uri.path contains "${nonWildcardPart}"`;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
} else if (pathname !== "/") {
|
|
631
|
+
expression += ` and http.request.uri.path == "${pathname}"`;
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
// Handle protocol
|
|
635
|
+
if (url.protocol === "https:") {
|
|
636
|
+
expression += " and ssl";
|
|
637
|
+
} else if (url.protocol === "http:") {
|
|
638
|
+
expression += " and not ssl";
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
return expression;
|
|
642
|
+
}
|
|
@@ -225,11 +225,14 @@ export function buildMiniflareWorkerOptions({
|
|
|
225
225
|
compatibilityDate,
|
|
226
226
|
compatibilityFlags,
|
|
227
227
|
unsafeDirectSockets: [{ entrypoint: undefined, proxy: true }],
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
228
|
+
containerEngine: {
|
|
229
|
+
localDocker: {
|
|
230
|
+
socketPath:
|
|
231
|
+
process.platform === "win32"
|
|
232
|
+
? "//./pipe/docker_engine"
|
|
233
|
+
: "unix:///var/run/docker.sock",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
233
236
|
};
|
|
234
237
|
for (const [name, binding] of Object.entries(bindings ?? {})) {
|
|
235
238
|
if (typeof binding === "string") {
|
|
@@ -519,19 +522,18 @@ export function buildMiniflareWorkerOptions({
|
|
|
519
522
|
break;
|
|
520
523
|
}
|
|
521
524
|
case "container": {
|
|
525
|
+
if (binding.dev?.remote) {
|
|
526
|
+
throw new Error(
|
|
527
|
+
`Container bindings with remote: true are not supported for locally emulated workers. Worker "${name}" is locally emulated but is bound to container "${name}" with remote: true.`,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
522
530
|
(options.durableObjects ??= {})[name] = {
|
|
523
531
|
className: binding.className,
|
|
524
532
|
scriptName: binding.scriptName,
|
|
525
533
|
useSQLite: binding.sqlite,
|
|
526
534
|
container: {
|
|
527
|
-
imageName: binding.image.
|
|
535
|
+
imageName: binding.image.imageRef,
|
|
528
536
|
},
|
|
529
|
-
// namespaceId: binding.namespaceId,
|
|
530
|
-
// unsafeUniqueKey?: string | typeof kUnsafeEphemeralUniqueKey | undefined;
|
|
531
|
-
// unsafePreventEviction?: boolean | undefined;
|
|
532
|
-
// remoteProxyConnectionString: binding.local
|
|
533
|
-
// ? undefined
|
|
534
|
-
// : remoteProxyConnectionString,
|
|
535
537
|
};
|
|
536
538
|
if (!binding.scriptName || binding.scriptName === workerName) {
|
|
537
539
|
options.unsafeDirectSockets!.push({
|
|
@@ -131,9 +131,16 @@ export class FileSystemStateStore implements StateStore {
|
|
|
131
131
|
if (key.includes("/")) {
|
|
132
132
|
//todo(michael): remove this next time we do a breaking change
|
|
133
133
|
//* windows doesn't support ":" in file paths, but we already use ":"
|
|
134
|
-
//* so now we use both to prevent breaking changes
|
|
134
|
+
//* so now we use both to prevent breaking changes
|
|
135
135
|
key = key.replaceAll("/", ALCHEMY_SEPERATOR_CHAR);
|
|
136
136
|
}
|
|
137
|
+
//todo(michael): remove this next time we do a breaking change
|
|
138
|
+
//* windows doesn't support "*" in file paths, but we already use "*"
|
|
139
|
+
//* when making cloudflare routes containing "*"
|
|
140
|
+
//* so now we use "+" on windows but "*" on mac/linux to prevent breaking changes
|
|
141
|
+
if (process.platform === "win32") {
|
|
142
|
+
key = key.replaceAll("*", "+");
|
|
143
|
+
}
|
|
137
144
|
return path.join(this.dir, `${key}.json`);
|
|
138
145
|
}
|
|
139
146
|
}
|