Skip to content

Commit 9514c9a

Browse files
authored
Add support for new placement modes (#10937)
Add wrangler support for targeted placement Worker configurations. The relevant tests have been added or updated, and type-level enforcement of the new modes should prevent invalid formats from being passed through.
1 parent ce295bf commit 9514c9a

File tree

9 files changed

+256
-45
lines changed

9 files changed

+256
-45
lines changed
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"wrangler": minor
3+
"@cloudflare/workers-utils": minor
4+
---
5+
6+
Add support for "targeted" placement mode with region, host, and hostname fields
7+
8+
This change adds a new mode to `placement` configuration. You can specify one of the following fields to target specific external resources for Worker placement:
9+
10+
- `region`: Specify a region identifier (e.g., "aws:us-east-1") to target a region from another cloud service provider
11+
- `host`: Specify a host with (required) port (e.g., "example.com:8123") to target a TCP service
12+
- `hostname`: Specify a hostname (e.g., "example.com") to target an HTTP resource
13+
14+
These fields are mutually exclusive - only one can be specified at a time.
15+
16+
Example configuration:
17+
18+
```toml
19+
[placement]
20+
host = "example.com:8123"
21+
```

‎packages/workers-utils/src/config/environment.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -566,7 +566,12 @@ interface EnvironmentInheritable {
566566
*
567567
* @inheritable
568568
*/
569-
placement: { mode: "off" | "smart"; hint?: string } | undefined;
569+
placement:
570+
| { mode: "off" | "smart"; hint?: string }
571+
| { mode?: "targeted"; region: string }
572+
| { mode?: "targeted"; host: string }
573+
| { mode?: "targeted"; hostname: string }
574+
| undefined;
570575

571576
/**
572577
* Specify the directory of static assets to deploy/serve

‎packages/workers-utils/src/config/validation.ts‎

Lines changed: 117 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -981,24 +981,124 @@ function normalizeAndValidatePlacement(
981981
rawEnv: RawEnvironment
982982
): Config["placement"] {
983983
if (rawEnv.placement) {
984-
validateRequiredProperty(
985-
diagnostics,
986-
"placement",
987-
"mode",
988-
rawEnv.placement.mode,
989-
"string",
990-
["off", "smart"]
991-
);
992-
validateOptionalProperty(
993-
diagnostics,
994-
"placement",
995-
"hint",
996-
rawEnv.placement.hint,
997-
"string"
998-
);
999-
if (rawEnv.placement.hint && rawEnv.placement.mode !== "smart") {
984+
const placement = rawEnv.placement as Record<string, unknown>;
985+
986+
// Detect which format is being used
987+
const hasHint = "hint" in placement;
988+
const hasRegion = "region" in placement;
989+
const hasHost = "host" in placement;
990+
const hasHostname = "hostname" in placement;
991+
const hasTargetedFields = hasRegion || hasHost || hasHostname;
992+
993+
// Validate that formats aren't mixed
994+
if (hasHint && hasTargetedFields) {
1000995
diagnostics.errors.push(
1001-
`"placement.hint" cannot be set if "placement.mode" is not "smart"`
996+
`"placement" cannot have both "hint" (smart format) and "region"/"host"/"hostname" (targeted format) fields`
997+
);
998+
return inheritable(
999+
diagnostics,
1000+
topLevelEnv,
1001+
rawEnv,
1002+
"placement",
1003+
() => true,
1004+
undefined
1005+
);
1006+
}
1007+
1008+
// Validate old format (with hint)
1009+
if (hasHint) {
1010+
validateRequiredProperty(
1011+
diagnostics,
1012+
"placement",
1013+
"mode",
1014+
placement.mode,
1015+
"string",
1016+
["off", "smart"]
1017+
);
1018+
1019+
const mode = placement.mode as string;
1020+
const hint = placement.hint;
1021+
1022+
// Hint must be a string (if provided)
1023+
if (hint !== undefined && typeof hint !== "string") {
1024+
diagnostics.errors.push(
1025+
`"placement.hint" must be a string when "placement.mode" is "${mode}"`
1026+
);
1027+
}
1028+
if (hint && mode !== "smart") {
1029+
diagnostics.errors.push(
1030+
`"placement.hint" can only be set when "placement.mode" is "smart"`
1031+
);
1032+
}
1033+
}
1034+
// Validate new format (with region/host/hostname)
1035+
else if (hasTargetedFields) {
1036+
// Mode is optional for new format, but if present must be "off" or "targeted"
1037+
validateOptionalProperty(
1038+
diagnostics,
1039+
"placement",
1040+
"mode",
1041+
placement.mode,
1042+
"string",
1043+
["off", "targeted"]
1044+
);
1045+
1046+
// Validate that region/host/hostname are strings if present
1047+
if (hasRegion) {
1048+
validateOptionalProperty(
1049+
diagnostics,
1050+
"placement",
1051+
"region",
1052+
placement.region,
1053+
"string"
1054+
);
1055+
}
1056+
if (hasHost) {
1057+
validateOptionalProperty(
1058+
diagnostics,
1059+
"placement",
1060+
"host",
1061+
placement.host,
1062+
"string"
1063+
);
1064+
}
1065+
if (hasHostname) {
1066+
validateOptionalProperty(
1067+
diagnostics,
1068+
"placement",
1069+
"hostname",
1070+
placement.hostname,
1071+
"string"
1072+
);
1073+
}
1074+
1075+
// Validate that region/host/hostname are mutually exclusive
1076+
const fieldsPresent = [hasRegion, hasHost, hasHostname].filter(Boolean);
1077+
if (fieldsPresent.length > 1) {
1078+
const presentFields = [];
1079+
if (hasRegion) {
1080+
presentFields.push("region");
1081+
}
1082+
if (hasHost) {
1083+
presentFields.push("host");
1084+
}
1085+
if (hasHostname) {
1086+
presentFields.push("hostname");
1087+
}
1088+
diagnostics.errors.push(
1089+
`"placement" fields ${presentFields.map((f) => `"${f}"`).join(", ")} are mutually exclusive. Only one can be specified.`
1090+
);
1091+
}
1092+
}
1093+
// Just mode, no hint or new format fields
1094+
else {
1095+
validateRequiredProperty(
1096+
diagnostics,
1097+
"placement",
1098+
"mode",
1099+
placement.mode,
1100+
"string",
1101+
["off", "smart", "targeted"]
10021102
);
10031103
}
10041104
}

‎packages/workers-utils/src/worker.ts‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -365,10 +365,11 @@ export interface CfDurableObjectMigrations {
365365
}[];
366366
}
367367

368-
export interface CfPlacement {
369-
mode: "smart";
370-
hint?: string;
371-
}
368+
export type CfPlacement =
369+
| { mode: "smart"; hint?: string }
370+
| { mode?: "targeted"; region: string }
371+
| { mode?: "targeted"; host: string }
372+
| { mode?: "targeted"; hostname: string };
372373

373374
export interface CfTailConsumer {
374375
service: string;

‎packages/wrangler/src/__tests__/config/configuration.test.ts‎

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1229,7 +1229,7 @@ describe("normalizeAndValidateConfig()", () => {
12291229
- Expected \\"tsconfig\\" to be of type string but got true.
12301230
- Expected \\"name\\" to be of type string, alphanumeric and lowercase with dashes only but got 111.
12311231
- Expected \\"main\\" to be of type string but got 1333.
1232-
- Expected \\"placement.mode\\" field to be one of [\\"off\\",\\"smart\\"] but got \\"INVALID\\".
1232+
- Expected \\"placement.mode\\" field to be one of [\\"off\\",\\"smart\\",\\"targeted\\"] but got \\"INVALID\\".
12331233
- The field \\"define.DEF1\\" should be a string but got 1777.
12341234
- Expected \\"no_bundle\\" to be of type boolean but got \\"INVALID\\".
12351235
- Expected \\"minify\\" to be of type boolean but got \\"INVALID\\".
@@ -4835,7 +4835,7 @@ describe("normalizeAndValidateConfig()", () => {
48354835

48364836
expect(diagnostics.renderErrors()).toMatchInlineSnapshot(`
48374837
"Processing wrangler configuration:
4838-
- \\"placement.hint\\" cannot be set if \\"placement.mode\\" is not \\"smart\\""
4838+
- \\"placement.hint\\" can only be set when \\"placement.mode\\" is \\"smart\\""
48394839
`);
48404840
});
48414841

@@ -4849,6 +4849,67 @@ describe("normalizeAndValidateConfig()", () => {
48494849

48504850
expect(diagnostics.hasErrors()).toBe(false);
48514851
});
4852+
4853+
it(`should error if placement hint object is set with placement mode "targeted"`, () => {
4854+
const { diagnostics } = normalizeAndValidateConfig(
4855+
{
4856+
placement: { mode: "targeted", hint: "wnam" },
4857+
} as unknown as RawConfig,
4858+
undefined,
4859+
undefined,
4860+
{ env: undefined }
4861+
);
4862+
4863+
expect(diagnostics.renderErrors()).toMatchInlineSnapshot(`
4864+
"Processing wrangler configuration:
4865+
- Expected \\"placement.mode\\" field to be one of [\\"off\\",\\"smart\\"] but got \\"targeted\\".
4866+
- \\"placement.hint\\" can only be set when \\"placement.mode\\" is \\"smart\\""
4867+
`);
4868+
});
4869+
4870+
it(`should not error if hostname field is set with placement mode "targeted"`, () => {
4871+
const { diagnostics } = normalizeAndValidateConfig(
4872+
{ placement: { hostname: "example.com" } },
4873+
undefined,
4874+
undefined,
4875+
{ env: undefined }
4876+
);
4877+
4878+
expect(diagnostics.hasErrors()).toBe(false);
4879+
});
4880+
4881+
it(`should not error if host field is set with placement mode "targeted"`, () => {
4882+
const { diagnostics } = normalizeAndValidateConfig(
4883+
{ placement: { host: "example.com:5432" } },
4884+
undefined,
4885+
undefined,
4886+
{ env: undefined }
4887+
);
4888+
4889+
expect(diagnostics.hasErrors()).toBe(false);
4890+
});
4891+
4892+
it(`should not error if host field is set with placement mode "targeted"`, () => {
4893+
const { diagnostics } = normalizeAndValidateConfig(
4894+
{ placement: { region: "aws:us-east-1" } },
4895+
undefined,
4896+
undefined,
4897+
{ env: undefined }
4898+
);
4899+
4900+
expect(diagnostics.hasErrors()).toBe(false);
4901+
});
4902+
4903+
it(`should error if placement has an invalid field`, () => {
4904+
const { diagnostics } = normalizeAndValidateConfig(
4905+
{ placement: { shoeSize: 13 } } as unknown as RawConfig,
4906+
undefined,
4907+
undefined,
4908+
{ env: undefined }
4909+
);
4910+
4911+
expect(diagnostics.hasErrors()).toBe(true);
4912+
});
48524913
});
48534914

48544915
describe("route & routes fields", () => {

‎packages/wrangler/src/api/pages/create-worker-bundle-contents.ts‎

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { Response } from "undici";
44
import { getBindings } from "../../deployment-bundle/bindings";
55
import { createWorkerUploadForm } from "../../deployment-bundle/create-worker-upload-form";
66
import { loadSourceMaps } from "../../deployment-bundle/source-maps";
7+
import { parseConfigPlacement } from "../../utils/placement";
78
import type { BundleResult } from "../../deployment-bundle/bundle";
89
import type { CfPlacement, Config } from "@cloudflare/workers-utils";
910
import type { Blob } from "node:buffer";
@@ -44,11 +45,13 @@ function createWorkerBundleFormData(
4445
type: workerBundle.bundleType || "esm",
4546
};
4647

47-
// The upload API only accepts an empty string or no specified placement for the "off" mode.
48-
const placement: CfPlacement | undefined =
49-
config?.placement?.mode === "smart"
50-
? { mode: "smart", hint: config.placement.hint }
51-
: undefined;
48+
let placement: CfPlacement | undefined;
49+
50+
if (config !== undefined) {
51+
placement = parseConfigPlacement(config);
52+
} else {
53+
placement = undefined;
54+
}
5255

5356
return createWorkerUploadForm({
5457
name: mainModule.name,

‎packages/wrangler/src/deploy/deploy.ts‎

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ import {
6161
import triggersDeploy from "../triggers/deploy";
6262
import { downloadWorkerConfig } from "../utils/download-worker-config";
6363
import { helpIfErrorIsSizeOrScriptStartup } from "../utils/friendly-validator-errors";
64+
import { parseConfigPlacement } from "../utils/placement";
6465
import { printBindings } from "../utils/print-bindings";
6566
import { retryOnAPIFailure } from "../utils/retry";
6667
import {
@@ -79,7 +80,6 @@ import type { RetrieveSourceMapFunction } from "../sourcemap";
7980
import type { ApiVersion, Percentage, VersionId } from "../versions/types";
8081
import type {
8182
CfModule,
82-
CfPlacement,
8383
CfWorkerInit,
8484
ComplianceConfig,
8585
Config,
@@ -788,11 +788,7 @@ See https://developers.cloudflare.com/workers/platform/compatibility-dates for m
788788
});
789789
}
790790

791-
// The upload API only accepts an empty string or no specified placement for the "off" mode.
792-
const placement: CfPlacement | undefined =
793-
config.placement?.mode === "smart"
794-
? { mode: "smart", hint: config.placement.hint }
795-
: undefined;
791+
const placement = parseConfigPlacement(config);
796792

797793
const entryPointName = path.basename(resolvedEntryPointPath);
798794
const main: CfModule = {
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import type { CfPlacement, Config } from "@cloudflare/workers-utils";
2+
3+
/**
4+
* Parse placement out of a Config
5+
*/
6+
export function parseConfigPlacement(config: Config): CfPlacement | undefined {
7+
if (config.placement) {
8+
const configPlacement = config.placement;
9+
const hint = "hint" in configPlacement ? configPlacement.hint : undefined;
10+
11+
if (!hint && configPlacement.mode === "off") {
12+
return undefined;
13+
} else if (hint || configPlacement.mode === "smart") {
14+
return { mode: "smart", hint: hint };
15+
} else {
16+
// mode is undefined or "targeted", which both map to the targeted variant
17+
// TypeScript needs explicit checks to narrow the union type
18+
if ("region" in configPlacement && configPlacement.region) {
19+
return { mode: "targeted", region: configPlacement.region };
20+
} else if ("host" in configPlacement && configPlacement.host) {
21+
return { mode: "targeted", host: configPlacement.host };
22+
} else if ("hostname" in configPlacement && configPlacement.hostname) {
23+
return { mode: "targeted", hostname: configPlacement.hostname };
24+
} else {
25+
return undefined;
26+
}
27+
}
28+
} else {
29+
return undefined;
30+
}
31+
}

0 commit comments

Comments
 (0)