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..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"; @@ -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,75 @@ 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 + * @returns {{ platform: string | null, routes: string[] }} + */ +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 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" || seen.has(routeUrl)) continue; + seen.add(routeUrl); + routes.push(routeUrl); + } + } + return { platform, routes }; +} + +/** + * @param {string} rawUrl + * @param {URL} controlUrl + * @param {boolean} isLocal + * @returns {string | null} + */ +function displayWorkerUrl(rawUrl, controlUrl, isLocal) { + /** @type {URL} */ + let workerUrl; + try { + workerUrl = new URL(rawUrl); + } catch { + return null; + } + if (workerUrl.protocol !== "http:" && workerUrl.protocol !== "https:") { + return null; + } + 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}`) { + 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; + const port = isLocal ? controlUrl.port : workerUrl.port; + return `${protocol}//${workerUrl.hostname}${port ? `:${port}` : ""}${suffix}`; } /** @@ -290,7 +368,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, @@ -299,12 +377,30 @@ async function runDeploy({ values, positionals, context: baseContext }) { authHeaders, }); + const parsedControlUrl = new URL(controlUrl); + const isLocal = isLocalDevHost(parsedControlUrl.hostname); + 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`); - const controlHost = new URL(controlUrl).hostname; - const isLocal = isLocalDevHost(controlHost); - if (isLocal) { - const workerUrl = new URL(controlUrl); + if (displayedPlatformUrl !== null) { + writeStatusLine(stdout, ` ${displayedPlatformUrl}`); + } else if (workersDev !== false && isLocal) { + const workerUrl = new URL(parsedControlUrl); workerUrl.username = ""; workerUrl.password = ""; workerUrl.hostname = `${ns}.${platformDomain || "workers.local"}`; @@ -312,9 +408,17 @@ 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}/`); } + 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/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..277a1e7 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,273 @@ test("runDeployCommand preserves the local control scheme and port in the Worker } }); +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 }); + 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", "127.0.0.1/ip"]', + ].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: { + routes: [ + "https://api.example/apiv1/*", + "https://api.example/mcp", + "https://127.0.0.1/ip", + ], + }, + }); + }, + } + ); + + 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 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 }); + 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")); + + 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 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 }); + } +}); + +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 {