Skip to content

Commit 446b838

Browse files
committed
Add support for new placement modes
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 2aec2b4 commit 446b838

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)