From 5edf22bfaf93b4fedc32b609c186efcb62d90695 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 27 Jul 2026 04:20:10 +0800 Subject: [PATCH 1/2] Add workers_dev route opt-out support Map Wrangler workers_dev to the WDL manifest and require deploy and promote acknowledgement. Render control-reported platform and route-pattern URL hints without normalizing operator patterns. Clarify workers_dev and preview_urls behavior across user and agent docs, with focused tests. Synchronize the Apache-2.0 license boilerplate with the upstream WDL repository. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 9 +- CHANGELOG.md | 6 + GUIDE-zh.md | 7 +- GUIDE.md | 18 +- LICENSE | 25 +++ README-zh.md | 8 +- README.md | 12 +- commands/deploy.js | 97 ++++++++-- docs/deploy-zh.md | 10 +- docs/deploy.md | 26 ++- docs/env-overrides-zh.md | 2 +- docs/env-overrides.md | 6 +- lib/wrangler-pack.js | 41 +++- lib/wrangler/config.js | 5 +- templates/AGENTS.md | 11 +- tests/unit/cli-deploy.test.js | 291 +++++++++++++++++++++++++++-- 16 files changed, 517 insertions(+), 57 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index ce222bd..f4da7ea 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -56,9 +56,16 @@ The control plane is canonical for unsupported runtime shapes such as unsupported workerd compatibility flags and WDL-reserved injected module names. The CLI still fails fast for cheap local cases such as Python Workers modules, unmapped top-level or selected-env Wrangler runtime/deploy keys -(`[site]`, `workers_dev`, `pages_build_output_dir`, `observability`, `limits`, +(`[site]`, `pages_build_output_dir`, `observability`, `limits`, `placement`, etc.), and ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. +For an operator-enabled routed Worker, explicit `workers_dev = false` keeps its +pattern routes active while disabling the default platform-domain URL; it +requires at least one `route` / `routes` pattern and is not inferred. +The deploy summary prints every active route-pattern URL hint, preserving the +trailing `*` on prefix patterns, and includes the platform-domain URL only while +it is enabled. Cloudflare's separate `preview_urls` field is unsupported and +rejected by the CLI. WDL-only `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and `[[services]].ns` are parsed by the CLI and removed from Wrangler's temporary bundle config; other fields retain their existing Wrangler passthrough diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d3b5f1..d684cce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ ## Unreleased +### Added + +- Routed Workers can set `workers_dev = false` to keep custom route patterns + active while disabling their platform-domain URL. Deploy summaries print the + active platform URL and route-pattern URL hints, omitting the disabled URL. + ## 1.5.1 ### Security diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 98eef5d..3be0f1a 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -232,6 +232,10 @@ https://.// 如果管理方已明确为你的 namespace 开通自定义 routing,会同时给出允许使用的 host 和 route pattern。普通 tenant 示例和首次部署不要配置 `route` / `routes`。如果 custom-host promote 因 host 已被占用而失败,请联系管理方;同一个 namespace 内的多个 Worker 仍可以在已开通的形态下按路径拆分流量。 +至少有一条 route pattern 的 Worker 可以设置 `workers_dev = false`,在保持 pattern route active 的同时关闭默认 WDL platform-domain URL。WDL 要求显式 opt out;仅声明 `route` / `routes` 不会关闭 platform URL。Deploy 摘要会输出每条 active route-pattern URL hint,并在 prefix pattern 上保留尾部 `*`,而且只在 platform-domain URL 启用时输出它。 + +Cloudflare 用 `workers_dev` 控制 Worker 的 `*.workers.dev` route;版本化 preview URL 由独立的 `preview_urls` 控制,后者默认跟随 `workers_dev`。WDL 则把 `workers_dev` 映射到 namespace 的普通服务路径 `.//`,所以迁移 `wrangler.toml` 时要重新确认。WDL 还要求至少有一条 route pattern 才能显式 opt out,不会仅因声明了 route 就自动关闭该路径。WDL 不支持 `preview_urls`,CLI 会拒绝该字段。 + ## 支持的 wrangler 配置 Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canonical validator 拒绝,包括不支持的 workerd compatibility flags 和 WDL 保留注入模块名。CLI 仍会对低成本的本地问题 fail-fast,例如 Python Workers modules,以及 `[vars]`、显式 bindings、隐式 `ASSETS` binding 之间的 runtime `env` 名称冲突。Deploy 和 secret mutation 还会校验留有 headroom 的 workerd 1 MiB `workerLoader` env budget;过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`。 @@ -244,6 +248,7 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | `[[d1_databases]]` | 支持 binding;先用 `wdl d1` 创建/管理数据库,再用 `database_id`(如果存在则优先)或 `database_name`(namespace 内唯一 alias)引用 | | `[assets] directory = "..."` | 支持;静态文件部署到平台资产服务,Worker 使用 `env.ASSETS.url(path)` 获取 URL | | `route` / `routes` | tenant self-service 暂未 GA;只有管理方明确为你的 namespace 开通自定义 host 时才使用 | +| `workers_dev` | 可选 boolean,仅用于至少有一条 `route` / `routes` pattern 的 Worker;`false` 关闭默认 WDL platform-domain URL,省略或设为 `true` 时保持启用 | | `[triggers] crons` | 支持;Cloudflare 兼容写法,按 UTC 执行 | | `[[triggers.schedules]]` | 平台扩展;每条 cron 可单独指定 `timezone`,不属于 Cloudflare 标准配置 | | `[[queues.producers]]` / `[[queues.consumers]]` | 支持生产和消费;`delivery_delay` 和 `retry_delay` 生效,`max_concurrency` 会被拒绝 | @@ -254,7 +259,7 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | Durable Objects | 支持本 worker 内 class,要求 class 列在 `[[migrations]].new_classes` 或 `[[migrations]].new_sqlite_classes`;两种写法在 WDL 都映射到 SQLite-backed DO storage。`script_name`、rename/delete migration 暂未实现。`stub.fetch()`、JSON-structured `stub.method(...args)` DO RPC、同步 `ctx.storage.sql`、alarm shim、普通 WebSocket upgrade 和 native WebSocket hibernation API surface 可用;平台级 session/cursor 恢复仍由应用自己处理 | | `[[workflows]]` | 支持当前 Worker 内定义的 workflow class。可用 `WorkflowEntrypoint`、`env..create()`、`createBatch()`、`get()`、`status()`、`pause()`/`resume()`/`restart()`/`terminate()`、`sendEvent()`、`step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`、retry、`NonRetryableError`、same-worker DO progress callback 和 runtime-observed parallel/DAG step。这是 WDL Workflows 支持,不是完整 Cloudflare Workflows parity。Instance payload、单 turn step fan-out 和并行 step 顺序都有上限;已启动的 step 必须 await。不支持 `script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer | | Analytics Engine | 暂不支持,部署时会拒绝 | -| 其他未映射的 Wrangler 绑定/配置/策略段(例如 `ai`、`vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`workers_dev`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | +| 其他未映射的 Wrangler 绑定/配置/策略段(例如 `ai`、`vectorize`、`hyperdrive`、`agent_memory`、`websearch`、`media`、`stream`、`ratelimits`、`vpc_services`、`cloudchamber`、`containers`、`wasm_modules`、`[site]`、`limits`、`placement`、`observability`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]` 和 `[[services]].ns`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 diff --git a/GUIDE.md b/GUIDE.md index f5ada40..2bd9442 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -347,6 +347,21 @@ fails because the host is already in use, contact your operator; multiple Workers in the same namespace can still split paths when that shape is enabled for you. +A Worker with at least one route pattern may set `workers_dev = false` to +disable its default WDL platform-domain URL while keeping its pattern routes +active. WDL requires this explicit opt-out; declaring `route` / `routes` +alone does not disable the platform URL. The deploy summary prints every active +route-pattern URL hint, preserving the trailing `*` on prefix patterns, and +prints the platform-domain URL only while it is enabled. + +Cloudflare uses `workers_dev` for a Worker's `*.workers.dev` route; versioned +preview URLs are controlled separately by `preview_urls`, which defaults to the +`workers_dev` setting. WDL maps `workers_dev` to +`.//`, the namespace's ordinary serving path, so +review the flag when porting a `wrangler.toml`. WDL also requires an explicit +opt-out with at least one route pattern instead of inferring it from routes. +WDL does not support `preview_urls`; the CLI rejects that field. + ## Supported Wrangler Configuration The control plane is the canonical validator for shapes that Wrangler can @@ -366,6 +381,7 @@ with `worker_env_too_large`. | `[[d1_databases]]` | Supported for bindings; create/manage databases with `wdl d1`, then reference them by `database_id` (preferred when present) or `database_name` (namespace-unique alias) | | `[assets] directory = "..."` | Supported; static files are deployed to platform assets, and the Worker gets `env.ASSETS.url(path)` | | `route` / `routes` | Not generally available for tenant self-service; use only when your operator explicitly enables a custom host for your namespace | +| `workers_dev` | Optional boolean for a Worker with at least one `route` / `routes` pattern. `false` disables the default WDL platform-domain URL; omitted or `true` keeps it enabled | | `[triggers] crons` | Supported; Cloudflare-compatible form, executed in UTC | | `[[triggers.schedules]]` | Platform extension; each cron can specify its own `timezone`; not part of standard Cloudflare configuration | | `[[queues.producers]]` / `[[queues.consumers]]` | Supported for producing and consuming queues; `delivery_delay` and `retry_delay` are honored, while `max_concurrency` is rejected | @@ -376,7 +392,7 @@ with `worker_env_too_large`. | Durable Objects | Supported for local classes listed in `[[migrations]].new_classes` or `[[migrations]].new_sqlite_classes`; both map to SQLite-backed DO storage in WDL. `script_name` and renamed/deleted migrations are not supported yet. `stub.fetch()`, JSON-structured `stub.method(...args)` DO RPC, synchronous `ctx.storage.sql`, the alarm shim, ordinary WebSocket upgrade, and the native WebSocket hibernation API surface are available; platform-level session/cursor recovery remains application-owned | | `[[workflows]]` | Supported for workflow classes defined in the current Worker. `WorkflowEntrypoint`, `env..create()`, `createBatch()`, `get()`, `status()`, `pause()`/`resume()`/`restart()`/`terminate()`, `sendEvent()`, `step.do()`/`sleep()`/`sleepUntil()`/`waitForEvent()`, retries, `NonRetryableError`, same-worker DO progress callbacks, and runtime-observed parallel/DAG steps are available. This is WDL Workflows support, not full Cloudflare Workflows parity. Instance payloads, per-turn step fan-out, and parallel step ordering are bounded; started steps must be awaited. `script_name`, cross-worker workflows, cross-worker callbacks, service-binding callbacks, and Cloudflare source-AST visualizer are unsupported | | Analytics Engine | Not currently supported; deploy fails if configured | -| Other unmapped Wrangler binding/config/policy sections (for example `ai`, `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `workers_dev`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | +| Other unmapped Wrangler binding/config/policy sections (for example `ai`, `vectorize`, `hyperdrive`, `agent_memory`, `websearch`, `media`, `stream`, `ratelimits`, `vpc_services`, `cloudchamber`, `containers`, `wasm_modules`, `[site]`, `limits`, `placement`, `observability`, `pages_build_output_dir`) | Not supported; deploy fails loudly instead of silently dropping the binding/config. The CLI error names the rejected field; the internal rejection list tracks the bundled Wrangler schema and is not reproduced exhaustively here | WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and `[[services]].ns` itself and removes these private diff --git a/LICENSE b/LICENSE index 4f6f639..55c7242 100644 --- a/LICENSE +++ b/LICENSE @@ -174,3 +174,28 @@ of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 The WDL Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README-zh.md b/README-zh.md index 4aaf6f2..e8fe1ac 100644 --- a/README-zh.md +++ b/README-zh.md @@ -8,15 +8,17 @@ ## 与 Cloudflare Workers 的关系 +**WDL 与 Cloudflare, Inc. 没有关联、背书或赞助关系。Cloudflare、Cloudflare Workers、Wrangler 和 workerd 是 Cloudflare, Inc. 的商标或注册商标。** + - 你写的就是标准 module worker(`export default { fetch }`),配普通的 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml`,pin 在 `wrangler@^4`。 - `wdl deploy` 只用 `wrangler deploy --dry-run` 做**本地打包**——不会向 Cloudflare 发送任何东西。在 WDL 平台上不要用 `wrangler deploy` 发布,真实发布走 `wdl deploy`。 -- Worker 通过平台域名上带路径前缀的 URL 提供服务: +- Worker 默认通过平台域名上带路径前缀的 URL 提供服务: ```text https://.// ``` - Worker 看到的路径已剥掉 `/` 前缀。 + Worker 看到的路径已剥掉 `/` 前缀。配置了 custom route 的 Worker 可以显式设置 `workers_dev = false`,关闭这个 URL,同时保持 route pattern active。 - 差异分三类——**更强**(单区架构带来强一致 KV 和读己之写的 D1,外加 platform bindings 这类 WDL 新增能力)、**语义差异**、**未实现**——逐能力面的对照见[兼容矩阵](https://github.com/wdl-dev/cli/blob/main/GUIDE-zh.md#兼容性总结)。 @@ -133,7 +135,7 @@ Worker/项目目录名:[如果已知就填,例如 hello-counter;不知道 4. 立刻打开并阅读新目录里的 `AGENTS.md`,再根据我的功能打开 `node_modules/@wdl-dev/cli/docs/` 下相关文档和示例。注意:session 中新生成的 `AGENTS.md` 不会自动加载,必须显式读取。 5. 根据功能修改 `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` 和 `src/`。需要第三方 API 鉴权 secret 时用 `wdl secret put --worker ` 写入,不要把 token 放进源码、Wrangler config 或 `.env`。 6. 先跑 `npm run dry-run` 修复本地 bundle 问题,再跑 `npm run deploy` 部署。 -7. 部署成功后给我 Worker URL(形态 `https://.//`)、本次改了哪些文件,以及我该如何验证。 +7. 部署成功后给我 CLI 输出的 Worker URL(启用时的平台 URL,以及所有 active route-pattern URL hint)、本次改了哪些文件,以及我该如何验证。 ``` diff --git a/README.md b/README.md index 3139d9f..d62153a 100644 --- a/README.md +++ b/README.md @@ -15,18 +15,24 @@ Workflows, secrets, and live logs — inside your own namespace. ## How it relates to Cloudflare Workers +**WDL is not affiliated with, endorsed by, or sponsored by Cloudflare, Inc. +Cloudflare, Cloudflare Workers, Wrangler, and workerd are trademarks or +registered trademarks of Cloudflare, Inc.** + - You write standard module workers (`export default { fetch }`) with a normal `wrangler.json` / `wrangler.jsonc` / `wrangler.toml`, pinned to `wrangler@^4`. - `wdl deploy` runs `wrangler deploy --dry-run` **for local bundling only** — nothing is ever sent to Cloudflare. Do not use `wrangler deploy` against a WDL platform; releases go through `wdl deploy`. -- Workers serve from a path-prefixed URL on the platform domain: +- Workers serve from a path-prefixed URL on the platform domain by default: ```text https://.// ``` - The worker sees the path with the `/` prefix stripped. + The worker sees the path with the `/` prefix stripped. A routed + Worker can explicitly set `workers_dev = false` to disable this URL while + keeping its custom route patterns active. - Differences come in three kinds — **stronger** (the single-region architecture gives strongly consistent KV and read-your-writes D1, and WDL adds @@ -171,7 +177,7 @@ Steps: 4. Immediately open and read `AGENTS.md` in the new directory, then open the relevant docs and examples under `node_modules/@wdl-dev/cli/docs/` for my feature. Note: a freshly generated `AGENTS.md` is not loaded automatically mid-session — read it explicitly. 5. Edit `wrangler.json` / `wrangler.jsonc` / `wrangler.toml` and `src/` for the feature. Push third-party API secrets with `wdl secret put --worker `; never put tokens in source, Wrangler config, or `.env`. 6. Run `npm run dry-run` first and fix local bundle issues, then deploy with `npm run deploy`. -7. After a successful deploy, give me the Worker URL (shape `https://.//`), the files you changed, and how I should verify. +7. After a successful deploy, give me the Worker URL(s) printed by the CLI (the platform URL when enabled, plus any active route-pattern URL hints), the files you changed, and how I should verify. ``` diff --git a/commands/deploy.js b/commands/deploy.js index 103028d..21395f1 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -52,10 +52,14 @@ function usageText() { * controlUrl: string, * authHeaders: Record, * }} arg - * @returns {Promise<{ version: unknown, platformDomain: unknown }>} + * @returns {Promise<{ version: unknown, platformDomain: unknown, workersDev: unknown, urls: unknown }>} */ export async function postArtifactToControl({ context, ns, workerName, manifest, controlUrl, authHeaders }) { const { stdout, stderr } = context; + const workersDevOptOutRequested = + manifest !== null && + typeof manifest === "object" && + /** @type {{ workersDev?: unknown }} */ (manifest).workersDev === false; const jsonHeaders = { "content-type": "application/json", ...authHeaders, @@ -65,7 +69,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, writeStatusLine(stdout, `[2/3] uploading ${workerName} → ${controlUrl}/ns/${ns}`); // `version` comes from the control response; keep the raw value for the // promote request body — display sites escape via writeStatusLine. - const { version, warnings } = /** @type {{ version: unknown, warnings?: DeployWarning[] }} */ ( + const { version, warnings, workersDev: deployedWorkersDev } = /** @type {{ version: unknown, warnings?: DeployWarning[], workersDev?: unknown }} */ ( await fetchDeployJson({ context, url: context.nsUrl("worker", workerName, "deploy"), @@ -82,12 +86,18 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, }) ); renderDeployWarnings(warnings, { ns, workerName, stderr }); + if (workersDevOptOutRequested && deployedWorkersDev !== false) { + throw new CliError( + "control did not confirm workers_dev = false; the uploaded version was retained but NOT promoted. " + + "Upgrade control and re-run `wdl deploy`." + ); + } writeStatusLine(stdout, `[3/3] promoting ${version}`); - /** @type {{ platformDomain?: unknown }} */ + /** @type {{ platformDomain?: unknown, workersDev?: unknown, urls?: unknown }} */ let promoteBody; try { - promoteBody = /** @type {{ platformDomain?: unknown }} */ ( + promoteBody = /** @type {{ platformDomain?: unknown, workersDev?: unknown, urls?: unknown }} */ ( await context.fetchJson( context.nsUrl("worker", workerName, "promote"), { @@ -105,7 +115,67 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, ); throw err; } - return { version, platformDomain: promoteBody.platformDomain }; + if (workersDevOptOutRequested && promoteBody.workersDev !== false) { + throw new CliError( + "control promoted the worker without preserving workers_dev = false; " + + "the platform-domain URL may still be active." + ); + } + return { + version, + platformDomain: promoteBody.platformDomain, + workersDev: promoteBody.workersDev, + urls: promoteBody.urls, + }; +} + +/** + * @param {unknown} raw + * @param {boolean} includePlatform + */ +function promotedWorkerUrls(raw, includePlatform) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return []; + const urls = /** @type {{ platform?: unknown, routes?: unknown }} */ (raw); + const out = []; + if (includePlatform && typeof urls.platform === "string") out.push(urls.platform); + if (Array.isArray(urls.routes)) { + for (const routeUrl of urls.routes) { + if (typeof routeUrl === "string") out.push(routeUrl); + } + } + return [...new Set(out)]; +} + +/** + * @param {string} rawUrl + * @param {URL} controlUrl + * @param {boolean} isLocal + */ +function displayWorkerUrl(rawUrl, controlUrl, isLocal) { + /** @type {URL} */ + let workerUrl; + try { + workerUrl = new URL(rawUrl); + } catch { + throw new CliError(`control returned an invalid Worker URL: ${escapeTerminalText(rawUrl)}`); + } + if (workerUrl.protocol !== "http:" && workerUrl.protocol !== "https:") { + throw new CliError(`control returned an invalid Worker URL: ${escapeTerminalText(rawUrl)}`); + } + // Rebuild only the origin: URL.href would normalize dot segments and change + // the operator's original route pattern. + const authorityStart = rawUrl.indexOf("://") + 3; + const suffixOffset = rawUrl.slice(authorityStart).search(/[/?#]/); + const authorityEnd = + suffixOffset === -1 ? rawUrl.length : authorityStart + suffixOffset; + if (rawUrl.slice(0, authorityEnd) !== `${workerUrl.protocol}//${workerUrl.host}`) { + throw new CliError(`control returned an invalid Worker URL: ${escapeTerminalText(rawUrl)}`); + } + const suffix = + suffixOffset === -1 ? "" : rawUrl.slice(authorityEnd); + const protocol = isLocal ? controlUrl.protocol : workerUrl.protocol; + const port = isLocal ? controlUrl.port : workerUrl.port; + return `${protocol}//${workerUrl.hostname}${port ? `:${port}` : ""}${suffix}`; } /** @@ -290,7 +360,7 @@ async function runDeploy({ values, positionals, context: baseContext }) { }); const { workerName, manifest } = await packWranglerProject(packOptions); - const { version, platformDomain } = await postArtifactToControl({ + const { version, platformDomain, workersDev, urls } = await postArtifactToControl({ context, ns, workerName, @@ -301,10 +371,15 @@ async function runDeploy({ values, positionals, context: baseContext }) { stdout(""); writeStatusLine(stdout, `✓ ${ns}/${workerName}@${version} live`); - const controlHost = new URL(controlUrl).hostname; - const isLocal = isLocalDevHost(controlHost); - if (isLocal) { - const workerUrl = new URL(controlUrl); + const parsedControlUrl = new URL(controlUrl); + const isLocal = isLocalDevHost(parsedControlUrl.hostname); + const reportedUrls = promotedWorkerUrls(urls, workersDev !== false); + if (reportedUrls.length > 0) { + for (const workerUrl of reportedUrls) { + writeStatusLine(stdout, ` ${displayWorkerUrl(workerUrl, parsedControlUrl, isLocal)}`); + } + } else if (workersDev !== false && isLocal) { + const workerUrl = new URL(parsedControlUrl); workerUrl.username = ""; workerUrl.password = ""; workerUrl.hostname = `${ns}.${platformDomain || "workers.local"}`; @@ -312,7 +387,7 @@ async function runDeploy({ values, positionals, context: baseContext }) { workerUrl.search = ""; workerUrl.hash = ""; writeStatusLine(stdout, ` ${workerUrl.href}`); - } else if (platformDomain) { + } else if (workersDev !== false && platformDomain) { writeStatusLine(stdout, ` https://${ns}.${platformDomain}/${workerName}/`); } } diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 7575e2b..d766d5e 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -52,7 +52,11 @@ https://.// Worker 看到的路径是**剥掉 `/` 之后的路径**。除非运维方明确启用,租户没有自定义路由能力;首次配置不要加 `route` / `routes`。 -对于本地开发 control host,deploy 摘要会复用 `CONTROL_URL` 的 scheme 和对外端口生成 Worker URL。`CONTROL_CONNECT_HOST` 只改变 control socket 的连接目标,不会改变输出的 Worker origin。 +运维方启用自定义路由后,至少有一条 route pattern 的 Worker 可以设置 `workers_dev = false`。Custom routes 会继续生效,但上面的 platform-domain URL 会返回 404。Deploy 摘要会输出每条 active route-pattern URL hint,并在 prefix pattern 上保留尾部 `*`,而且只在 platform-domain URL 启用时输出它。WDL 不会仅因配置了 `route` / `routes` 就推断为 opt-out。 + +Cloudflare 用 `workers_dev` 控制 Worker 的 `*.workers.dev` route;版本化 preview URL 由独立的 `preview_urls` 控制,后者默认跟随 `workers_dev`。WDL 则把该开关映射到上面的普通 platform-domain 服务路径,所以迁移 `wrangler.toml` 时要重新确认。WDL 不支持 `preview_urls`,CLI 会拒绝该字段。 + +对于本地开发 control host,deploy 摘要会为每个输出的 Worker URL 复用 `CONTROL_URL` 的 scheme 和对外端口。`CONTROL_CONNECT_HOST` 只改变 control socket 的连接目标,不会改变输出的 Worker origin。 ## 核心命令 @@ -103,7 +107,7 @@ wdl deploy . --env production 新项目应继续使用 `2026-06-17` compatibility date,除非具体功能需要更新日期。Control 会拒绝早于 `2026-04-01` 的显式日期、无效或未来日期,以及超出 bundled workerd 支持范围的日期。上游 experimental enable flags、`legacy_error_serialization` 和 `allow_irrevocable_stub_storage` 不受支持。 -**支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`[env.]`。 +**支持:** `name`、`main`、`compatibility_date` / `compatibility_flags`、`[vars]`、`[[kv_namespaces]]`、`[[d1_databases]]`、`[[durable_objects.bindings]]`、`[[workflows]]`、`[[r2_buckets]]`、`[assets] directory`、`[triggers] crons`、`[[triggers.schedules]]`(带 timezone,平台扩展)、`[[queues.producers]]` / `[[queues.consumers]]`、`[[services]]`、`[[platform_bindings]]`、`[[exports]]`、`route` / `routes`、`workers_dev`、`[env.]`。 WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]` 和 `[[services]].ns`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 @@ -111,7 +115,7 @@ WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedu Tenant JSRPC 可以序列化 `Blob` value,并把 service 或 Durable Object class stub 作为 opaque capability argument 传递。接收方可以调用被委托的目标,但不能改写 stub 携带的 host-authored caller properties。Delegated stub 只应留在内存中;WDL 不支持长期 irrevocable stub storage。 -**不支持(部署失败):** Analytics Engine。Durable Objects 仅支持同 worker class;`script_name`、rename/delete migration 暂未实现。WDL Workflows 仅支持当前 Worker 内定义的 workflow class,不是完整 Cloudflare Workflows parity;`script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 暂不支持。`route` / `routes` 仅在运维方启用时支持。Python Workers modules、不支持的 workerd compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。WDL 会忽略、且无法映射进 manifest 的顶层或所选 env Wrangler runtime/deploy 配置字段和 section 也会由 CLI 直接拒绝,包括 legacy `[site]` Workers Sites、`workers_dev`、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config field 或 section。`assets.run_worker_first` 会被静默忽略。 +**不支持(部署失败):** Analytics Engine。Durable Objects 仅支持同 worker class;`script_name`、rename/delete migration 暂未实现。WDL Workflows 仅支持当前 Worker 内定义的 workflow class,不是完整 Cloudflare Workflows parity;`script_name`、跨 worker workflow、跨 worker callback、service-binding callback 和 Cloudflare source-AST visualizer 暂不支持。`route` / `routes` 仅在运维方启用时支持。Python Workers modules、不支持的 workerd compatibility flags 和 WDL 保留注入模块名会在部署时被拒绝:CLI 会对本地 `.py` module fail-fast,workerd compatibility 与 bundle-shape policy 由 control plane canonical 判断。WDL 会忽略、且无法映射进 manifest 的顶层或所选 env Wrangler runtime/deploy 配置字段和 section 也会由 CLI 直接拒绝,包括 legacy `[site]` Workers Sites、`pages_build_output_dir`、`observability`、`limits`、`placement`,以及错误信息点名的其它 unsupported binding/config field 或 section。`assets.run_worker_first` 会被静默忽略。 Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在可路由的 tenant Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是 public/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 diff --git a/docs/deploy.md b/docs/deploy.md index ee58bf5..c8e5a31 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -97,9 +97,22 @@ The Worker sees the path **with the `/` prefix stripped**. Tenants have no custom routing capability unless the operator explicitly enables it; do not add `route` / `routes` in a first-time setup. +When an operator has enabled custom routing, a Worker with at least one route +pattern may set `workers_dev = false`. Its custom routes remain active, but the +platform-domain URL above returns 404. The deploy summary prints each active +route-pattern URL hint, preserving the trailing `*` on prefix patterns, and +prints the platform-domain URL only while it is enabled. WDL does not infer this +opt-out merely because `route` / `routes` is present. + +Cloudflare uses `workers_dev` for a Worker's `*.workers.dev` route; versioned +preview URLs are controlled separately by `preview_urls`, which defaults to the +`workers_dev` setting. WDL maps the flag to the ordinary platform-domain serving +path above, so review it when porting a `wrangler.toml`. WDL does not support +`preview_urls`; the CLI rejects that field. + For local-development control hosts, the deploy summary reuses `CONTROL_URL`'s -scheme and public port when constructing the Worker URL. `CONTROL_CONNECT_HOST` -changes only the control socket target and never the printed Worker origin. +scheme and public port for every printed Worker URL. `CONTROL_CONNECT_HOST` +changes only the control socket target and never a printed Worker origin. ## Core commands @@ -184,7 +197,8 @@ control. Upstream experimental enable flags, `legacy_error_serialization`, and `[[workflows]]`, `[[r2_buckets]]`, `[assets] directory`, `[triggers] crons`, `[[triggers.schedules]]` (with timezone, a platform extension), `[[queues.producers]]` / `[[queues.consumers]]`, `[[services]]`, -`[[platform_bindings]]`, `[[exports]]`, `[env.]`. +`[[platform_bindings]]`, `[[exports]]`, `route` / `routes`, `workers_dev`, +`[env.]`. WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and `[[services]].ns` itself and removes these private @@ -212,9 +226,9 @@ and the control plane is canonical for workerd compatibility and bundle-shape policy. Top-level or selected-environment Wrangler runtime/deploy config fields and sections that WDL would otherwise ignore are also rejected by the CLI, including -legacy `[site]` Workers Sites, `workers_dev`, `pages_build_output_dir`, -`observability`, `limits`, `placement`, and other unsupported binding/config -fields or sections named in the error. +legacy `[site]` Workers Sites, `pages_build_output_dir`, `observability`, +`limits`, `placement`, and other unsupported binding/config fields or sections +named in the error. `assets.run_worker_first` is silently ignored. Cron triggers and queue consumers are runtime dispatch features; declare them diff --git a/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index abd6174..913dc3f 100644 --- a/docs/env-overrides-zh.md +++ b/docs/env-overrides-zh.md @@ -96,7 +96,7 @@ Cloudflare Workers / Wrangler 的 `--env preview` 通常会发布带环境后缀 `[env.]` 可以覆盖多类配置,但继承规则不同: - Non-inheritable:`[env.].vars`、`[[env..kv_namespaces]]`、`[[env..d1_databases]]`、`[[env..r2_buckets]]`、`[[env..queues.*]]`、`[[env..services]]`、`[[env..workflows]]` 等。选中 env 后,顶层同类配置不会回退进来。 -- Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`[[migrations]]`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。 +- Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`workers_dev`、`[[migrations]]`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。 因此,共享的 `vars` 或 binding 不能只放顶层后期待所有 env 自动继承;每个 env 都需要声明自己要用的 runtime vars 和 bindings。共享的 DO migrations、assets / cron 等可放顶层,只在差异 env 下覆盖。 diff --git a/docs/env-overrides.md b/docs/env-overrides.md index ad54754..6641a56 100644 --- a/docs/env-overrides.md +++ b/docs/env-overrides.md @@ -128,9 +128,9 @@ differ: `[[env..workflows]]`, etc. Once an env is selected, top-level config of the same kind does not fall back in. - Inheritable: `main`, `compatibility_date` / `compatibility_flags`, `route` / - `routes`, `[[migrations]]`, `[assets]`, `[triggers]`, etc. When the env does - not set them, the top-level value keeps applying; when the env sets them, it - overrides the top-level value. + `routes`, `workers_dev`, `[[migrations]]`, `[assets]`, `[triggers]`, etc. When + the env does not set them, the top-level value keeps applying; when the env + sets them, it overrides the top-level value. So shared `vars` or bindings cannot live only at the top level in the expectation that every env inherits them; each env must declare the runtime vars diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index 2c2cc05..0330483 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -93,6 +93,7 @@ const WRANGLER_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024; * @property {unknown} [compatibilityDate] * @property {unknown} [compatibilityFlags] * @property {string[]} [routes] + * @property {boolean} [workersDev] * @property {Array<{ cron: string, timezone: string }>} [crons] * @property {import("./wrangler/bindings.js").QueueConsumer[]} [queueConsumers] * @property {Array<{ name: string, binding: string, className: string }>} [workflows] @@ -154,6 +155,7 @@ export async function packWranglerProject({ const loadedConfig = wrapCli(() => loadWranglerConfig(absProject)); const { path: configPath, cfg: rawCfg } = loadedConfig; const configRel = path.basename(configPath); + const shownConfig = escapeTerminalText(configRel); const shadowWarning = formatWranglerConfigShadowWarning(loadedConfig); if (shadowWarning) stderr(`warning: ${shadowWarning}`); const { cfg, envName } = wrapCli(() => { @@ -164,9 +166,9 @@ export async function packWranglerProject({ // temp name, so Wrangler never checks the original cfg.name — a non-string // would otherwise be asserted as the string workerName below. if (typeof cfg.name !== "string" || !cfg.name.trim()) { - throw new CliError(`${configRel}: 'name' must be a non-empty string`); + throw new CliError(`${shownConfig}: 'name' must be a non-empty string`); } - if (!cfg.main) throw new CliError(`${configRel} missing 'main'`); + if (!cfg.main) throw new CliError(`${shownConfig} missing 'main'`); const bindings = manifestMap(); // Every name a worker binds (manifest bindings, workflows, platform @@ -248,8 +250,10 @@ export async function packWranglerProject({ // it before bundling instead of letting asRecord() null it out and silently // skip assets. if (cfg.assets != null && asRecord(cfg.assets) == null) { - throw new CliError(`${configRel}: [assets] must be a table`); + throw new CliError(`${shownConfig}: [assets] must be a table`); } + const routes = collectRoutes(cfg, configRel); + const workersDev = parseWorkersDev(cfg, routes, configRel); const outDir = path.join(absProject, ".deploy-dist"); rmSync(outDir, { recursive: true, force: true }); @@ -314,8 +318,8 @@ export async function packWranglerProject({ if (cfg.compatibility_date) manifest.compatibilityDate = cfg.compatibility_date; if (cfg.compatibility_flags) manifest.compatibilityFlags = cfg.compatibility_flags; - const routes = collectRoutes(cfg, configRel); if (routes.length) manifest.routes = routes; + if (!workersDev) manifest.workersDev = false; const crons = wrapCli(() => parseTriggers(cfg.triggers, configRel)); if (crons.length) manifest.crons = crons; @@ -370,7 +374,7 @@ export async function packWranglerProject({ * @returns {string[]} */ export function collectRoutes(cfg, configRel) { - configRel = escapeTerminalText(configRel); + const shownConfig = escapeTerminalText(configRel); /** @type {string[]} */ const collected = []; /** @@ -384,20 +388,43 @@ export function collectRoutes(cfg, configRel) { } else throw new CliError(`unsupported ${source} entry: ${formatDiagnosticValue(r)}`); }; if (cfg.route !== undefined && cfg.routes !== undefined) { - throw new CliError(`${configRel}: specify either "route" or "routes", not both`); + throw new CliError(`${shownConfig}: specify either "route" or "routes", not both`); } if (cfg.route !== undefined) pushEntry(cfg.route, "route"); if (cfg.routes !== undefined) { // Loudly reject a non-array `routes` rather than silently dropping it (a // worker would deploy with no routes). Matches the other parsers' contract. if (!Array.isArray(cfg.routes)) { - throw new CliError(`${configRel}: "routes" must be an array of strings or { pattern } tables`); + throw new CliError(`${shownConfig}: "routes" must be an array of strings or { pattern } tables`); } for (const r of cfg.routes) pushEntry(r, "routes"); } return collected; } +/** + * WDL keeps the platform-domain route enabled unless the project explicitly + * opts out. An opt-out requires at least one pattern route so deploy cannot + * accidentally make the worker unreachable. + * @param {import("./wrangler/config.js").WranglerConfig} cfg + * @param {string[]} routes + * @param {string} configRel + * @returns {boolean} + */ +export function parseWorkersDev(cfg, routes, configRel) { + if (cfg.workers_dev === undefined) return true; + const shownConfig = escapeTerminalText(configRel); + if (typeof cfg.workers_dev !== "boolean") { + throw new CliError(`${shownConfig}: "workers_dev" must be a boolean`); + } + if (!cfg.workers_dev && routes.length === 0) { + throw new CliError( + `${shownConfig}: workers_dev = false requires at least one route pattern` + ); + } + return cfg.workers_dev; +} + /** * @param {string} filePath * @param {{ diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 34c0ff7..c53336a 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -11,7 +11,7 @@ import { asRecord } from "./utils.js"; * parser re-validates the value it reads. Known sections (`name`, `main`, * `kv_namespaces`, `d1_databases`, `r2_buckets`, `services`, * `durable_objects`, `migrations`, `workflows`, `queues`, `exports`, - * `platform_bindings`, `vars`, `triggers`, `route`, `routes`, `assets`, + * `platform_bindings`, `vars`, `triggers`, `route`, `routes`, `workers_dev`, `assets`, * `compatibility_date`, `compatibility_flags`, `env`, and the unsupported * sections rejected by name) are read off this object and narrowed at the use * site, so the honest value type is `unknown`. @@ -80,14 +80,13 @@ const UNSUPPORTED_WRANGLER_KEYS = [ "wasm_modules", "websearch", "worker_loaders", - "workers_dev", ]; const SUPPORTED_WRANGLER_SUMMARY = "Supported: [[kv_namespaces]], [[d1_databases]], [[r2_buckets]], [[services]], " + "[[durable_objects.bindings]], [[workflows]], [[queues.producers]], [[queues.consumers]], " + "[[platform_bindings]], [[exports]], [vars], [triggers], assets.directory, route(s), " + - "compatibility_date/compatibility_flags."; + "workers_dev, compatibility_date/compatibility_flags."; const NON_INHERITABLE_ENV_KEYS = new Set([ "define", diff --git a/templates/AGENTS.md b/templates/AGENTS.md index 24fd55d..f993e3a 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -49,9 +49,16 @@ enable flags, `legacy_error_serialization`, and plane is canonical for unsupported runtime shapes such as unsupported workerd compatibility flags and WDL-reserved injected module names. The CLI still fails fast for cheap local cases such as Python Workers modules, unmapped top-level or -selected-env Wrangler runtime/deploy keys (`[site]`, `workers_dev`, -`pages_build_output_dir`, etc.), and ambiguous runtime `env` name collisions +selected-env Wrangler runtime/deploy keys (`[site]`, `pages_build_output_dir`, +etc.), and ambiguous runtime `env` name collisions between `[vars]`, explicit bindings, and the implicit `ASSETS` binding. +For an operator-enabled routed Worker, explicit `workers_dev = false` keeps its +pattern routes active while disabling the default platform-domain URL; it +requires at least one `route` / `routes` pattern and is not inferred. +The deploy summary prints every active route-pattern URL hint, preserving the +trailing `*` on prefix patterns, and includes the platform-domain URL only while +it is enabled. Cloudflare's separate `preview_urls` field is unsupported and +rejected by the CLI. ## Runnable end-to-end examples diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index a041152..c6ef44e 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -29,6 +29,7 @@ import { parseR2BucketsFromCfg, parseServicesFromCfg, parseTriggers, + parseWorkersDev, parseWorkflowsFromCfg, parseWranglerMajorVersion, resolveAssetsDir, @@ -555,6 +556,23 @@ test("collectRoutes: accepts strings and { pattern } tables, rejects non-arrays" ); }); +test("parseWorkersDev requires an explicit boolean and a route for opt-out", () => { + assert.equal(parseWorkersDev({}, [], "wrangler.toml"), true); + assert.equal(parseWorkersDev({ workers_dev: true }, [], "wrangler.toml"), true); + assert.equal( + parseWorkersDev({ workers_dev: false }, ["app.example/*"], "wrangler.toml"), + false + ); + assert.throws( + () => parseWorkersDev({ workers_dev: "false" }, ["app.example/*"], "wrangler.toml"), + /"workers_dev" must be a boolean/ + ); + assert.throws( + () => parseWorkersDev({ workers_dev: false }, [], "wrangler.toml"), + /requires at least one route pattern/ + ); +}); + test("parseKvNamespacesFromCfg: validates shape and non-empty string binding/id", () => { assert.deepEqual(parseKvNamespacesFromCfg({}), []); assert.deepEqual(parseKvNamespacesFromCfg({ kv_namespaces: [] }), []); @@ -1354,6 +1372,8 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab services: [{ binding: "AUTH", service: "auth" }], queues: { producers: [{ binding: "Q", queue: "top-q" }] }, assets: { directory: "./top-public" }, + route: "api.example/*", + workers_dev: false, env: { prod: { vars: { ENV: "prod" }, @@ -1368,6 +1388,8 @@ test("resolveWranglerConfig: non-inheritable keys are env-scoped while inheritab assert.deepEqual(cfg.queues, { consumers: [{ queue: "jobs" }] }); assert.equal(cfg.services, undefined); assert.deepEqual(cfg.assets, { directory: "./top-public" }); + assert.equal(cfg.route, "api.example/*"); + assert.equal(cfg.workers_dev, false); }); test("resolveWranglerConfig: selected environment can override inherited assets", () => { @@ -1385,6 +1407,21 @@ test("resolveWranglerConfig: selected environment can override inherited assets" assert.deepEqual(cfg.assets, { directory: "./prod-public" }); }); +test("resolveWranglerConfig: selected environment can override inherited workers_dev", () => { + const { cfg } = resolveWranglerConfig({ + name: "demo", + main: "src/index.js", + workers_dev: true, + env: { + prod: { + workers_dev: false, + }, + }, + }, "prod", "wrangler.jsonc"); + + assert.equal(cfg.workers_dev, false); +}); + test("resolveWranglerConfig: selected environment can override durable object migrations", () => { const { cfg } = resolveWranglerConfig({ name: "demo", @@ -1614,7 +1651,6 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy "legacy_env", "preview_urls", "upload_source_maps", - "workers_dev", ]); for (const key of [ "addresses", @@ -1651,7 +1687,6 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy "vpc_services", "websearch", "worker_loaders", - "workers_dev", ]) { assert.throws( () => validateUnsupportedWranglerConfig({ @@ -1663,15 +1698,6 @@ test("validateUnsupportedWranglerConfig rejects unmapped wrangler runtime/deploy ); } - assert.throws( - () => validateUnsupportedWranglerConfig({ - name: "demo", - main: "src/index.js", - workers_dev: false, - }, null, "wrangler.toml"), - /unsupported Wrangler field "workers_dev"/ - ); - assert.throws( () => validateUnsupportedWranglerConfig({ name: "demo", @@ -2642,7 +2668,14 @@ test("runDeployCommand preserves the local control scheme and port in the Worker fetchCount += 1; return fetchCount === 1 ? response({ version: "v1", warnings: [] }) - : response({ platformDomain: "workers.local" }); + : response({ + platformDomain: "workers.local", + workersDev: true, + urls: { + platform: "https://demo.workers.local/api/", + routes: [], + }, + }); }, } ); @@ -2654,6 +2687,240 @@ test("runDeployCommand preserves the local control scheme and port in the Worker } }); +test("runDeployCommand preserves prefix and exact route-pattern URL hints", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-route-urls-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "api"', + 'main = "src/index.js"', + 'routes = ["api.example/apiv1/*", "api.example/mcp"]', + ].join("\n")); + + /** @type {string[]} */ + const lines = []; + let fetchCount = 0; + await runDeployCommand( + [dir, "--ns", "demo", "--control-url", "https://control.example"], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [], workersDev: true }) + : response({ + platformDomain: "workers.example", + workersDev: true, + urls: { + platform: "https://demo.workers.example/api/", + routes: [ + "https://api.example/apiv1/*", + "https://api.example/mcp", + ], + }, + }); + }, + } + ); + + assert.ok(lines.includes(" https://demo.workers.example/api/")); + assert.ok(lines.includes(" https://api.example/apiv1/*")); + assert.ok(lines.includes(" https://api.example/mcp")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand rejects ambiguous control-reported Worker URL authorities", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-route-url-authority-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "api"', + 'main = "src/index.js"', + 'route = "app.example/hello/*"', + ].join("\n")); + + let fetchCount = 0; + await assert.rejects( + runDeployCommand( + [dir, "--ns", "demo", "--control-url", "https://control.example"], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [], workersDev: true }) + : response({ + platformDomain: "workers.example", + workersDev: true, + urls: { + platform: "https://demo.workers.example/api/", + routes: ["https://app.example\\evil/hello/*"], + }, + }); + }, + } + ), + /control returned an invalid Worker URL/ + ); + assert.equal(fetchCount, 2); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand sends workers_dev opt-out and prints only route-pattern URL hints", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-workers-dev-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "api"', + 'main = "src/index.js"', + 'workers_dev = false', + 'route = "app.example/a/../b/*"', + ].join("\n")); + + for (const { label, controlUrl, platformDomain } of [ + { label: "remote", controlUrl: "https://control.example", platformDomain: "workers.example" }, + { label: "local", controlUrl: "http://localhost:8443", platformDomain: "workers.local" }, + ]) { + /** @type {string[]} */ + const lines = []; + /** @type {RecordedFetch[]} */ + const fetchCalls = []; + await runDeployCommand( + [dir, "--ns", "demo", "--control-url", controlUrl], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(/** @type {string} */ line), + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + fetchCalls.push({ url, init }); + return fetchCalls.length === 1 + ? response({ version: "v1", warnings: [], workersDev: false }) + : response({ + platformDomain, + workersDev: false, + urls: { routes: ["https://app.example/a/../b/*"] }, + }); + }, + } + ); + + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.deepEqual(manifest.routes, ["app.example/a/../b/*"]); + assert.equal(manifest.workersDev, false); + assert.ok(lines.includes("✓ demo/api@v1 live")); + assert.equal( + lines.some((line) => line.includes(platformDomain)), + false, + `${label} platform URL` + ); + assert.ok(lines.includes( + label === "local" + ? " http://app.example:8443/a/../b/*" + : " https://app.example/a/../b/*" + )); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand does not promote when control omits the workers_dev opt-out acknowledgement", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-workers-dev-skew-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "api"', + 'main = "src/index.js"', + 'workers_dev = false', + 'route = "app.example/*"', + ].join("\n")); + + /** @type {RecordedFetch[]} */ + const fetchCalls = []; + await assert.rejects( + runDeployCommand( + [dir, "--ns", "demo", "--control-url", "https://control.example"], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + fetchCalls.push({ url, init }); + return response({ version: "v1", warnings: [] }); + }, + } + ), + /control did not confirm workers_dev = false.*NOT promoted/ + ); + assert.equal(fetchCalls.length, 1); + assert.match(fetchCalls[0].url, /\/deploy$/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand fails when promote does not preserve the workers_dev opt-out", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-workers-dev-promote-skew-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), [ + 'name = "api"', + 'main = "src/index.js"', + 'workers_dev = false', + 'route = "app.example/*"', + ].join("\n")); + + /** @type {RecordedFetch[]} */ + const fetchCalls = []; + await assert.rejects( + runDeployCommand( + [dir, "--ns", "demo", "--control-url", "https://control.example"], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async (/** @type {string} */ url, /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {}) => { + fetchCalls.push({ url, init }); + return fetchCalls.length === 1 + ? response({ version: "v1", warnings: [], workersDev: false }) + : response({ + platformDomain: "workers.example", + workersDev: true, + urls: { + platform: "https://demo.workers.example/api/", + routes: ["https://app.example/*"], + }, + }); + }, + } + ), + /control promoted the worker without preserving workers_dev = false/ + ); + assert.equal(fetchCalls.length, 2); + assert.match(fetchCalls[1].url, /\/promote$/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand detects local control by hostname only", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-nonlocal-host-")); try { From ca3e755e205a772bff9319cd424ac451aecb9beb Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Mon, 27 Jul 2026 11:18:47 +0800 Subject: [PATCH 2/2] Harden promoted Worker URL hint rendering Validate control-reported URL hints before displaying them. Warn and omit invalid hints without failing an already promoted deploy. Fall back to the derived platform URL when its reported hint is absent or invalid. Cover canonical authorities, ambiguous host rewrites, and mixed valid and invalid responses. Signed-off-by: Lu Zhang --- commands/deploy.js | 67 ++++++++++++++++++-------- tests/unit/cli-deploy.test.js | 89 ++++++++++++++++++++++++----------- 2 files changed, 109 insertions(+), 47 deletions(-) diff --git a/commands/deploy.js b/commands/deploy.js index 21395f1..adce6e9 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -5,7 +5,7 @@ import { execFileSync } from "node:child_process"; import { LONG_CONTROL_TIMEOUT_MS } from "../lib/control-fetch.js"; import { defineCommand } from "../lib/command.js"; import { CliError, defineCliOption, formatHelp, formatHttpError, isMain, optionHelp, readJsonOrFail, unexpectedArgument } from "../lib/common.js"; -import { escapeTerminalText, formatKnownWarning, shellArgForDisplay, writeStatusLine } from "../lib/output.js"; +import { escapeTerminalText, formatDiagnosticValue, formatKnownWarning, shellArgForDisplay, writeStatusLine } from "../lib/output.js"; import { isLocalDevHost } from "../lib/credentials.js"; import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; @@ -132,24 +132,32 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, /** * @param {unknown} raw * @param {boolean} includePlatform + * @returns {{ platform: string | null, routes: string[] }} */ -function promotedWorkerUrls(raw, includePlatform) { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return []; +function promotedWorkerUrlHints(raw, includePlatform) { + if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + return { platform: null, routes: [] }; + } const urls = /** @type {{ platform?: unknown, routes?: unknown }} */ (raw); - const out = []; - if (includePlatform && typeof urls.platform === "string") out.push(urls.platform); + const platform = + includePlatform && typeof urls.platform === "string" ? urls.platform : null; + const seen = new Set(platform === null ? [] : [platform]); + const routes = []; if (Array.isArray(urls.routes)) { for (const routeUrl of urls.routes) { - if (typeof routeUrl === "string") out.push(routeUrl); + if (typeof routeUrl !== "string" || seen.has(routeUrl)) continue; + seen.add(routeUrl); + routes.push(routeUrl); } } - return [...new Set(out)]; + return { platform, routes }; } /** * @param {string} rawUrl * @param {URL} controlUrl * @param {boolean} isLocal + * @returns {string | null} */ function displayWorkerUrl(rawUrl, controlUrl, isLocal) { /** @type {URL} */ @@ -157,20 +165,20 @@ function displayWorkerUrl(rawUrl, controlUrl, isLocal) { try { workerUrl = new URL(rawUrl); } catch { - throw new CliError(`control returned an invalid Worker URL: ${escapeTerminalText(rawUrl)}`); + return null; } if (workerUrl.protocol !== "http:" && workerUrl.protocol !== "https:") { - throw new CliError(`control returned an invalid Worker URL: ${escapeTerminalText(rawUrl)}`); + return null; } - // Rebuild only the origin: URL.href would normalize dot segments and change - // the operator's original route pattern. const authorityStart = rawUrl.indexOf("://") + 3; const suffixOffset = rawUrl.slice(authorityStart).search(/[/?#]/); const authorityEnd = suffixOffset === -1 ? rawUrl.length : authorityStart + suffixOffset; if (rawUrl.slice(0, authorityEnd) !== `${workerUrl.protocol}//${workerUrl.host}`) { - throw new CliError(`control returned an invalid Worker URL: ${escapeTerminalText(rawUrl)}`); + return null; } + // Normalize only the origin. URL.href would also normalize dot segments and + // change the operator's original route pattern. const suffix = suffixOffset === -1 ? "" : rawUrl.slice(authorityEnd); const protocol = isLocal ? controlUrl.protocol : workerUrl.protocol; @@ -369,15 +377,28 @@ async function runDeploy({ values, positionals, context: baseContext }) { authHeaders, }); - stdout(""); - writeStatusLine(stdout, `✓ ${ns}/${workerName}@${version} live`); const parsedControlUrl = new URL(controlUrl); const isLocal = isLocalDevHost(parsedControlUrl.hostname); - const reportedUrls = promotedWorkerUrls(urls, workersDev !== false); - if (reportedUrls.length > 0) { - for (const workerUrl of reportedUrls) { - writeStatusLine(stdout, ` ${displayWorkerUrl(workerUrl, parsedControlUrl, isLocal)}`); - } + const reportedUrlHints = promotedWorkerUrlHints(urls, workersDev !== false); + const invalidUrlHints = []; + const displayedPlatformUrl = + reportedUrlHints.platform === null + ? null + : displayWorkerUrl(reportedUrlHints.platform, parsedControlUrl, isLocal); + if (reportedUrlHints.platform !== null && displayedPlatformUrl === null) { + invalidUrlHints.push(reportedUrlHints.platform); + } + const displayedRouteUrls = []; + for (const workerUrl of reportedUrlHints.routes) { + const displayedUrl = displayWorkerUrl(workerUrl, parsedControlUrl, isLocal); + if (displayedUrl === null) invalidUrlHints.push(workerUrl); + else displayedRouteUrls.push(displayedUrl); + } + + stdout(""); + writeStatusLine(stdout, `✓ ${ns}/${workerName}@${version} live`); + if (displayedPlatformUrl !== null) { + writeStatusLine(stdout, ` ${displayedPlatformUrl}`); } else if (workersDev !== false && isLocal) { const workerUrl = new URL(parsedControlUrl); workerUrl.username = ""; @@ -390,6 +411,14 @@ async function runDeploy({ values, positionals, context: baseContext }) { } else if (workersDev !== false && platformDomain) { writeStatusLine(stdout, ` https://${ns}.${platformDomain}/${workerName}/`); } + for (const workerUrl of displayedRouteUrls) writeStatusLine(stdout, ` ${workerUrl}`); + for (const workerUrl of invalidUrlHints) { + writeStatusLine( + stderr, + "warning: deployment succeeded, but control returned an invalid Worker URL hint; " + + `omitted from output: ${formatDiagnosticValue(workerUrl)}` + ); + } } if (isMain(import.meta.url)) { diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index c6ef44e..277a1e7 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -2687,7 +2687,7 @@ test("runDeployCommand preserves the local control scheme and port in the Worker } }); -test("runDeployCommand preserves prefix and exact route-pattern URL hints", async () => { +test("runDeployCommand preserves canonical URL authorities and route-pattern suffixes", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-route-urls-")); try { mkdirSync(path.join(dir, "src"), { recursive: true }); @@ -2695,7 +2695,7 @@ test("runDeployCommand preserves prefix and exact route-pattern URL hints", asyn writeFileSync(path.join(dir, "wrangler.toml"), [ 'name = "api"', 'main = "src/index.js"', - 'routes = ["api.example/apiv1/*", "api.example/mcp"]', + 'routes = ["api.example/apiv1/*", "api.example/mcp", "127.0.0.1/ip"]', ].join("\n")); /** @type {string[]} */ @@ -2716,10 +2716,10 @@ test("runDeployCommand preserves prefix and exact route-pattern URL hints", asyn platformDomain: "workers.example", workersDev: true, urls: { - platform: "https://demo.workers.example/api/", routes: [ "https://api.example/apiv1/*", "https://api.example/mcp", + "https://127.0.0.1/ip", ], }, }); @@ -2730,12 +2730,13 @@ test("runDeployCommand preserves prefix and exact route-pattern URL hints", asyn assert.ok(lines.includes(" https://demo.workers.example/api/")); assert.ok(lines.includes(" https://api.example/apiv1/*")); assert.ok(lines.includes(" https://api.example/mcp")); + assert.ok(lines.includes(" https://127.0.0.1/ip")); } finally { rmSync(dir, { recursive: true, force: true }); } }); -test("runDeployCommand rejects ambiguous control-reported Worker URL authorities", async () => { +test("runDeployCommand omits non-canonical URL hints without failing a promoted deploy", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-route-url-authority-")); try { mkdirSync(path.join(dir, "src"), { recursive: true }); @@ -2746,33 +2747,65 @@ test("runDeployCommand rejects ambiguous control-reported Worker URL authorities 'route = "app.example/hello/*"', ].join("\n")); + const invalidRouteUrls = [ + "HTTPS://API.EXAMPLE:443/apiv1/*", + "https://bücher.example/mcp", + "https://app.example\\evil/hello/*", + "https://user@app.example/hello/*", + "https://app.example\t/hello/*", + "https://127.1/hello/*", + "https://2130706433/hello/*", + "https://0x7f.0.0.1/hello/*", + "https://①②⑦.⓪.⓪.①/hello/*", + ]; + const invalidPlatformUrl = "HTTPS://DEMO.WORKERS.EXAMPLE:443/api/"; + /** @type {string[]} */ + const lines = []; + /** @type {string[]} */ + const warnings = []; let fetchCount = 0; - await assert.rejects( - runDeployCommand( - [dir, "--ns", "demo", "--control-url", "https://control.example"], - { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: () => {}, - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetchCount += 1; - return fetchCount === 1 - ? response({ version: "v1", warnings: [], workersDev: true }) - : response({ - platformDomain: "workers.example", - workersDev: true, - urls: { - platform: "https://demo.workers.example/api/", - routes: ["https://app.example\\evil/hello/*"], - }, - }); - }, - } - ), - /control returned an invalid Worker URL/ + await runDeployCommand( + [dir, "--ns", "demo", "--control-url", "https://control.example"], + { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + stderr: (/** @type {string} */ line) => warnings.push(line), + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + return fetchCount === 1 + ? response({ version: "v1", warnings: [], workersDev: true }) + : response({ + platformDomain: "workers.example", + workersDev: true, + urls: { + platform: invalidPlatformUrl, + routes: [ + "https://valid.example/ok/*", + ...invalidRouteUrls, + "https://valid.example/after/*", + ], + }, + }); + }, + } ); + assert.equal(fetchCount, 2); + assert.ok(lines.includes("✓ demo/api@v1 live")); + assert.deepEqual( + lines.filter((line) => line.startsWith(" http")), + [ + " https://demo.workers.example/api/", + " https://valid.example/ok/*", + " https://valid.example/after/*", + ] + ); + assert.equal(warnings.length, invalidRouteUrls.length + 1); + for (const warning of warnings) { + assert.match(warning, /warning: deployment succeeded, but control returned an invalid Worker URL hint/); + assertNoRawTerminalControls(warning, "invalid Worker URL warning"); + } } finally { rmSync(dir, { recursive: true, force: true }); }