From 8e776385d1ed3bed354effe40da50a2bcf12930d Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Wed, 5 Aug 2026 23:00:17 +0800 Subject: [PATCH 1/5] Support opt-in session restarts through [wdl] session_policy Parse the private [wdl] table, strip it from the temporary Wrangler config, and send sessionPolicy on the deploy manifest, verifying that control echoes the policy on deploy and confirms it with a restart sequence on promote so an older control plane cannot apply it silently. Inherit the policy into [env.] like workers_dev rather than treating it as a binding, and reject a TOML datetime as a table in asRecord so a bare date can no longer stand in for [wdl], [triggers], [durable_objects], or an env table. Document the policy in the deploy chapter with facet-level detail in the Durable Objects chapter, in English and Chinese, and share one deploy-then-promote control stub across the deploy tests. Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 32 +- CHANGELOG.md | 9 + GUIDE-zh.md | 22 +- GUIDE.md | 103 +++--- commands/deploy.js | 38 ++- docs/README-zh.md | 1 + docs/README.md | 1 + docs/deploy-zh.md | 19 +- docs/deploy.md | 80 +++-- docs/durable-objects-zh.md | 15 + docs/durable-objects.md | 37 +++ docs/env-overrides-zh.md | 2 +- docs/env-overrides.md | 6 +- docs/secrets-zh.md | 2 +- docs/secrets.md | 4 +- lib/output.js | 4 + lib/wrangler-pack.js | 5 + lib/wrangler/config.js | 45 ++- lib/wrangler/utils.js | 4 +- templates/AGENTS.md | 4 + tests/unit/cli-deploy.test.js | 499 +++++++++++++++++++++++------ tests/unit/cli-output.test.js | 17 +- tests/unit/helpers.js | 20 ++ 23 files changed, 764 insertions(+), 205 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 43edcff..c3bd3ec 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -25,7 +25,8 @@ Open the relevant doc before answering: credential resolution. - `docs/d1.md` — `[[d1_databases]]` config, `wdl d1` commands, migrations. - `docs/durable-objects.md` — `[[durable_objects.bindings]]`, migration class - declarations, the DO runtime surface. + declarations, the DO runtime surface, and what a restart session policy does + to facets and alarms. - `docs/r2.md` — `[[r2_buckets]]` config, `wdl r2` commands, R2 cleanup after worker deletion. - `docs/queues.md` — `[[queues.producers]]` / `[[queues.consumers]]` config, @@ -65,18 +66,23 @@ 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 behavior. -Wrangler's object-shaped declarative `exports` config is unsupported. The -dry-run child hides Wrangler's banner (and its normal update check) and disables -anonymous telemetry. Wrangler may still consult the configured npm registry when -reporting an unknown configuration field; project build hooks retain their -normal network access. For `[[services]]` and `[[exports]]`, read -`docs/deploy.md`: tenant JSRPC may delegate service or Durable Object class -stubs as opaque capabilities, but the receiver cannot rewrite their -host-authored caller properties. Keep delegated stubs in memory; long-term -irrevocable stub storage is unsupported. +`[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +`[[services]].ns`, and `[wdl]` are parsed by the CLI and removed from Wrangler's +temporary bundle config; other fields retain their existing Wrangler passthrough +behavior. `[wdl] session_policy` accepts `preserve` or `restart`. The default +`preserve` leaves loaded Durable Object facets on the version that built them +until the host actor restarts or the facet is deleted, and keeps established +WebSockets draining while their backend stays healthy. `restart` closes the +worker's open WebSockets with code `1012` at promotion and retires stale facets +on their next dispatch, preserving SQLite state. Wrangler's object-shaped +declarative `exports` config is unsupported. The dry-run child hides Wrangler's +banner (and its normal update check) and disables anonymous telemetry. Wrangler +may still consult the configured npm registry when reporting an unknown +configuration field; project build hooks retain their normal network access. For +`[[services]]` and `[[exports]]`, read `docs/deploy.md`: tenant JSRPC may +delegate service or Durable Object class stubs as opaque capabilities, but the +receiver cannot rewrite their host-authored caller properties. Keep delegated +stubs in memory; long-term irrevocable stub storage is unsupported. Never recommend setting `CONTROL_CONNECT_HOST` outside local development: it overrides the TCP target the admin token connects to (Host header + TLS SNI diff --git a/CHANGELOG.md b/CHANGELOG.md index cbd8ede..487410e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +### Added + +- Added `[wdl] session_policy = "restart"` for opt-in session restarts, matching + Cloudflare's default deploy behavior. The default remains `preserve`; a + restart promotion closes the worker's open WebSockets with code `1012` and + aborts stale Durable Object facets on their next dispatch while keeping SQLite + state. `wdl deploy` refuses to promote when control does not confirm the + policy. + ## 1.6.1 ### Changed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index 3be0f1a..daed88f 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -257,11 +257,12 @@ Wrangler 能打包、但 WDL 不能运行的形状由 control plane 作为 canon | `[env.]` | 支持;用 `--env ` 或 `CLOUDFLARE_ENV` 选择;见下面的环境覆盖说明 | | `[[r2_buckets]]` | 支持常用 R2 object API,包括条件请求、range GET 和 `list({ include })`;对象存储在平台本地 R2,并按 namespace + `bucket_name` 隔离 | | 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 恢复仍由应用自己处理 | +| `[wdl]` | WDL 平台扩展表,当前含 `session_policy = "preserve" \| "restart"`(默认 `preserve`);`restart` 下 promotion 会以 `1012` 关闭该 worker 打开的 WebSocket,stale Durable Object facet 在下一次 dispatch 时中止。与 `workers_dev` 一样会被 `[env.]` 继承,除非该 env 自己声明了 `[wdl]` | | `[[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`、`pages_build_output_dir`) | 不支持;部署时显式报错,不会静默丢弃绑定/配置。CLI 报错会点名被拒字段;内部拒绝列表跟随打包的 Wrangler schema,这里不复刻完整清单 | -WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]` 和 `[[services]].ns`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 +WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 Cron triggers 和 queue consumers 是运行时 dispatch 能力。除非管理方明确给了 reserved namespace,否则只应声明在 tenant namespace 里的可路由 Worker 上。通过 `[[platform_bindings]]` 选择的 Worker 是冷加载的平台能力,不是公开/runtime dispatch 目标,不能声明 cron triggers 或 queue consumers。 @@ -275,7 +276,7 @@ R2 object key 可以包含开头、结尾或连续的 `/` 分隔符;CLI 会保 ### 环境覆盖 -如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows 等不会自动进入该 env。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 +如果 Wrangler 配置里有 `[env.]`,必须通过 `--env ` 或 `CLOUDFLARE_ENV` 显式选择;CLI 不会自动挑一个默认环境。和 Cloudflare Workers / Wrangler 不同,WDL 不会把环境名追加到 worker / script 名后面:`wdl deploy . --env preview` 仍然更新顶层 `name` 指定的 worker。`vars` 和大部分 bindings 仍是 env-scoped / non-inheritable:选中 env 后,顶层 `[vars]`、KV、D1、R2、queues、services、workflows 都不会自动进入该 env。策略类配置则会继承:`workers_dev`、`route` / `routes` 和 `[wdl]` 在 env 没有自己声明时继续生效。需要同时跑 staging / production 时,默认用不同 namespace 区分,除非管理方另有约定。 ### KV @@ -458,6 +459,15 @@ export default { }; ``` +默认情况下,promotion 会让已加载的 facet 停留在构造它的 version 上,直到 host actor 重启或 facet 被删除;已打开的 WebSocket 也会在 backend 仍健康时继续 drain。backend 丢失后不会重连已不活跃的 version,WebSocket 以 `1012` 关闭。若希望每次 promotion 都关闭打开的 WebSocket 并让旧 version facet 退役、同时保留 SQLite storage,可增加: + +```toml +[wdl] +session_policy = "restart" +``` + +此时 active WebSocket 会在 promotion 时立即以 `1012` 关闭,stale facet 则在下一次 dispatch 时中止;client 必须重连并重新执行应用握手。部署即重启是 Cloudflare 的默认行为,WDL 将其作为可选项;该策略并非 Durable Object 专属——包括纯 WebSocket worker 在内的任何 worker 都可以设置它。 + 当前支持 `stub.fetch()`、JSON-structured `stub.method(...args)` RPC、native `ctx.storage`、同步 `ctx.storage.sql`、alarm、普通 WebSocket upgrade 以及 native WebSocket hibernation API surface。跨 script binding、rename/delete migration、平台级 WebSocket session/cursor 恢复暂未实现。 DO fetch 请求体上限是 1 MiB。RPC method name 必须符合 JavaScript identifier grammar,且最多 256 ASCII bytes。RPC arguments 最多 1 MiB,只接受 structural JSON:finite value、dense array 和 plain object;序列化不会调用 `toJSON()`。Object name 和 id 必须是 well-formed Unicode;DO class name 使用 ASCII JavaScript class-name grammar,最多 468 bytes。 @@ -528,7 +538,7 @@ printf '%s' "$DATABASE_URL" | wdl secret put --scope ns DATABASE_URL 生效时机: -- 已有线上版本的 Worker 修改 worker-level secret 时,平台会自动创建并 promote 一个新版本,因此新流量会 cold-load 更新后的 secret。已经加载的历史版本可能继续持有旧值,直到 runtime eviction 或 recycle。 +- 已有线上版本的 Worker 修改 worker-level secret 时,平台会自动创建并 promote 一个新版本,因此新流量会 cold-load 更新后的 secret。已经加载的历史版本可能继续持有旧值,直到 runtime eviction 或 recycle。由于这就是一次普通 promotion,配置了 `[wdl] session_policy = "restart"` 的 Worker 在 secret 变更时同样会以 `1012` 关闭已打开的 WebSocket。 - worker-level secret 修改是原子的。如果 mutation 期间 active version 变化,control 会返回 `secret_mutation_contention`,CLI 会要求重试,而不是留下"已存储但未 promote"的半成功状态。 - `secret_encryption_unconfigured`、`secret_decrypt_failed`、`invalid_envelope`、`unsupported_envelope`、`unknown_kid` 或 `secret_not_encrypted` 这类 secret-envelope 错误表示 mutation 没有写入;等运维侧修复 envelope 配置或已存储数据后再重试。 - worker-level secret 可以在第一次部署前设置;第一次部署会读取这些 secret。 @@ -843,6 +853,8 @@ wdl tail hello | Worker URL 返回 404 | URL 形态或 worker name 不对 | 使用 `https://.//`,不要漏掉 worker name 这一段路径 | | Worker URL 返回 `502 runtime_error` | Worker `fetch()` handler 在产生响应前抛错 | 用 `wdl tail ` 和请求日志排查;异常细节不会复制到客户端响应体 | | namespace-level secret 没有立刻变化 | namespace secret 不会给所有 Worker 自动 bump 版本 | 重新部署该 Worker,或等待自然 cold-load;需要立即发布时使用 worker-level secret | +| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy` | 升级 control;version 已上传但未 promote,重跑同一条 deploy 无济于事 | +| `control promoted the worker without confirming its restart session policy` | control 完成了 promote,但没有回显策略或可用的 restart 序号 | 升级 control 后重新部署;新 version 已生效,但会话可能仍固定在旧 version | | service binding 仍调用旧目标行为 | binding 在调用方部署时固定版本 | 重新部署调用方 Worker | | `wdl tail` 没有历史日志 | tail 是 live-only;首次连接只看之后的新事件 | 先打开 `wdl tail `,再触发请求;需要手动续读时使用单 worker 的 `--since ` | | 多 worker `wdl tail` 重连后可能少日志 | 一个连接无法同时保存多个 worker 的独立续读位置 | 对关键 worker 单独运行 `wdl tail ` | @@ -858,14 +870,14 @@ wdl tail hello | 能力面 | 状态 | 更强 / 新增 | 语义差异 | 未实现 | | --- | --- | --- | --- | --- | | Module Workers(`fetch` / `scheduled` / `queue`) | 支持 | — | 未捕获异常返回平台 `502 runtime_error`;异常详情进 `wdl tail` 和日志,不进响应体 | — | -| WebSocket 升级 | 支持 | — | — | 平台重启后的会话自动恢复;客户端应重连 | +| WebSocket 升级 | 支持 | 只要 pinned version 的 backend 仍健康,连接可以跨 promotion 存活(worker 选择 `session_policy = "restart"` 时除外) | Cloudflare 部署即断开全部 WebSocket;WDL 在丢失 backend 的版本已不活跃时、或 `session_policy = "restart"` 下的 promotion 时发送 `1012`,客户端重连到活跃版本 | 自动重连——重连由应用负责,且 gateway rolling 重启仍会断开物理 client socket | | 流式响应、出站 TCP(`cloudflare:sockets`) | 支持 | — | 租户 worker 只能连公网端点;平台内网地址被阻断 | — | | `compatibility_date` / `compatibility_flags` | 部分 | — | 平台运行单一 workerd 配置;不按 worker 逐个模拟 Cloudflare 的历史行为变更 | — | | KV | 支持 | 写入立即可见——Cloudflare 的边缘复制是最终一致,这里是强一致 | `cacheTtl` 可接受但不是新鲜度契约 | — | | R2 | 支持 | — | 单区对象存储 | Multipart 上传、`preview_bucket_name`、`jurisdiction` | | 静态资源 | 部分 | `env.ASSETS.url(path)` 发放带 token 的 CDN URL——WDL 新增能力 | — | Cloudflare Pages 式资源管线、fetch 形态的 assets binding | | D1 | 部分 | 单主库——默认读己之写,没有复制延迟和 bookmark 语义需要操心 | 请求/结果有大小上限;生命周期和迁移用 `wdl d1` 管理,`[[d1_databases]]` 只作为 binding 声明 | 读副本复制、Time Travel / bookmarks | -| Durable Objects | 部分 | — | 仅同 worker 内 class;`new_classes` 与 `new_sqlite_classes` 在 WDL 等价 | `script_name`(跨 script binding)、rename/delete migration、WebSocket 会话/游标恢复 | +| Durable Objects | 部分 | `[wdl] session_policy` 决定 promotion 保留还是退役既有会话(默认 `preserve`;Cloudflare 总是 restart) | 仅同 worker 内 class;`new_classes` 与 `new_sqlite_classes` 在 WDL 等价 | `script_name`(跨 script binding)、rename/delete migration、WebSocket 会话/游标恢复 | | Queues | 部分 | — | 按 batch 大小驱动派发;`max_batch_timeout` 为配置兼容而保存,不是聚合窗口 | `max_concurrency`(显式拒绝)、`contentType: "v8"` | | Cron 触发器 | 支持 | — | Cloudflare 兼容表达式,按 UTC 执行;best-effort 分钟槽——错过的槽跳过不补发,失败不重试 | — | | Workflows | 部分 | 并行 / DAG step 在运行时实测捕获,包括 `Promise.all` 并行分支 | WDL 自有的 payload 语义;payload 与单 turn step fan-out 有上限;严格 await 顺序;`step.do` 永久失败即终止运行(即使被 catch) | 完整 Cloudflare Workflows 对等、`script_name` / 跨 worker workflow 与 callback、source-AST 可视化 | diff --git a/GUIDE.md b/GUIDE.md index 4253007..051b974 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -386,15 +386,16 @@ with `worker_env_too_large`. | `[env.]` | Supported; select with `--env ` or `CLOUDFLARE_ENV`; see environment override notes below | | `[[r2_buckets]]` | Supported for common R2 object APIs, including conditional requests, range GETs, and `list({ include })`; objects are stored in platform-local R2 and isolated by namespace + `bucket_name` | | 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 | +| `[wdl]` | WDL platform extension table, currently `session_policy = "preserve" \| "restart"` (default `preserve`); under `restart`, promotion closes the worker's open WebSockets with `1012` and stale Durable Object facets abort on their next dispatch. Inherited by `[env.]` like `workers_dev` unless that env declares its own `[wdl]` | | `[[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`, `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 extensions from the temporary -config passed to the Wrangler bundler. Other fields retain their existing -Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` -configuration is not supported by WDL. +WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, +`[[services]].ns`, and `[wdl]` itself and removes these private extensions from +the temporary config passed to the Wrangler bundler. Other fields retain their +existing Wrangler passthrough behavior. Wrangler's object-shaped declarative +`exports` configuration is not supported by WDL. Cron triggers and queue consumers are dispatch features. Declare them only on routeable Workers in tenant namespaces unless your operator gives you an @@ -437,8 +438,9 @@ append the environment name to the worker / script name: `wdl deploy . --env preview` still updates the top-level `name`. `vars` and most bindings remain env-scoped and non-inheritable: selecting an env does not carry top-level `[vars]`, KV, D1, R2, queues, services, or workflows into that env. -For staging and production side by side, use separate namespaces unless your -operator tells you otherwise. +Policies do inherit: `workers_dev`, `route` / `routes`, and `[wdl]` keep +applying unless the env declares its own. For staging and production side by +side, use separate namespaces unless your operator tells you otherwise. ### KV @@ -680,6 +682,24 @@ export default { }; ``` +By default a promotion leaves already loaded facets on the version that built +them until the host actor restarts or the facet is deleted, and open WebSockets +keep draining on that version while their backend stays healthy; after a backend +is lost, a version that is no longer active is not reconnected, and the +WebSocket closes with `1012`. To close open WebSockets and retire old-version +facets on every promotion while retaining SQLite storage, add: + +```toml +[wdl] +session_policy = "restart" +``` + +Active WebSockets then close with `1012` at promotion, and stale facets abort on +their next dispatch; clients must reconnect and repeat their application +handshake. Restarting on deploy is Cloudflare's default behavior; WDL makes it +opt-in, and the policy is not Durable-Object-specific — any worker, including a +pure WebSocket worker, may set it. + Supported DO surface includes `stub.fetch()`, JSON-structured `stub.method(...args)` RPC, native `ctx.storage` and synchronous `ctx.storage.sql`, alarms, ordinary WebSocket upgrade, and the native WebSocket @@ -792,6 +812,9 @@ Effect timing: - Worker-level secret changes on an active Worker create and promote a new version, so new traffic cold-loads the updated secret. Already-loaded historical versions can keep old values until runtime eviction or recycle. + Because this is an ordinary promotion, a Worker with + `[wdl] session_policy = "restart"` also closes its open WebSockets with `1012` + when a secret changes. - Worker-level secret changes are atomic. If the active version changes during the mutation, control returns `secret_mutation_contention` and the CLI asks you to retry instead of leaving a stored-but-not-promoted partial update. @@ -1178,19 +1201,21 @@ wdl tail hello ## Troubleshooting -| Symptom | Likely cause | What to check | -| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | -| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | -| Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | -| Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | -| Worker URL returns `502 runtime_error` | The Worker `fetch()` handler threw before producing a response | Use `wdl tail ` and request logs; exception details are intentionally not copied into the client response body | -| A namespace-level secret did not change immediately | Namespace secrets do not bump every Worker version | Redeploy the Worker or wait for a natural cold-load; use a worker-level secret for immediate rollout | -| A service binding still calls the old target behavior | Bindings are pinned at caller deploy time | Redeploy the caller Worker | -| `wdl tail` has no history | Tail is live-only; first connect starts at the current stream tail | Start `wdl tail ` before triggering the request; use single-worker `--since ` only for manual resume | -| Multi-worker `wdl tail` can miss logs after reconnect | One connection cannot preserve independent resume positions for multiple workers | Use a dedicated `wdl tail ` session for critical debugging | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream because the client stopped reading or the session hit its lifetime cap | The CLI reconnects automatically; if it repeats, make sure the terminal or wrapper is consuming output | -| Scheduled / queue handler `console.*` output is absent from `wdl tail` | Tail shows fetch / scheduled / queue start/finish; scheduled / queue handler console does not enter the tail stream | Use `wdl tail` for trigger/outcome and the normal log platform for handler console details | +| Symptom | Likely cause | What to check | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | +| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | +| Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | +| Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | +| Worker URL returns `502 runtime_error` | The Worker `fetch()` handler threw before producing a response | Use `wdl tail ` and request logs; exception details are intentionally not copied into the client response body | +| A namespace-level secret did not change immediately | Namespace secrets do not bump every Worker version | Redeploy the Worker or wait for a natural cold-load; use a worker-level secret for immediate rollout | +| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy` | Upgrade control; the version was uploaded but not promoted, and re-running the same deploy cannot help | +| `control promoted the worker without confirming its restart session policy` | Control promoted but did not echo the policy or a usable restart sequence | Upgrade control, then redeploy; the new version is live but its sessions may still be pinned to the old one | +| A service binding still calls the old target behavior | Bindings are pinned at caller deploy time | Redeploy the caller Worker | +| `wdl tail` has no history | Tail is live-only; first connect starts at the current stream tail | Start `wdl tail ` before triggering the request; use single-worker `--since ` only for manual resume | +| Multi-worker `wdl tail` can miss logs after reconnect | One connection cannot preserve independent resume positions for multiple workers | Use a dedicated `wdl tail ` session for critical debugging | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream because the client stopped reading or the session hit its lifetime cap | The CLI reconnects automatically; if it repeats, make sure the terminal or wrapper is consuming output | +| Scheduled / queue handler `console.*` output is absent from `wdl tail` | Tail shows fetch / scheduled / queue start/finish; scheduled / queue handler console does not enter the tail stream | Use `wdl tail` for trigger/outcome and the normal log platform for handler console details | ## Compatibility Summary @@ -1208,25 +1233,25 @@ eventually consistent) and capabilities WDL adds beyond Cloudflare. just things to know. **Not implemented** means the surface genuinely does not exist here. -| Surface | Status | Stronger / added on WDL | Different from Cloudflare | Not implemented | -| ----------------------------------------------------------------------------- | ------------- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| Module Workers (`fetch` / `scheduled` / `queue`) | Supported | — | An uncaught exception returns a platform `502 runtime_error`; exception detail goes to `wdl tail` and logs, not the response body | — | -| WebSocket upgrade | Supported | — | — | Automatic session recovery across platform restarts; clients should reconnect | -| Streaming responses, outbound TCP (`cloudflare:sockets`) | Supported | — | Tenant workers dial public endpoints only; platform-internal addresses are blocked | — | -| `compatibility_date` / `compatibility_flags` | Partial | — | The platform runs one workerd configuration; historical Cloudflare behavior changes are not emulated per worker | — | -| KV | Supported | Writes are immediately visible — strong consistency where Cloudflare's edge replication is eventually consistent | `cacheTtl` is accepted but is not a freshness contract | — | -| R2 | Supported | — | Single-region object store | Multipart upload, `preview_bucket_name`, `jurisdiction` | -| Static assets | Partial | `env.ASSETS.url(path)` hands out tokenized CDN URLs — a WDL addition | — | Cloudflare Pages-style asset pipeline, fetch-style assets binding | -| D1 | Partial | Single primary database — read-your-writes by default, no replication lag or bookmark semantics to reason about | Request/result sizes are capped. Lifecycle and migrations are managed with `wdl d1`; `[[d1_databases]]` is the binding declaration only | Read replication, Time Travel / bookmarks | -| Durable Objects | Partial | — | Same-worker classes; `new_classes` and `new_sqlite_classes` are equivalent on WDL | `script_name` (cross-script bindings), rename/delete migrations, WebSocket session/cursor recovery | -| Queues | Partial | — | Batching is size-driven; `max_batch_timeout` is stored for config compatibility but is not an aggregation window | `max_concurrency` (rejected loudly), `contentType: "v8"` | -| Cron triggers | Supported | — | Cloudflare-compatible expressions, executed in UTC; best-effort minute slots — missed slots are skipped, never replayed, and failures are not retried | — | -| Workflows | Partial | Parallel / DAG steps are observed at runtime, including `Promise.all` siblings | WDL-specific payload semantics; bounded payloads and per-turn step fan-out; strict await ordering; a permanently failed `step.do` is terminal even if caught | Full Cloudflare Workflows parity, `script_name` / cross-worker workflows and callbacks, source-AST visualizer | -| Service bindings | Supported | — | — | — | -| Platform bindings | Supported | A WDL addition with no Cloudflare counterpart: operator-curated capabilities injected into `env` via `[[platform_bindings]]` | — | — | -| Vars and secrets | Supported | — | Secrets are platform-managed via `wdl secret`, not Cloudflare account secrets | — | -| Cache API (`caches.default`) | Not supported | — | — | Not exposed; do not depend on it | -| Workers AI, Vectorize, Analytics Engine, Browser Rendering, Hyperdrive, Email | Not supported | — | — | No binding exists; deploy rejects these config sections loudly | +| Surface | Status | Stronger / added on WDL | Different from Cloudflare | Not implemented | +| ----------------------------------------------------------------------------- | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Module Workers (`fetch` / `scheduled` / `queue`) | Supported | — | An uncaught exception returns a platform `502 runtime_error`; exception detail goes to `wdl tail` and logs, not the response body | — | +| WebSocket upgrade | Supported | Connections survive a promotion while the pinned version's backend stays healthy, unless the worker opts into `session_policy = "restart"` | Cloudflare disconnects every WebSocket on deploy; WDL sends `1012` when a lost backend's version is no longer active, or at promotion under `session_policy = "restart"`, and the client reconnects to the active version | Automatic reconnection — reconnect is application-owned, and gateway rolling restarts still drop physical client sockets | +| Streaming responses, outbound TCP (`cloudflare:sockets`) | Supported | — | Tenant workers dial public endpoints only; platform-internal addresses are blocked | — | +| `compatibility_date` / `compatibility_flags` | Partial | — | The platform runs one workerd configuration; historical Cloudflare behavior changes are not emulated per worker | — | +| KV | Supported | Writes are immediately visible — strong consistency where Cloudflare's edge replication is eventually consistent | `cacheTtl` is accepted but is not a freshness contract | — | +| R2 | Supported | — | Single-region object store | Multipart upload, `preview_bucket_name`, `jurisdiction` | +| Static assets | Partial | `env.ASSETS.url(path)` hands out tokenized CDN URLs — a WDL addition | — | Cloudflare Pages-style asset pipeline, fetch-style assets binding | +| D1 | Partial | Single primary database — read-your-writes by default, no replication lag or bookmark semantics to reason about | Request/result sizes are capped. Lifecycle and migrations are managed with `wdl d1`; `[[d1_databases]]` is the binding declaration only | Read replication, Time Travel / bookmarks | +| Durable Objects | Partial | `[wdl] session_policy` chooses whether a promotion keeps or retires established sessions (`preserve` default; Cloudflare always restarts) | Same-worker classes; `new_classes` and `new_sqlite_classes` are equivalent on WDL | `script_name` (cross-script bindings), rename/delete migrations, WebSocket session/cursor recovery | +| Queues | Partial | — | Batching is size-driven; `max_batch_timeout` is stored for config compatibility but is not an aggregation window | `max_concurrency` (rejected loudly), `contentType: "v8"` | +| Cron triggers | Supported | — | Cloudflare-compatible expressions, executed in UTC; best-effort minute slots — missed slots are skipped, never replayed, and failures are not retried | — | +| Workflows | Partial | Parallel / DAG steps are observed at runtime, including `Promise.all` siblings | WDL-specific payload semantics; bounded payloads and per-turn step fan-out; strict await ordering; a permanently failed `step.do` is terminal even if caught | Full Cloudflare Workflows parity, `script_name` / cross-worker workflows and callbacks, source-AST visualizer | +| Service bindings | Supported | — | — | — | +| Platform bindings | Supported | A WDL addition with no Cloudflare counterpart: operator-curated capabilities injected into `env` via `[[platform_bindings]]` | — | — | +| Vars and secrets | Supported | — | Secrets are platform-managed via `wdl secret`, not Cloudflare account secrets | — | +| Cache API (`caches.default`) | Not supported | — | — | Not exposed; do not depend on it | +| Workers AI, Vectorize, Analytics Engine, Browser Rendering, Hyperdrive, Email | Not supported | — | — | No binding exists; deploy rejects these config sections loudly | Resources are platform-local, not Cloudflare account resources: `kv_namespaces.id`, queue names, and platform binding names refer to this diff --git a/commands/deploy.js b/commands/deploy.js index 1490c5a..5a93586 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -24,6 +24,7 @@ import { import { isLocalDevHost } from "../lib/credentials.js"; import { isSecretEnvelopeErrorCode } from "../lib/secret-envelope-errors.js"; import { packWranglerProject } from "../lib/wrangler-pack.js"; +import { asRecord } from "../lib/wrangler/utils.js"; export const DEPLOY_JSON_BODY_MAX_BYTES = 32 * 1024 * 1024; @@ -55,6 +56,11 @@ function usageText() { * @property {string[]} [missingCallerSecrets] */ +/** + * The promote response fields the CLI reads back. + * @typedef {{ platformDomain?: unknown, workersDev?: unknown, sessionPolicy?: unknown, restartSequence?: unknown, urls?: unknown }} PromoteResponseBody + */ + // Upload a packed manifest to control + promote. Token rides authHeaders. // controlUrl is passed only for the readable upload log line; the fetch URLs // are built via context.nsUrl so segment encoding stays consistent. @@ -71,10 +77,9 @@ function usageText() { */ 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 manifestRecord = asRecord(manifest); + const workersDevOptOutRequested = manifestRecord?.workersDev === false; + const sessionPolicyRestartRequested = manifestRecord?.sessionPolicy === "restart"; const jsonHeaders = { "content-type": "application/json", ...authHeaders, @@ -88,7 +93,8 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, version, warnings, workersDev: deployedWorkersDev, - } = /** @type {{ version: unknown, warnings?: DeployWarning[], workersDev?: unknown }} */ ( + sessionPolicy: deployedSessionPolicy, + } = /** @type {{ version: unknown, warnings?: DeployWarning[], workersDev?: unknown, sessionPolicy?: unknown }} */ ( await fetchDeployJson({ context, url: context.nsUrl("worker", workerName, "deploy"), @@ -111,12 +117,18 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, "Upgrade control and re-run `wdl deploy`." ); } + if (sessionPolicyRestartRequested && deployedSessionPolicy !== "restart") { + throw new CliError( + "control did not confirm session_policy = restart; the uploaded version was retained but NOT promoted. " + + "Upgrade control and re-run `wdl deploy`." + ); + } writeStatusLine(stdout, `[3/3] promoting ${version}`); - /** @type {{ platformDomain?: unknown, workersDev?: unknown, urls?: unknown }} */ + /** @type {PromoteResponseBody} */ let promoteBody; try { - promoteBody = /** @type {{ platformDomain?: unknown, workersDev?: unknown, urls?: unknown }} */ ( + promoteBody = /** @type {PromoteResponseBody} */ ( await context.fetchJson( context.nsUrl("worker", workerName, "promote"), { @@ -140,6 +152,18 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, "the platform-domain URL may still be active." ); } + if ( + sessionPolicyRestartRequested && + (promoteBody.sessionPolicy !== "restart" || + !Number.isSafeInteger(promoteBody.restartSequence) || + Number(promoteBody.restartSequence) <= 0) + ) { + throw new CliError( + "control promoted the worker without confirming its restart session policy; " + + "the new version is live, but existing sessions may still be pinned to the old version. " + + "Upgrade control, then redeploy." + ); + } return { version, platformDomain: promoteBody.platformDomain, diff --git a/docs/README-zh.md b/docs/README-zh.md index 7d162c7..d6aabca 100644 --- a/docs/README-zh.md +++ b/docs/README-zh.md @@ -17,6 +17,7 @@ | 对象/附件/blob 存储 | [r2-zh.md](./r2-zh.md) | | SQL / 关系型状态和迁移 | [d1-zh.md](./d1-zh.md) | | Durable Object 本地 class、SQLite-backed state | [durable-objects-zh.md](./durable-objects-zh.md) | +| Promotion 的会话策略(`[wdl] session_policy`) | [deploy-zh.md](./deploy-zh.md) | | Workflow 实例、durable steps、事件等待 | [workflows-zh.md](./workflows-zh.md) | | Queue producer / consumer 后台任务 | [queues-zh.md](./queues-zh.md) | | Cron / 定时任务 | [cron-triggers-zh.md](./cron-triggers-zh.md) | diff --git a/docs/README.md b/docs/README.md index edd4578..36a07ef 100644 --- a/docs/README.md +++ b/docs/README.md @@ -26,6 +26,7 @@ English set. | Object / attachment / blob storage | [r2.md](./r2.md) | | SQL / relational state and migrations | [d1.md](./d1.md) | | Durable Object local classes, SQLite-backed state | [durable-objects.md](./durable-objects.md) | +| Session policy on promotion (`[wdl] session_policy`) | [deploy.md](./deploy.md) | | Workflow instances, durable steps, event waits | [workflows.md](./workflows.md) | | Queue producer / consumer background work | [queues.md](./queues.md) | | Cron / scheduled jobs | [cron-triggers.md](./cron-triggers.md) | diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index d766d5e..c21a477 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -88,6 +88,19 @@ Deploy 上传给 control 的 manifest JSON 最大 32 MiB。Assets 在部署时 Control plane 会按留有 headroom 的 workerd 1 MiB `workerLoader` environment 预算校验(可用 1,040,384 bytes)。过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`;减少 env payload,或在错误点名已有版本时 redeploy/delete 该 retained version。 +## 会话策略 + +`[wdl] session_policy` 决定 promotion 如何处理该 worker 的既有会话。它接受 `preserve`(默认)或 `restart`,并像 `workers_dev` 一样继承进 `[env.]`: + +```toml +[wdl] +session_policy = "restart" +``` + +`restart` 下,promote 会以 `1012` 关闭该 worker 打开的 WebSocket,并让 stale Durable Object facet 在下一次 dispatch 时退役,同时保留 SQLite state([facet 细节](./durable-objects-zh.md#会话策略与-facet));client 需要重连并重新执行应用握手。每一次 promotion 都算,包括 worker 级 secret 变更触发的那一次;namespace secret 不会 promote,因此也不会重启会话。 + +CLI 会校验 control 确实应用了该策略。deploy 响应没有回显时,version 会被保留且不会 promote;promote 响应没有确认时,promotion 已经发生——新 version 已经生效,其会话可能仍固定在旧 version 上,CLI 会失败以免这个差异被忽略。两种情况都说明 control 比当前 CLI 旧。 + ## 环境覆盖 当 wrangler 配置有 `[env.]` 段时,`--env `(或 `CLOUDFLARE_ENV`)是**必填**的 —— CLI 不会替你挑默认值。明确指定: @@ -107,9 +120,9 @@ 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]]`、`route` / `routes`、`workers_dev`、`[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`、`[wdl] session_policy`、`[env.]`。 -WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]` 和 `[[services]].ns`,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。 +WDL 会自行解析 `[[exports]]`、`[[platform_bindings]]`、`[[triggers.schedules]]`、`[[services]].ns` 和 `[wdl]` 本身,并从传给 Wrangler bundler 的临时配置中移除这些私有扩展;其它字段保持既有的 Wrangler 透传行为。WDL 不支持 Wrangler 对象形态的 declarative `exports` 配置。`[wdl] session_policy` 见上面的会话策略一节。 ### Service bindings 与 capability delegation @@ -145,6 +158,8 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `worker_code_invalid` | 按 control plane 返回的原因修正 Worker bundle 形状,包括 WDL 保留注入模块名。 | | `wrangler build failed` | 在项目里跑 `npx wrangler deploy --dry-run` 然后在那边修。 | | 部署成功但 promote 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | +| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy`;version 已上传但未 promote。需要升级 control——重跑同一条 deploy 无济于事。 | +| `control promoted the worker without confirming its restart session policy` | 新 version 已经生效,但其 session policy 未被确认;升级 control 后重新部署以获得确认的 restart。 | | Worker URL 返回 404 | URL 缺了 `/` 这一段。 | | `wdl tail` 没有历史日志 | tail 是 live-only;先打开 `wdl tail ` 再触发请求。 | | `tail session_idle` / `tail session_expired` | control 回收了 live-tail stream;CLI 会自动重连,除非达到重连上限。 | diff --git a/docs/deploy.md b/docs/deploy.md index d3aa6d2..0aca5fe 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -163,6 +163,31 @@ budget (1,040,384 bytes usable). Large `[vars]`, secrets, binding metadata, or retained versions can fail with `worker_env_too_large`; reduce the env payload, or redeploy/delete the retained version named in the error when one is shown. +## Session policy + +`[wdl] session_policy` decides what a promotion does to the worker's established +sessions. It accepts `preserve` (default) or `restart` and inherits into +`[env.]` like `workers_dev`: + +```toml +[wdl] +session_policy = "restart" +``` + +Under `restart`, promoting closes the worker's open WebSockets with code `1012` +and retires stale Durable Object facets on their next dispatch, keeping SQLite +state ([facet detail](./durable-objects.md#session-policy-and-facets)); clients +reconnect and repeat their application handshake. Every promotion counts, +including the one a worker-level secret change performs; namespace secrets do +not promote, so they do not restart sessions. + +The CLI checks that control applied the policy. When the deploy response does +not echo it, the version is retained and never promoted. When the promote +response does not confirm it, the promotion has already happened — the new +version is live, its sessions may still be pinned to the old one, and the CLI +fails so the discrepancy is not silent. Both cases mean the control plane is +older than this CLI. + ## Environment overrides When the wrangler config has `[env.]` sections, `--env ` (or @@ -197,13 +222,14 @@ control. Upstream experimental enable flags, `legacy_error_serialization`, and `[assets] directory`, `[triggers] crons`, `[[triggers.schedules]]` (with timezone, a platform extension), `[[queues.producers]]` / `[[queues.consumers]]`, `[[services]]`, `[[platform_bindings]]`, `[[exports]]`, -`route` / `routes`, `workers_dev`, `[env.]`. +`route` / `routes`, `workers_dev`, `[wdl] session_policy`, `[env.]`. WDL parses `[[exports]]`, `[[platform_bindings]]`, `[[triggers.schedules]]`, and -`[[services]].ns` itself and removes these private extensions from the temporary -config passed to the Wrangler bundler. Other fields retain their existing -Wrangler passthrough behavior. Wrangler's object-shaped declarative `exports` -configuration is not supported by WDL. +`[[services]].ns` plus `[wdl]` itself and removes these private extensions from +the temporary config passed to the Wrangler bundler. Other fields retain their +existing Wrangler passthrough behavior. Wrangler's object-shaped declarative +`exports` configuration is not supported by WDL. `[wdl] session_policy` has its +own section above. ### Service bindings and delegated capabilities @@ -250,27 +276,29 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). ## Common errors -| Symptom | Cause / fix | -| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | -| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | -| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | -| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | -| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | -| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | -| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | -| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | -| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | -| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | -| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | -| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | -| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | -| Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | -| Worker URL returns 404 | The URL is missing the `/` segment. | -| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | -| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | -| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | +| Symptom | Cause / fix | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | +| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | +| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | +| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | +| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | +| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | +| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | +| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | +| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | +| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | +| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | +| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | +| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | +| Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | +| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy`; the version was uploaded but not promoted. Upgrade control — re-running the same deploy cannot help. | +| `control promoted the worker without confirming its restart session policy` | The new version is live but its session policy is unconfirmed; upgrade control, then redeploy to obtain a confirmed restart. | +| Worker URL returns 404 | The URL is missing the `/` segment. | +| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | +| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | +| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | ## Anti-patterns diff --git a/docs/durable-objects-zh.md b/docs/durable-objects-zh.md index d8b8b66..637f6c3 100644 --- a/docs/durable-objects-zh.md +++ b/docs/durable-objects-zh.md @@ -18,6 +18,21 @@ tag = "v1" new_classes = ["Room"] ``` +## 会话策略与 facet + +默认情况下,promote 新 Worker version 会让已经构造的 DO facet 停留在构造它的 version 上,直到 host actor 重启或 facet 被删除。已打开的 WebSocket 也会继续在该 version 上 drain,但前提是它的 backend 仍健康:backend 一旦丢失,WDL 不会重连已不活跃的 version——WebSocket 会以 `1012` 关闭,client 必须重连才能到达 active version。希望每次 promotion 都让旧 version 的会话退役、并在下一次 dispatch 时退役旧 facet 的应用可以显式配置: + +```toml +[wdl] +session_policy = "restart" +``` + +`session_policy` 接受 `preserve` 或 `restart`,默认是 `preserve`。它在新 version 被 promote 时生效,只上传 bundle 时不会触发。该策略并非 Durable Object 专属:它约束的是 worker 的既有会话,因此没有 Durable Object 的纯 WebSocket worker 也可以设置它。 + +使用 `restart` 时,WDL 会在旧 version facet 下一次 dispatch 时将其中止,但不会删除 SQLite state。Active HTTP/RPC call 可能失败,已有 WebSocket 会在 promote 时立即以 `1012` 关闭,而不是等到 backend 丢失;client 必须重连并重新执行应用握手。下一次 invocation 会使用同一份持久化 storage 构造 active class version。这与 Cloudflare 的默认行为一致——部署新代码会重启每个 Durable Object。由已被取代的 version 排定的 alarm 会改由 active version 执行;`preserve` 则让它们留在排定它的 version 上。 + +Promotion 与 route 变更在同一个事务中原子提交该策略;后续的 `preserve` promotion 可以覆盖尚未被观察到的 restart 工作,但无法撤销已经发生的关闭或 facet abort。如果 `wdl deploy` 在 promote 前失败,之前的 version 继续服务;重新运行同一个 `wdl deploy` 即可重试——除非失败原因是 control 未确认该策略,那需要先升级 control。 + ## Worker 代码 ```js diff --git a/docs/durable-objects.md b/docs/durable-objects.md index a070a66..d217a90 100644 --- a/docs/durable-objects.md +++ b/docs/durable-objects.md @@ -22,6 +22,43 @@ tag = "v1" new_classes = ["Room"] ``` +## Session policy and facets + +By default, promoting a new Worker version leaves already constructed DO facets +on the version that built them until the host actor restarts or the facet is +deleted. Open WebSockets keep draining on that version too, but only while their +backend stays healthy: once a backend is lost, WDL does not reconnect a version +that is no longer active — the WebSocket closes with code `1012`, and the client +must reconnect to reach the active version. Applications that want each +promotion to retire old-version sessions, and old-version facets on their next +dispatch, can opt in: + +```toml +[wdl] +session_policy = "restart" +``` + +`session_policy` accepts `preserve` or `restart`; the default is `preserve`. It +applies when a new version is promoted, not while a bundle is only uploaded. The +policy is not Durable-Object-specific: it governs the worker's established +sessions, so a pure WebSocket worker without Durable Objects may also set it. + +With `restart`, WDL aborts old-version facets on their next dispatch without +deleting SQLite state. Active HTTP/RPC calls may fail, and existing WebSockets +close with code `1012` at promotion instead of waiting for backend loss; clients +must reconnect and repeat their application handshake. The next invocation +constructs the active class version against the same persisted storage. This +matches Cloudflare's default behavior, where deploying new code restarts every +Durable Object. Alarms scheduled by a superseded version fire on the active +version instead; `preserve` keeps them on the version that scheduled them. + +Promotion commits the policy atomically with the route change; a later +`preserve` promotion supersedes restart work that has not been observed yet, but +cannot undo a close or a facet abort that already happened. If `wdl deploy` +fails before promotion, the previous version keeps serving; re-run the same +`wdl deploy` to retry — unless it failed because control did not confirm the +policy, which needs a control upgrade first. + ## Worker code ```js diff --git a/docs/env-overrides-zh.md b/docs/env-overrides-zh.md index 913dc3f..72e0be7 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`、`workers_dev`、`[[migrations]]`、`[assets]`、`[triggers]` 等。env 里没写时继续使用顶层值;env 里写了则覆盖顶层值。 +- Inheritable:`main`、`compatibility_date` / `compatibility_flags`、`route` / `routes`、`workers_dev`、`[wdl]`、`[[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 6641a56..6894b20 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`, `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. + `routes`, `workers_dev`, `[wdl]`, `[[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/docs/secrets-zh.md b/docs/secrets-zh.md index 2e3f87e..3941d03 100644 --- a/docs/secrets-zh.md +++ b/docs/secrets-zh.md @@ -46,7 +46,7 @@ worker 级 secret > 命名空间级 secret > [vars] 同名 key 的 worker 级 secret 会盖过命名空间级。`[vars]` 里的同名条目会被两种 secret 都遮蔽。 -修改 worker 级 secret 会创建并 promote 新版本,但已经加载的历史版本可能继续持有旧值,直到 runtime eviction 或 recycle。需要严格撤销时,应同时考虑禁用旧凭据。 +修改 worker 级 secret 会创建并 promote 新版本,但已经加载的历史版本可能继续持有旧值,直到 runtime eviction 或 recycle。需要严格撤销时,应同时考虑禁用旧凭据。由于这就是一次普通 promotion,配置了 `[wdl] session_policy = "restart"` 的 Worker 在 secret 变更时同样会以 `1012` 关闭已打开的 WebSocket。 Worker 级 secret mutation 是原子的:如果更新期间 active version 变化,control 会返回 `secret_mutation_contention`,CLI 会要求重试,而不是留下"已存储但未 promote"的半成功状态。Namespace secret mutation 在 retained worker metadata 持续变化时也可能返回 `namespace_secret_mutation_contention`。 diff --git a/docs/secrets.md b/docs/secrets.md index b30f252..fcb596f 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -59,7 +59,9 @@ A same-named `[vars]` entry is shadowed by both kinds of secret. Changing a worker-level secret creates and promotes a new version, but already-loaded historical versions can keep holding the old value until runtime eviction or recycle. When strict revocation matters, also consider disabling the -old credential. +old credential. Because this is an ordinary promotion, a Worker with +`[wdl] session_policy = "restart"` also closes its open WebSockets with `1012` +when a secret changes. Worker-level secret mutations are atomic: if the active version changes during the update, control returns `secret_mutation_contention` and the CLI asks you to diff --git a/lib/output.js b/lib/output.js index d7d6010..80065fc 100644 --- a/lib/output.js +++ b/lib/output.js @@ -53,6 +53,10 @@ export function escapeTerminalText(value) { /** @param {unknown} value */ export function formatDiagnosticValue(value) { + // JSON.stringify renders NaN and ±Infinity as "null", hiding what was written. + if (typeof value === "number" && !Number.isFinite(value)) return escapeTerminalText(String(value)); + // TOML datetimes parse into Date; quoting their ISO form would read as a string. + if (value instanceof Date) return escapeTerminalText(`datetime ${value.toISOString()}`); return escapeTerminalText(JSON.stringify(value)); } diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index f10303c..a5d24ee 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -31,6 +31,7 @@ import { createWranglerBundleConfig, formatWranglerConfigShadowWarning, loadWranglerConfig, + parseSessionPolicy, resolveWranglerConfig, validateUnsupportedWranglerConfig, WRANGLER_WDL_TMP_PREFIX, @@ -60,6 +61,7 @@ export { formatWranglerConfigShadowWarning, loadWranglerConfig, parseJsonc, + parseSessionPolicy, resolveWranglerConfig, selectWranglerConfigFiles, validateUnsupportedWranglerConfig, @@ -82,6 +84,7 @@ const WRANGLER_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024; * @property {unknown} [compatibilityFlags] * @property {string[]} [routes] * @property {boolean} [workersDev] + * @property {"restart"} [sessionPolicy] * @property {Array<{ cron: string, timezone: string }>} [crons] * @property {import("./wrangler/bindings.js").QueueConsumer[]} [queueConsumers] * @property {Array<{ name: string, binding: string, className: string }>} [workflows] @@ -240,6 +243,7 @@ export async function packWranglerProject({ } const routes = collectRoutes(cfg, configRel); const workersDev = parseWorkersDev(cfg, routes, configRel); + const sessionPolicy = wrapCli(() => parseSessionPolicy(cfg, configRel)); const outDir = path.join(absProject, ".deploy-dist"); rmSync(outDir, { recursive: true, force: true }); @@ -299,6 +303,7 @@ export async function packWranglerProject({ if (routes.length) manifest.routes = routes; if (!workersDev) manifest.workersDev = false; + if (sessionPolicy === "restart") manifest.sessionPolicy = "restart"; const crons = wrapCli(() => parseTriggers(cfg.triggers, configRel)); if (crons.length) manifest.crons = crons; diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index 51a2a15..c2366bc 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -11,8 +11,9 @@ 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`, `workers_dev`, `assets`, - * `compatibility_date`, `compatibility_flags`, `env`, and the unsupported + * `platform_bindings`, `wdl`, `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`. * @typedef {Record} WranglerConfig @@ -81,9 +82,11 @@ const UNSUPPORTED_WRANGLER_KEYS = [ 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), " + + "[[platform_bindings]], [[exports]], [wdl], [vars], [triggers], assets.directory, route(s), " + "workers_dev, compatibility_date/compatibility_flags."; +const SESSION_POLICY_MODES = new Set(["preserve", "restart"]); + const NON_INHERITABLE_ENV_KEYS = new Set([ "define", "vars", @@ -194,6 +197,7 @@ function stripWdlConfigExtensions(cfg) { const projected = { ...cfg }; delete projected.exports; delete projected.platform_bindings; + delete projected.wdl; const triggers = asRecord(cfg.triggers); if (triggers && Object.hasOwn(triggers, "schedules")) { @@ -214,6 +218,31 @@ function stripWdlConfigExtensions(cfg) { return projected; } +/** + * @param {WranglerConfig} cfg + * @param {string} [configRel] + * @returns {"preserve" | "restart"} + */ +export function parseSessionPolicy(cfg, configRel = "wrangler config") { + if (cfg.wdl === undefined) return "preserve"; + const shownConfig = escapeTerminalText(configRel); + const wdl = asRecord(cfg.wdl); + if (!wdl) throw new Error(`${shownConfig}: [wdl] must be a table`); + for (const key of Object.keys(wdl)) { + if (key !== "session_policy") { + throw new Error(`${shownConfig}: [wdl] has unsupported field ${formatDiagnosticValue(key)}`); + } + } + // Only an absent field means preserve; an explicit null is a typo, not a default. + const mode = wdl.session_policy === undefined ? "preserve" : wdl.session_policy; + if (typeof mode !== "string" || !SESSION_POLICY_MODES.has(mode)) { + throw new Error( + `${shownConfig}: [wdl].session_policy must be "preserve" or "restart", got ${formatDiagnosticValue(mode)}` + ); + } + return /** @type {"preserve" | "restart"} */ (mode); +} + /** * @param {unknown} rawCfg * @param {string | null | undefined} envName @@ -250,6 +279,16 @@ export function validateUnsupportedWranglerConfig(rawCfg, envName, configRel = " if (hasConfiguredKey(selectedEnvCfg, "allowed_callers")) { throw new Error(`${shownConfig} env.${shownEnv} uses top-level allowed_callers — removed. ${allowedCallersHint}`); } + + // Outside [wdl] the key would pass through to Wrangler as an unknown field + // and the deploy would silently run as preserve. + const sessionPolicyHint = "Declare session_policy inside the [wdl] table."; + if (hasConfiguredKey(cfg, "session_policy")) { + throw new Error(`${shownConfig} uses top-level session_policy. ${sessionPolicyHint}`); + } + if (hasConfiguredKey(selectedEnvCfg, "session_policy")) { + throw new Error(`${shownConfig} env.${shownEnv} uses top-level session_policy. ${sessionPolicyHint}`); + } } /** diff --git a/lib/wrangler/utils.js b/lib/wrangler/utils.js index 9c73b06..2034068 100644 --- a/lib/wrangler/utils.js +++ b/lib/wrangler/utils.js @@ -22,7 +22,9 @@ export function hasOwn(obj, key) { * @returns {Record | null} */ export function asRecord(value) { - return value && typeof value === "object" && !Array.isArray(value) + // smol-toml parses datetimes into Date instances: objects with no own keys. + // Treating one as an empty table would silently drop whatever it stood for. + return value && typeof value === "object" && !Array.isArray(value) && !(value instanceof Date) ? /** @type {Record} */ (value) : null; } diff --git a/templates/AGENTS.md b/templates/AGENTS.md index cc82c91..832dfee 100644 --- a/templates/AGENTS.md +++ b/templates/AGENTS.md @@ -58,6 +58,10 @@ disabling the default platform-domain URL; it requires at least one `route` / 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] session_policy = "restart"` makes every promotion close the Worker's open +WebSockets with `1012` and retire stale Durable Object facets on their next +dispatch; the default `preserve` leaves facets on the version that built them +and keeps open WebSockets draining while their backend stays healthy. ## Runnable end-to-end examples diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 351d857..1cb9fb7 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -13,6 +13,7 @@ import { } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; +import { parse as parseToml } from "smol-toml"; import { DEPLOY_JSON_BODY_MAX_BYTES, runDeployCommand, serializeDeployManifest } from "../../commands/deploy.js"; import { collectAssets, @@ -33,6 +34,7 @@ import { parseQueues, parseR2BucketsFromCfg, parseServicesFromCfg, + parseSessionPolicy, parseTriggers, parseWorkersDev, parseWorkflowsFromCfg, @@ -45,7 +47,14 @@ import { } from "../../lib/wrangler-pack.js"; import { LONG_CONTROL_TIMEOUT_MS } from "../../lib/control-fetch.js"; import { checkWranglerVersion, formatWranglerFailure } from "../../lib/wrangler/command.js"; -import { ESC, MODE_BITS_ENFORCED_ONLY, assertNoRawTerminalControls, assertUnreadable, response } from "./helpers.js"; +import { + ESC, + MODE_BITS_ENFORCED_ONLY, + assertNoRawTerminalControls, + assertUnreadable, + deployPromoteFetch, + response, +} from "./helpers.js"; /** * @param {() => unknown} fn @@ -81,12 +90,7 @@ function assertThrowsNoRawTerminalControls(fn, expected, target) { * @property {ExecFileOpts} opts */ -/** - * A recorded controlFetch invocation captured by a fake. - * @typedef {object} RecordedFetch - * @property {string} url - * @property {import("../../lib/control-fetch.js").ControlFetchInit} init - */ +/** @typedef {import("./helpers.js").ControlCall} RecordedFetch */ // Shared happy-path execFile stub: answers the version probe and writes the // bundled entry the deploy pipeline expects in --outdir. @@ -724,6 +728,11 @@ test("wrangler binding parser diagnostics escape terminal controls", () => { /service must be a non-empty string/, "service diagnostics" ); + assertThrowsNoRawTerminalControls( + () => parseSessionPolicy({ wdl: { [bad]: true } }, badConfigRel), + /\[wdl\] has unsupported field/, + "session policy diagnostics" + ); assertThrowsNoRawTerminalControls( () => parseDurableObjectsFromCfg({ @@ -1558,6 +1567,7 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source ], exports: [{ entrypoint: "Auth", allowed_callers: ["acme"] }], platform_bindings: [{ binding: "PAYMENT", platform: "STRIPE" }], + wdl: { session_policy: "restart" }, env: { staging: { define: { BUILD_ENV: '"staging"' }, @@ -1568,6 +1578,7 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source services: [{ binding: "API", service: "api-worker", ns: "backend", remote: false }], exports: [{ entrypoint: "default", allowed_callers: ["*"] }], platform_bindings: [{ binding: "SEARCH", platform: "SEARCH" }], + wdl: { session_policy: "preserve" }, }, }, }; @@ -1579,6 +1590,7 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source assert.equal(projected.name, "wdl-bundle-tmp"); assert.equal(projected.exports, undefined); assert.equal(projected.platform_bindings, undefined); + assert.equal(projected.wdl, undefined); assert.deepEqual(projected.build, { command: "npm run build" }); assert.deepEqual(projected.vars, { MODE: "top" }); assert.deepEqual(projected.triggers, { crons: ["*/5 * * * *"] }); @@ -1597,6 +1609,80 @@ test("createWranglerBundleConfig projects WDL extensions without mutating source assert.deepEqual(projectedEnv.staging.services, [{ binding: "API", service: "api-worker", remote: false }]); assert.equal(projectedEnv.staging.exports, undefined); assert.equal(projectedEnv.staging.platform_bindings, undefined); + assert.equal(projectedEnv.staging.wdl, undefined); +}); + +test("parseSessionPolicy validates the [wdl] session policy", () => { + assert.equal(parseSessionPolicy({}), "preserve"); + assert.equal(parseSessionPolicy({ wdl: {} }), "preserve"); + assert.equal(parseSessionPolicy({ wdl: { session_policy: "restart" } }), "restart"); + assert.throws(() => parseSessionPolicy({ wdl: [] }), /\[wdl\] must be a table/); + assert.throws(() => parseSessionPolicy({ wdl: { session_policy: "replace" } }), /must be "preserve" or "restart"/); + assert.throws( + () => parseSessionPolicy({ wdl: { session_policy: "restart", typo: true } }), + /\[wdl\] has unsupported field "typo"/ + ); + // An explicit null is rejected at the field and at the table boundary. + assert.throws(() => parseSessionPolicy({ wdl: { session_policy: null } }), /must be "preserve" or "restart"/); + assert.throws(() => parseSessionPolicy({ wdl: null }), /\[wdl\] must be a table/); + assert.throws(() => parseSessionPolicy({ wdl: { session_policy: Number.NaN } }), /got NaN/); + // smol-toml parses bare dates into TomlDate, an object with no own keys: it + // is neither a table nor a string value. + const tomlDate = parseToml("v = 2026-08-04").v; + assert.throws(() => parseSessionPolicy({ wdl: tomlDate }), /\[wdl\] must be a table/); + assert.throws(() => parseSessionPolicy({ wdl: { session_policy: tomlDate } }), /got datetime 2026-08-04/); +}); + +test("a bare TOML datetime is never mistaken for a table", () => { + const tomlDate = parseToml("v = 2026-08-04").v; + assert.throws(() => parseTriggers(tomlDate), /\[triggers\] must be a table/); + assert.throws(() => parseDurableObjectsFromCfg({ durable_objects: tomlDate }), /\[durable_objects\] must be a table/); + assert.throws( + () => resolveWranglerConfig({ name: "a", main: "i.js", env: { prod: tomlDate } }, "prod"), + /env\.prod must be an object/ + ); +}); + +test("validateUnsupportedWranglerConfig: rejects session_policy hoisted out of [wdl]", () => { + assert.throws( + () => + validateUnsupportedWranglerConfig( + { name: "demo", main: "src/index.js", session_policy: "restart" }, + null, + "wrangler.toml" + ), + /top-level session_policy.*\[wdl\]/ + ); + assert.throws( + () => + validateUnsupportedWranglerConfig( + { name: "demo", main: "src/index.js", env: { prod: { session_policy: "restart" } } }, + "prod", + "wrangler.toml" + ), + /env\.prod uses top-level session_policy/ + ); +}); + +test("[wdl] resolves per environment like the policies beside it", () => { + const topLevelWdl = { + name: "demo", + main: "src/index.js", + wdl: { session_policy: "restart" }, + durable_objects: { bindings: [{ name: "ROOMS", class_name: "Room" }] }, + env: { prod: { vars: { STAGE: "prod" } } }, + }; + // Inherited when the env declares none, unlike the bindings beside it. + const inherited = resolveWranglerConfig(topLevelWdl, "prod", "wrangler.toml").cfg; + assert.equal(parseSessionPolicy(inherited), "restart"); + assert.equal(inherited.durable_objects, undefined); + // An env-level table replaces the top-level one whole, contents and all. + const overridden = resolveWranglerConfig( + { ...topLevelWdl, wdl: { typo: 1 }, env: { prod: { wdl: { session_policy: "preserve" } } } }, + "prod", + "wrangler.toml" + ).cfg; + assert.equal(parseSessionPolicy(overridden), "preserve"); }); test("validateUnsupportedWranglerConfig: workflows are supported at top-level and selected env", () => { @@ -2442,8 +2528,10 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a /** @type {RecordedExec[]} */ const execCalls = []; - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [] }, + { platformDomain: "workers.example" } + ); /** @type {string[]} */ const lines = []; await runDeployCommand(["sub", "--control-url", "http://ctl.test"], { @@ -2469,16 +2557,7 @@ test("runDeployCommand resolves cwd-relative project dir and WDL_NS fallback", a mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), 'export default { fetch() { return new Response("ok"); } };'); }, - controlFetch: async ( - /** @type {string} */ url, - /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} - ) => { - fetchCalls.push({ url, init }); - if (fetchCalls.length === 1) { - return response({ version: "v1", warnings: [] }); - } - return response({ platformDomain: "workers.example" }); - }, + controlFetch, }); assert.equal(execCalls.length, 2); @@ -2587,8 +2666,10 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w let tmpConfigSeen = null; let tmpConfigContentAtExec = /** @type {{ name?: string, main?: string, vars?: unknown, exports?: unknown } | null} */ (null); - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [] }, + { platformDomain: "workers.example" } + ); /** @type {string[]} */ const warnings = []; await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { @@ -2608,14 +2689,7 @@ test("runDeployCommand sanitizes wrangler.name via temp --config so mixed-case w mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export default {}"); }, - controlFetch: async ( - /** @type {string} */ url, - /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} - ) => { - fetchCalls.push({ url, init }); - if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); - return response({ platformDomain: "workers.example" }); - }, + controlFetch, }); assert.ok( @@ -2736,21 +2810,16 @@ test("runDeployCommand preserves prototype-shaped binding keys for control valid }) ); - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [] }, + { platformDomain: "workers.example" } + ); await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { 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 }); - if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); - return response({ platformDomain: "workers.example" }); - }, + controlFetch, }); const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); @@ -2793,6 +2862,34 @@ test("runDeployCommand rejects a non-table [assets] before bundling", async () = } }); +test("runDeployCommand rejects a malformed [wdl] before bundling", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-session-policy-invalid-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync( + path.join(dir, "wrangler.json"), + JSON.stringify({ name: "api", main: "src/index.js", wdl: { session_policy: "replace" } }) + ); + + let execCalled = false; + await assert.rejects( + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), + { message: 'wrangler.json: [wdl].session_policy must be "preserve" or "restart", got "replace"' } + ); + assert.equal(execCalled, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand rejects non-object vars before bundling", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-vars-")); try { @@ -3000,26 +3097,20 @@ test("runDeployCommand sends workers_dev opt-out and prints only route-pattern U ]) { /** @type {string[]} */ const lines = []; - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [], workersDev: false }, + { + platformDomain, + workersDev: false, + urls: { routes: ["https://app.example/a/../b/*"] }, + } + ); 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/*"] }, - }); - }, + controlFetch, }); const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); @@ -3085,30 +3176,24 @@ test("runDeployCommand fails when promote does not preserve the workers_dev opt- ['name = "api"', 'main = "src/index.js"', "workers_dev = false", 'route = "app.example/*"'].join("\n") ); - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [], workersDev: false }, + { + platformDomain: "workers.example", + workersDev: true, + urls: { + platform: "https://demo.workers.example/api/", + routes: ["https://app.example/*"], + }, + } + ); 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/*"], - }, - }); - }, + controlFetch, }), /control promoted the worker without preserving workers_dev = false/ ); @@ -3462,21 +3547,16 @@ test("runDeployCommand treats an empty assets directory as an implicit ASSETS bi }) ); - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [] }, + { platformDomain: "wdl.sh" } + ); await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { 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 }); - if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); - return response({ platformDomain: "wdl.sh" }); - }, + controlFetch, }); const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); @@ -4181,6 +4261,231 @@ test("runDeployCommand explains a failed promote after upload", async () => { } }); +const RESTART_SESSION_POLICY_TOML = [ + 'name = "api"', + 'main = "src/index.js"', + "[wdl]", + 'session_policy = "restart"', +].join("\n"); + +test("runDeployCommand sends the restart session policy", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-session-policy-wire-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), RESTART_SESSION_POLICY_TOML); + + /** @type {string[]} */ + const lines = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v3", warnings: [], sessionPolicy: "restart" }, + { + platformDomain: "workers.example", + sessionPolicy: "restart", + restartSequence: 7, + urls: {}, + } + ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch, + }); + + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.equal(manifest.sessionPolicy, "restart"); + assert.match(fetchCalls[1].url, /\/promote$/); + assert.ok(lines.includes("✓ demo/api@v3 live")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand inherits a top-level [wdl] into an --env deploy", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-session-policy-env-")); + 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"', + "[wdl]", + 'session_policy = "restart"', + "[env.prod.vars]", + 'STAGE = "prod"', + ].join("\n") + ); + + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v3", warnings: [], sessionPolicy: "restart" }, + { + platformDomain: "workers.example", + sessionPolicy: "restart", + restartSequence: 3, + urls: {}, + } + ); + await runDeployCommand([dir, "--ns", "demo", "--env", "prod", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch, + }); + + // The env declares no [wdl] of its own, so the policy must survive env + // resolution and reach the wire. + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.equal(manifest.sessionPolicy, "restart"); + assert.match(fetchCalls[1].url, /\/promote$/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand sends the env's own [wdl] instead of the top-level one", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-session-policy-env-override-")); + 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"', + "[wdl]", + 'session_policy = "restart"', + "[env.prod.wdl]", + 'session_policy = "preserve"', + ].join("\n") + ); + + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v3", warnings: [] }, + { platformDomain: "workers.example", urls: {} } + ); + await runDeployCommand([dir, "--ns", "demo", "--env", "prod", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch, + }); + + // The env overrides the top-level restart with preserve, so nothing about + // the policy may reach the wire. + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.equal(manifest.sessionPolicy, undefined); + assert.match(fetchCalls[1].url, /\/promote$/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand does not promote when control omits the restart session policy acknowledgement", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-session-policy-skew-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), RESTART_SESSION_POLICY_TOML); + + /** @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: "v3", warnings: [] }); + }, + }), + /control did not confirm session_policy = restart.*NOT promoted/ + ); + assert.equal(fetchCalls.length, 1); + assert.match(fetchCalls[0].url, /\/deploy$/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +for (const { label, promoteBody } of [ + { label: "a non-positive sequence", promoteBody: { sessionPolicy: "restart", restartSequence: 0 } }, + { label: "no policy echo", promoteBody: { restartSequence: 7 } }, + { label: "no sequence", promoteBody: { sessionPolicy: "restart" } }, +]) { + test(`runDeployCommand fails when the promote response carries ${label}`, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-session-policy-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"), RESTART_SESSION_POLICY_TOML); + + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v3", warnings: [], sessionPolicy: "restart" }, + { platformDomain: "workers.example", urls: {}, ...promoteBody } + ); + await assert.rejects( + runDeployCommand([dir, "--ns", "demo", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch, + }), + /without confirming its restart session policy/ + ); + assert.equal(fetchCalls.length, 2); + assert.match(fetchCalls[1].url, /\/promote$/); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test("runDeployCommand keeps the default policy off the wire", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-preserve-summary-")); + 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"'].join("\n")); + + /** @type {string[]} */ + const lines = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v4", warnings: [] }, + { + platformDomain: "workers.example", + sessionPolicy: "preserve", + restartSequence: 5, + urls: {}, + } + ); + await runDeployCommand([dir, "--ns", "demo", "--control-url", "https://control.example"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: (/** @type {string} */ line) => lines.push(line), + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch, + }); + + const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); + assert.equal(manifest.sessionPolicy, undefined, "the default policy must stay off the wire"); + assert.ok(lines.includes("✓ demo/api@v4 live")); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand warns that DO named entrypoints must be declared exports", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-do-warning-")); try { @@ -4318,21 +4623,16 @@ test("runDeployCommand maps a .mts main to the bundled .js entry", async () => { writeFileSync(path.join(dir, "src", "index.mts"), "export default {}"); writeFileSync(path.join(dir, "wrangler.toml"), 'name = "api"\nmain = "src/index.mts"\n'); - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [] }, + { platformDomain: "wdl.sh" } + ); await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { 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 }); - if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); - return response({ platformDomain: "wdl.sh" }); - }, + controlFetch, }); const manifest = JSON.parse(/** @type {string} */ (fetchCalls[0].init.body)); @@ -4357,21 +4657,16 @@ test("runDeployCommand notes skipped asset entries on stderr", async () => { /** @type {string[]} */ const stderrLines = []; - /** @type {RecordedFetch[]} */ - const fetchCalls = []; + const { calls: fetchCalls, controlFetch } = deployPromoteFetch( + { version: "v1", warnings: [] }, + { platformDomain: "wdl.sh" } + ); await runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { env: { ADMIN_TOKEN: "tok" }, stdout: () => {}, stderr: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), execFile: fakeWranglerExecFile, - controlFetch: async ( - /** @type {string} */ url, - /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} - ) => { - fetchCalls.push({ url, init }); - if (fetchCalls.length === 1) return response({ version: "v1", warnings: [] }); - return response({ platformDomain: "wdl.sh" }); - }, + controlFetch, }); const note = stderrLines.find((line) => line.startsWith("note: assets: skipped")); diff --git a/tests/unit/cli-output.test.js b/tests/unit/cli-output.test.js index e31a5c3..ba9a9cb 100644 --- a/tests/unit/cli-output.test.js +++ b/tests/unit/cli-output.test.js @@ -1,6 +1,6 @@ import { test } from "node:test"; import assert from "node:assert/strict"; -import { maskToken, writeJsonOr, writeStatusLine } from "../../lib/output.js"; +import { formatDiagnosticValue, maskToken, writeJsonOr, writeStatusLine } from "../../lib/output.js"; test("writeStatusLine escapes terminal control bytes in the assembled line", () => { /** @type {string[]} */ @@ -10,6 +10,21 @@ test("writeStatusLine escapes terminal control bytes in the assembled line", () assert.doesNotMatch(lines[0], new RegExp(String.fromCharCode(27)), "raw ESC must not pass through"); }); +test("formatDiagnosticValue renders values JSON.stringify would misrepresent", () => { + assert.equal(formatDiagnosticValue(Number.NaN), "NaN"); + assert.equal(formatDiagnosticValue(Number.POSITIVE_INFINITY), "Infinity"); + assert.equal(formatDiagnosticValue(Number.NEGATIVE_INFINITY), "-Infinity"); + assert.equal(formatDiagnosticValue(0), "0"); + assert.equal(formatDiagnosticValue("restart"), '"restart"'); + assert.equal(formatDiagnosticValue(null), "null"); + assert.equal(formatDiagnosticValue(new Date(0)), "datetime 1970-01-01T00:00:00.000Z"); + assert.doesNotMatch( + formatDiagnosticValue(`bad${String.fromCharCode(27)}[2J`), + new RegExp(String.fromCharCode(27)), + "raw ESC must not pass through" + ); +}); + test("writeJsonOr emits JSON and reports handled, or defers to the human path", () => { /** @type {string[]} */ const out = []; diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index c2e56b8..0de28f3 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -99,6 +99,26 @@ export function response(body, status = 200) { }; } +// A deploy issues at most two control calls: deploy, then promote if the CLI +// accepts the deploy response. Record them and answer the first call with the +// deploy body and any later call with the promote body. +/** + * @param {unknown} deployBody + * @param {unknown} promoteBody + */ +export function deployPromoteFetch(deployBody, promoteBody) { + /** @type {ControlCall[]} */ + const calls = []; + return { + calls, + /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} [init] */ + controlFetch: async (url, init = {}) => { + calls.push({ url, init }); + return response(calls.length === 1 ? deployBody : promoteBody); + }, + }; +} + // Records control-plane calls and stdout lines, returning deps for a command // runner. env defaults to a bare admin token; pass a richer env (e.g. with // WDL_NS) when the command resolves the namespace from the environment. From 3211e2b9a04f274ae2f766f95509d829b278203f Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Wed, 5 Aug 2026 23:14:32 +0800 Subject: [PATCH 2/5] Take patched undici and brace-expansion in the install tree Override undici to ^7.29.0: every miniflare release pins 7.28.0 exactly, so no wrangler version resolves the five advisories on its own, and refresh brace-expansion to 5.0.9 for GHSA-rgw5-rvv9-x895. Co-Authored-By: Claude Fable 5 Signed-off-by: Lu Zhang --- package-lock.json | 12 ++++++------ package.json | 3 +++ 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/package-lock.json b/package-lock.json index cd35a6b..f515152 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1469,9 +1469,9 @@ "license": "MIT" }, "node_modules/brace-expansion": { - "version": "5.0.8", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", - "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==", + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", "dev": true, "license": "MIT", "dependencies": { @@ -2305,9 +2305,9 @@ } }, "node_modules/undici": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", - "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.29.0.tgz", + "integrity": "sha512-IDxfleLmmbSskfWSUATiN1nfn2rDuvnMOqb5CWR92iIfojA0Ud+ulOAAEQ57LPr9rWmsreUyf5lwyao+7GNNVw==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 6a8f8cb..2336ce4 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,9 @@ "smol-toml": "^1.7.0", "wrangler": "4.114.0" }, + "overrides": { + "undici": "^7.29.0" + }, "engines": { "node": ">=22" }, From 5a855abe4539df3ea310f0ef21c89bb7ef9eddef Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Wed, 5 Aug 2026 23:31:46 +0800 Subject: [PATCH 3/5] Keep diagnostics total and recovery guidance consistent Render an invalid Date as "datetime invalid" instead of letting toISOString() throw out of a formatter whose parameter contract is unknown, and describe asRecord as a config-table narrowing that rejects arrays and TOML datetimes. State the control-skew recovery the way the CLI does: re-running before upgrading control cannot help, so upgrade control and deploy again. Co-Authored-By: Claude Fable 5 Signed-off-by: Lu Zhang --- GUIDE-zh.md | 2 +- GUIDE.md | 30 +++++++++++------------ docs/deploy-zh.md | 2 +- docs/deploy.md | 46 +++++++++++++++++------------------ lib/output.js | 6 ++++- lib/wrangler/utils.js | 3 ++- tests/unit/cli-output.test.js | 1 + 7 files changed, 48 insertions(+), 42 deletions(-) diff --git a/GUIDE-zh.md b/GUIDE-zh.md index daed88f..b0b086a 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -853,7 +853,7 @@ wdl tail hello | Worker URL 返回 404 | URL 形态或 worker name 不对 | 使用 `https://.//`,不要漏掉 worker name 这一段路径 | | Worker URL 返回 `502 runtime_error` | Worker `fetch()` handler 在产生响应前抛错 | 用 `wdl tail ` 和请求日志排查;异常细节不会复制到客户端响应体 | | namespace-level secret 没有立刻变化 | namespace secret 不会给所有 Worker 自动 bump 版本 | 重新部署该 Worker,或等待自然 cold-load;需要立即发布时使用 worker-level secret | -| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy` | 升级 control;version 已上传但未 promote,重跑同一条 deploy 无济于事 | +| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy` | version 已上传但未 promote;升级 control 前重跑没有意义,先升级 control 再重新部署 | | `control promoted the worker without confirming its restart session policy` | control 完成了 promote,但没有回显策略或可用的 restart 序号 | 升级 control 后重新部署;新 version 已生效,但会话可能仍固定在旧 version | | service binding 仍调用旧目标行为 | binding 在调用方部署时固定版本 | 重新部署调用方 Worker | | `wdl tail` 没有历史日志 | tail 是 live-only;首次连接只看之后的新事件 | 先打开 `wdl tail `,再触发请求;需要手动续读时使用单 worker 的 `--since ` | diff --git a/GUIDE.md b/GUIDE.md index 051b974..b9df5c8 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1201,21 +1201,21 @@ wdl tail hello ## Troubleshooting -| Symptom | Likely cause | What to check | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | -| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | -| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | -| Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | -| Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | -| Worker URL returns `502 runtime_error` | The Worker `fetch()` handler threw before producing a response | Use `wdl tail ` and request logs; exception details are intentionally not copied into the client response body | -| A namespace-level secret did not change immediately | Namespace secrets do not bump every Worker version | Redeploy the Worker or wait for a natural cold-load; use a worker-level secret for immediate rollout | -| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy` | Upgrade control; the version was uploaded but not promoted, and re-running the same deploy cannot help | -| `control promoted the worker without confirming its restart session policy` | Control promoted but did not echo the policy or a usable restart sequence | Upgrade control, then redeploy; the new version is live but its sessions may still be pinned to the old one | -| A service binding still calls the old target behavior | Bindings are pinned at caller deploy time | Redeploy the caller Worker | -| `wdl tail` has no history | Tail is live-only; first connect starts at the current stream tail | Start `wdl tail ` before triggering the request; use single-worker `--since ` only for manual resume | -| Multi-worker `wdl tail` can miss logs after reconnect | One connection cannot preserve independent resume positions for multiple workers | Use a dedicated `wdl tail ` session for critical debugging | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream because the client stopped reading or the session hit its lifetime cap | The CLI reconnects automatically; if it repeats, make sure the terminal or wrapper is consuming output | -| Scheduled / queue handler `console.*` output is absent from `wdl tail` | Tail shows fetch / scheduled / queue start/finish; scheduled / queue handler console does not enter the tail stream | Use `wdl tail` for trigger/outcome and the normal log platform for handler console details | +| Symptom | Likely cause | What to check | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | +| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | +| Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | +| Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | +| Worker URL returns `502 runtime_error` | The Worker `fetch()` handler threw before producing a response | Use `wdl tail ` and request logs; exception details are intentionally not copied into the client response body | +| A namespace-level secret did not change immediately | Namespace secrets do not bump every Worker version | Redeploy the Worker or wait for a natural cold-load; use a worker-level secret for immediate rollout | +| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy` | The version was uploaded but not promoted; re-running before upgrading control cannot help, so upgrade control and deploy again | +| `control promoted the worker without confirming its restart session policy` | Control promoted but did not echo the policy or a usable restart sequence | Upgrade control, then redeploy; the new version is live but its sessions may still be pinned to the old one | +| A service binding still calls the old target behavior | Bindings are pinned at caller deploy time | Redeploy the caller Worker | +| `wdl tail` has no history | Tail is live-only; first connect starts at the current stream tail | Start `wdl tail ` before triggering the request; use single-worker `--since ` only for manual resume | +| Multi-worker `wdl tail` can miss logs after reconnect | One connection cannot preserve independent resume positions for multiple workers | Use a dedicated `wdl tail ` session for critical debugging | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream because the client stopped reading or the session hit its lifetime cap | The CLI reconnects automatically; if it repeats, make sure the terminal or wrapper is consuming output | +| Scheduled / queue handler `console.*` output is absent from `wdl tail` | Tail shows fetch / scheduled / queue start/finish; scheduled / queue handler console does not enter the tail stream | Use `wdl tail` for trigger/outcome and the normal log platform for handler console details | ## Compatibility Summary diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index c21a477..049a4e9 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -158,7 +158,7 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `worker_code_invalid` | 按 control plane 返回的原因修正 Worker bundle 形状,包括 WDL 保留注入模块名。 | | `wrangler build failed` | 在项目里跑 `npx wrangler deploy --dry-run` 然后在那边修。 | | 部署成功但 promote 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | -| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy`;version 已上传但未 promote。需要升级 control——重跑同一条 deploy 无济于事。 | +| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy`;version 已上传但未 promote。升级 control 前重跑没有意义;先升级 control,再重新部署。 | | `control promoted the worker without confirming its restart session policy` | 新 version 已经生效,但其 session policy 未被确认;升级 control 后重新部署以获得确认的 restart。 | | Worker URL 返回 404 | URL 缺了 `/` 这一段。 | | `wdl tail` 没有历史日志 | tail 是 live-only;先打开 `wdl tail ` 再触发请求。 | diff --git a/docs/deploy.md b/docs/deploy.md index 0aca5fe..f4294aa 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -276,29 +276,29 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). ## Common errors -| Symptom | Cause / fix | -| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | -| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | -| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | -| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | -| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | -| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | -| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | -| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | -| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | -| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | -| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | -| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | -| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | -| Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | -| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy`; the version was uploaded but not promoted. Upgrade control — re-running the same deploy cannot help. | -| `control promoted the worker without confirming its restart session policy` | The new version is live but its session policy is unconfirmed; upgrade control, then redeploy to obtain a confirmed restart. | -| Worker URL returns 404 | The URL is missing the `/` segment. | -| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | -| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | -| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | +| Symptom | Cause / fix | +| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | +| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | +| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | +| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | +| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | +| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | +| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | +| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | +| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | +| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | +| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | +| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | +| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | +| Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | +| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy`; the version was uploaded but not promoted. Re-running before upgrading control cannot help; upgrade control, then deploy again. | +| `control promoted the worker without confirming its restart session policy` | The new version is live but its session policy is unconfirmed; upgrade control, then redeploy to obtain a confirmed restart. | +| Worker URL returns 404 | The URL is missing the `/` segment. | +| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | +| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | +| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | ## Anti-patterns diff --git a/lib/output.js b/lib/output.js index 80065fc..304cfef 100644 --- a/lib/output.js +++ b/lib/output.js @@ -56,7 +56,11 @@ export function formatDiagnosticValue(value) { // JSON.stringify renders NaN and ±Infinity as "null", hiding what was written. if (typeof value === "number" && !Number.isFinite(value)) return escapeTerminalText(String(value)); // TOML datetimes parse into Date; quoting their ISO form would read as a string. - if (value instanceof Date) return escapeTerminalText(`datetime ${value.toISOString()}`); + // toISOString() throws on an invalid Date, and a diagnostic must never throw. + if (value instanceof Date) { + const stamp = Number.isNaN(value.getTime()) ? "invalid" : value.toISOString(); + return escapeTerminalText(`datetime ${stamp}`); + } return escapeTerminalText(JSON.stringify(value)); } diff --git a/lib/wrangler/utils.js b/lib/wrangler/utils.js index 2034068..6394d9a 100644 --- a/lib/wrangler/utils.js +++ b/lib/wrangler/utils.js @@ -17,7 +17,8 @@ export function hasOwn(obj, key) { } /** - * A non-null, non-array object viewed as a string-keyed record, or null. + * A config table viewed as a string-keyed record, or null. Arrays and TOML + * datetimes are objects but not tables. * @param {unknown} value * @returns {Record | null} */ diff --git a/tests/unit/cli-output.test.js b/tests/unit/cli-output.test.js index ba9a9cb..a51e7c8 100644 --- a/tests/unit/cli-output.test.js +++ b/tests/unit/cli-output.test.js @@ -18,6 +18,7 @@ test("formatDiagnosticValue renders values JSON.stringify would misrepresent", ( assert.equal(formatDiagnosticValue("restart"), '"restart"'); assert.equal(formatDiagnosticValue(null), "null"); assert.equal(formatDiagnosticValue(new Date(0)), "datetime 1970-01-01T00:00:00.000Z"); + assert.equal(formatDiagnosticValue(new Date(Number.NaN)), "datetime invalid"); assert.doesNotMatch( formatDiagnosticValue(`bad${String.fromCharCode(27)}[2J`), new RegExp(String.fromCharCode(27)), From f750447e8646640872b141ce0106972fddebda36 Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 6 Aug 2026 22:55:05 +0800 Subject: [PATCH 4/5] Report deploy failures as outcomes, not instructions Require both control answers to name what they did, so an unconfirmed promote reads as unknown instead of claiming it did not happen, and state each outcome without printing commands, assembled names or recovery advice. Reject a bare TOML datetime wherever a config table belongs, and give the deploy chapter sole ownership of what a failed deploy leaves behind. Co-Authored-By: Claude Fable 5 Signed-off-by: Lu Zhang --- .claude/skills/wdl-deploy/SKILL.md | 4 +- CHANGELOG.md | 28 ++- GUIDE-zh.md | 7 +- GUIDE.md | 31 +-- commands/deploy.js | 80 ++++--- docs/deploy-zh.md | 13 +- docs/deploy.md | 74 ++++--- docs/durable-objects-zh.md | 4 +- docs/durable-objects.md | 7 +- lib/output.js | 4 +- lib/wrangler-pack.js | 2 +- lib/wrangler/config.js | 2 +- tests/unit/cli-deploy.test.js | 330 +++++++++++++++++++++++++---- tests/unit/helpers.js | 21 +- 14 files changed, 458 insertions(+), 149 deletions(-) diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index c3bd3ec..5268428 100644 --- a/.claude/skills/wdl-deploy/SKILL.md +++ b/.claude/skills/wdl-deploy/SKILL.md @@ -16,8 +16,8 @@ Open the relevant doc before answering: - `docs/README.md` — main docs entry, GUIDE/docs division, picking the topic doc by task. - `docs/deploy.md` — `wdl deploy` / `wdl tail`, credentials, the Worker URL - shape, supported/unsupported wrangler config, common errors, destructive - commands. + shape, supported/unsupported wrangler config, `[wdl] session_policy`, what a + failed deploy leaves behind, common errors, destructive commands. - `docs/secrets.md` — `wdl secret` (worker-level vs namespace-level), runtime secret precedence, `--json` automation output, anti-patterns. - `docs/token.md` — `wdl token set/list/use/rm`, the local credential store diff --git a/CHANGELOG.md b/CHANGELOG.md index 487410e..c2eddf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,35 @@ ### Added -- Added `[wdl] session_policy = "restart"` for opt-in session restarts, matching - Cloudflare's default deploy behavior. The default remains `preserve`; a - restart promotion closes the worker's open WebSockets with code `1012` and +- `[wdl] session_policy = "restart"` opts a Worker into session restarts, + matching Cloudflare's default deploy behavior. The default remains `preserve`; + a restart promotion closes the worker's open WebSockets with code `1012` and aborts stale Durable Object facets on their next dispatch while keeping SQLite state. `wdl deploy` refuses to promote when control does not confirm the policy. +### Fixed + +- Reject a bare TOML datetime where a config table is expected — `[wdl]`, + `[vars]`, `[assets]`, `[triggers]`, `[queues]`, `[durable_objects]`, + `[env.]` and the like — instead of reading it as an empty table and + silently dropping the section. +- Report an unknown promotion outcome when a timeout, transport failure, 3xx/5xx + or unconfirmed response answers the promote, instead of claiming the version + was not promoted. Only a 4xx now says control rejected it, and a deploy that + stops before promoting says the uploaded version was retained. + +### Security + +- Override `undici` to `^7.29.0` and refresh `brace-expansion` to 5.0.9, + clearing five undici advisories and GHSA-rgw5-rvv9-x895. The `miniflare` + releases the pinned `wrangler` depends on pin undici 7.28.0 exactly, so an + override is the only way this repository's install tree takes the patch; npm + honours `overrides` for the root project only, so installs of the published + CLI keep resolving miniflare's pin until it moves. Neither package is on a CLI + code path: undici is reachable only through the miniflare dev server the CLI + never runs, and brace-expansion only through ESLint. + ## 1.6.1 ### Changed diff --git a/GUIDE-zh.md b/GUIDE-zh.md index b0b086a..cbd853f 100644 --- a/GUIDE-zh.md +++ b/GUIDE-zh.md @@ -466,7 +466,7 @@ export default { session_policy = "restart" ``` -此时 active WebSocket 会在 promotion 时立即以 `1012` 关闭,stale facet 则在下一次 dispatch 时中止;client 必须重连并重新执行应用握手。部署即重启是 Cloudflare 的默认行为,WDL 将其作为可选项;该策略并非 Durable Object 专属——包括纯 WebSocket worker 在内的任何 worker 都可以设置它。 +此时 active WebSocket 会在 promotion 时以 `1012` 关闭,stale facet 则在下一次 dispatch 时中止;client 必须重连并重新执行应用握手。部署即重启是 Cloudflare 的默认行为,WDL 将其作为可选项;该策略并非 Durable Object 专属——包括纯 WebSocket worker 在内的任何 worker 都可以设置它。 当前支持 `stub.fetch()`、JSON-structured `stub.method(...args)` RPC、native `ctx.storage`、同步 `ctx.storage.sql`、alarm、普通 WebSocket upgrade 以及 native WebSocket hibernation API surface。跨 script binding、rename/delete migration、平台级 WebSocket session/cursor 恢复暂未实现。 @@ -853,8 +853,9 @@ wdl tail hello | Worker URL 返回 404 | URL 形态或 worker name 不对 | 使用 `https://.//`,不要漏掉 worker name 这一段路径 | | Worker URL 返回 `502 runtime_error` | Worker `fetch()` handler 在产生响应前抛错 | 用 `wdl tail ` 和请求日志排查;异常细节不会复制到客户端响应体 | | namespace-level secret 没有立刻变化 | namespace secret 不会给所有 Worker 自动 bump 版本 | 重新部署该 Worker,或等待自然 cold-load;需要立即发布时使用 worker-level secret | -| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy` | version 已上传但未 promote;升级 control 前重跑没有意义,先升级 control 再重新部署 | -| `control promoted the worker without confirming its restart session policy` | control 完成了 promote,但没有回显策略或可用的 restart 序号 | 升级 control 后重新部署;新 version 已生效,但会话可能仍固定在旧 version | +| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy` | version 已上传并被保留,但没有 promote;先升级 control 再重新部署 | +| `control promoted the worker without confirming its restart session policy` | control 完成了 promote,但没有回显策略或可用的 restart 序号 | version 已生效,但会话可能没有重启;让必须运行新版本的 client 重连,或在 control 能确认该策略后重新部署 | +| `the promotion outcome is unknown` | promote 遇到 timeout、传输失败、3xx/5xx,或 2xx 未确认 | 重试前先用 `wdl workers` 确认 active version;重跑 deploy 会再上传一个版本并可能再次重启会话 | | service binding 仍调用旧目标行为 | binding 在调用方部署时固定版本 | 重新部署调用方 Worker | | `wdl tail` 没有历史日志 | tail 是 live-only;首次连接只看之后的新事件 | 先打开 `wdl tail `,再触发请求;需要手动续读时使用单 worker 的 `--since ` | | 多 worker `wdl tail` 重连后可能少日志 | 一个连接无法同时保存多个 worker 的独立续读位置 | 对关键 worker 单独运行 `wdl tail ` | diff --git a/GUIDE.md b/GUIDE.md index b9df5c8..366af03 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -1201,21 +1201,22 @@ wdl tail hello ## Troubleshooting -| Symptom | Likely cause | What to check | -| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | -| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | -| Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | -| Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | -| Worker URL returns `502 runtime_error` | The Worker `fetch()` handler threw before producing a response | Use `wdl tail ` and request logs; exception details are intentionally not copied into the client response body | -| A namespace-level secret did not change immediately | Namespace secrets do not bump every Worker version | Redeploy the Worker or wait for a natural cold-load; use a worker-level secret for immediate rollout | -| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy` | The version was uploaded but not promoted; re-running before upgrading control cannot help, so upgrade control and deploy again | -| `control promoted the worker without confirming its restart session policy` | Control promoted but did not echo the policy or a usable restart sequence | Upgrade control, then redeploy; the new version is live but its sessions may still be pinned to the old one | -| A service binding still calls the old target behavior | Bindings are pinned at caller deploy time | Redeploy the caller Worker | -| `wdl tail` has no history | Tail is live-only; first connect starts at the current stream tail | Start `wdl tail ` before triggering the request; use single-worker `--since ` only for manual resume | -| Multi-worker `wdl tail` can miss logs after reconnect | One connection cannot preserve independent resume positions for multiple workers | Use a dedicated `wdl tail ` session for critical debugging | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream because the client stopped reading or the session hit its lifetime cap | The CLI reconnects automatically; if it repeats, make sure the terminal or wrapper is consuming output | -| Scheduled / queue handler `console.*` output is absent from `wdl tail` | Tail shows fetch / scheduled / queue start/finish; scheduled / queue handler console does not enter the tail stream | Use `wdl tail` for trigger/outcome and the normal log platform for handler console details | +| Symptom | Likely cause | What to check | +| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `Missing admin token` | No tenant token was provided | Run `wdl token set --ns --control-url ` (recommended), set `ADMIN_TOKEN`, or pass `--token` | +| `wrangler build failed` | Wrangler could not bundle the Worker project | Run `npx wrangler deploy --dry-run` inside the Worker project and fix local build/config errors | +| Deploy succeeds but promote fails | Route, custom host, or binding validation failed at promotion time | Check that custom hosts are enabled for your namespace and service-binding targets exist | +| Worker URL returns 404 | URL shape or worker name is wrong | Use `https://.//`; include the worker name path segment | +| Worker URL returns `502 runtime_error` | The Worker `fetch()` handler threw before producing a response | Use `wdl tail ` and request logs; exception details are intentionally not copied into the client response body | +| A namespace-level secret did not change immediately | Namespace secrets do not bump every Worker version | Redeploy the Worker or wait for a natural cold-load; use a worker-level secret for immediate rollout | +| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy` | The version was uploaded and retained but not promoted; upgrade control and deploy again | +| `control promoted the worker without confirming its restart session policy` | Control promoted but did not echo the policy or a usable restart sequence | The version is live but its sessions may not have restarted; reconnect clients that must run it, or deploy again once control confirms the policy | +| `the promotion outcome is unknown` | A timeout, transport failure, 3xx/5xx, or an unconfirmed 2xx answered the promote | Check the active version with `wdl workers` before retrying; re-running the deploy uploads another version and may restart sessions again | +| A service binding still calls the old target behavior | Bindings are pinned at caller deploy time | Redeploy the caller Worker | +| `wdl tail` has no history | Tail is live-only; first connect starts at the current stream tail | Start `wdl tail ` before triggering the request; use single-worker `--since ` only for manual resume | +| Multi-worker `wdl tail` can miss logs after reconnect | One connection cannot preserve independent resume positions for multiple workers | Use a dedicated `wdl tail ` session for critical debugging | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream because the client stopped reading or the session hit its lifetime cap | The CLI reconnects automatically; if it repeats, make sure the terminal or wrapper is consuming output | +| Scheduled / queue handler `console.*` output is absent from `wdl tail` | Tail shows fetch / scheduled / queue start/finish; scheduled / queue handler console does not enter the tail stream | Use `wdl tail` for trigger/outcome and the normal log platform for handler console details | ## Compatibility Summary diff --git a/commands/deploy.js b/commands/deploy.js index 5a93586..29b8bec 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -56,6 +56,16 @@ function usageText() { * @property {string[]} [missingCallerSecrets] */ +/** @param {boolean} restartRequested */ +function unknownPromoteNote(restartRequested) { + const restarted = restartRequested ? " and closed existing sessions" : ""; + return `note: the promotion outcome is unknown; control may have promoted this version already${restarted}.`; +} + +const REJECTED_PROMOTE_NOTE = + "note: control rejected the promotion, so this version was not activated and traffic is unchanged; " + + "it may still be retained."; + /** * The promote response fields the CLI reads back. * @typedef {{ platformDomain?: unknown, workersDev?: unknown, sessionPolicy?: unknown, restartSequence?: unknown, urls?: unknown }} PromoteResponseBody @@ -73,7 +83,7 @@ function usageText() { * controlUrl: string, * authHeaders: Record, * }} arg - * @returns {Promise<{ version: unknown, platformDomain: unknown, workersDev: unknown, urls: unknown }>} + * @returns {Promise<{ version: string, platformDomain: unknown, workersDev: unknown, urls: unknown }>} */ export async function postArtifactToControl({ context, ns, workerName, manifest, controlUrl, authHeaders }) { const { stdout, stderr } = context; @@ -87,14 +97,8 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, const deployBody = serializeDeployManifest(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, - workersDev: deployedWorkersDev, - sessionPolicy: deployedSessionPolicy, - } = /** @type {{ version: unknown, warnings?: DeployWarning[], workersDev?: unknown, sessionPolicy?: unknown }} */ ( + // `version` is the body of the promote request, so the response must carry one. + const deployed = asRecord( await fetchDeployJson({ context, url: context.nsUrl("worker", workerName, "deploy"), @@ -110,40 +114,49 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, stderr, }) ); - renderDeployWarnings(warnings, { ns, workerName, stderr }); + renderDeployWarnings(/** @type {DeployWarning[] | undefined} */ (deployed?.warnings), { ns, workerName, stderr }); + const version = deployed?.version; + if (typeof version !== "string" || !version) { + throw new CliError( + "deploy failed: control's response named no version, so nothing was promoted; " + + "any version it retained cannot be identified from here." + ); + } + const deployedWorkersDev = deployed.workersDev; + const deployedSessionPolicy = deployed.sessionPolicy; 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`." + "control did not confirm workers_dev = false, so nothing was promoted; the uploaded version was retained." ); } if (sessionPolicyRestartRequested && deployedSessionPolicy !== "restart") { throw new CliError( - "control did not confirm session_policy = restart; the uploaded version was retained but NOT promoted. " + - "Upgrade control and re-run `wdl deploy`." + "control did not confirm session_policy = restart, so nothing was promoted; the uploaded version was retained." ); } writeStatusLine(stdout, `[3/3] promoting ${version}`); /** @type {PromoteResponseBody} */ let promoteBody; + let promoteRejected = false; try { - promoteBody = /** @type {PromoteResponseBody} */ ( - await context.fetchJson( - context.nsUrl("worker", workerName, "promote"), - { - method: "POST", - headers: jsonHeaders, - body: JSON.stringify({ version }), - }, - "promote" - ) - ); + const res = await context.controlFetch(context.nsUrl("worker", workerName, "promote"), { + method: "POST", + headers: jsonHeaders, + body: JSON.stringify({ version }), + env: context.env, + }); + // A 3xx or 5xx can come from a proxy in front of control, after control + // committed the route flip and, under a restart policy, closed sessions. + promoteRejected = res.status !== undefined && res.status >= 400 && res.status < 500; + const body = asRecord(await readJsonOrFail(res, "promote")); + // Whoever produced this answer, only control's own acknowledgement counts. + if (body?.active !== true || body.version !== version) { + throw new CliError(`promote failed: response did not confirm ${escapeTerminalText(version)} is active`); + } + promoteBody = /** @type {PromoteResponseBody} */ (body); } catch (err) { - stderr( - `note: version ${escapeTerminalText(String(version))} was uploaded and retained but NOT promoted; ` + - `the previously active version still serves traffic. Re-run \`wdl deploy\` to retry.` - ); + stderr(promoteRejected ? REJECTED_PROMOTE_NOTE : unknownPromoteNote(sessionPolicyRestartRequested)); throw err; } if (workersDevOptOutRequested && promoteBody.workersDev !== false) { @@ -160,8 +173,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, ) { throw new CliError( "control promoted the worker without confirming its restart session policy; " + - "the new version is live, but existing sessions may still be pinned to the old version. " + - "Upgrade control, then redeploy." + "the new version is live, but existing sessions may not have restarted." ); } return { @@ -178,7 +190,7 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, * @returns {{ platform: string | null, routes: string[] }} */ function promotedWorkerUrlHints(raw, includePlatform) { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { + if (!asRecord(raw)) { return { platform: null, routes: [] }; } const urls = /** @type {{ platform?: unknown, routes?: unknown }} */ (raw); @@ -295,7 +307,7 @@ function stripRenderedWarnings(text) { } catch { return text; } - if (!body || typeof body !== "object" || Array.isArray(body)) return text; + if (!asRecord(body)) return text; const record = /** @type {Record} */ (body); if (!Array.isArray(record.warnings)) return text; const { warnings: _warnings, ...rest } = record; @@ -311,7 +323,7 @@ function deployErrorHint(text) { } catch { return ""; } - if (!body || typeof body !== "object" || Array.isArray(body)) return ""; + if (!asRecord(body)) return ""; const error = /** @type {{ error?: unknown }} */ (body).error; if (error === "worker_env_too_large") { return "; reduce [vars], secrets, or binding metadata. If the error names a retained version, redeploy/delete that version."; diff --git a/docs/deploy-zh.md b/docs/deploy-zh.md index 049a4e9..75e34b4 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -84,13 +84,15 @@ Cloudflare 用 `workers_dev` 控制 Worker 的 `*.workers.dev` route;版本化 6. **应用 D1 迁移**,如果设置了 `migrations_dir` —— 见 [d1-zh.md](./d1-zh.md)。 7. **部署:** `wdl deploy .`。CLI 会打印上传、提升、运行时 URL —— 把这个 URL 给用户看。 +一次 deploy 是两个请求:上传,然后 promote。上传必须回报它保留的 version;只有当 control 回应它所激活的 version 时,promotion 才算完成。其余情况——timeout、传输失败、来自任何一方的 3xx/5xx、无法解析的 body,或者确认了另一个 version——都让结果不确定:control 可能已经完成了 promote。这时 CLI 会如实报告结果未知。只有 4xx 表示 control 拒绝了这次 promotion:version 没有被激活,流量不变。CLI 只报告发生了什么然后停下,不会尝试自动恢复。再次部署前请用 `wdl workers` 确认 active version,因为再跑一次 deploy 会再上传一个版本。 + Deploy 上传给 control 的 manifest JSON 最大 32 MiB。Assets 在部署时会嵌进这个 JSON 请求;如果静态文件集合较大,可能先撞到 control request cap。大体积或频繁变化的文件用 R2,不要放进 assets。 Control plane 会按留有 headroom 的 workerd 1 MiB `workerLoader` environment 预算校验(可用 1,040,384 bytes)。过大的 `[vars]`、secrets、binding metadata 或 retained versions 可能触发 `worker_env_too_large`;减少 env payload,或在错误点名已有版本时 redeploy/delete 该 retained version。 ## 会话策略 -`[wdl] session_policy` 决定 promotion 如何处理该 worker 的既有会话。它接受 `preserve`(默认)或 `restart`,并像 `workers_dev` 一样继承进 `[env.]`: +`[wdl] session_policy` 决定 promotion 如何处理该 worker 的既有会话。它接受 `preserve`(默认)或 `restart`,并像 `workers_dev` 一样继承进 `[env.]`。env 自己的 `[wdl]` 会整表覆盖顶层那张表。`preserve` 下,已打开的 WebSocket 会在 backend 仍健康时继续在原 version 上 drain,已加载的 Durable Object facet 也留在构造它的 version 上。要选择相反的行为: ```toml [wdl] @@ -99,7 +101,7 @@ session_policy = "restart" `restart` 下,promote 会以 `1012` 关闭该 worker 打开的 WebSocket,并让 stale Durable Object facet 在下一次 dispatch 时退役,同时保留 SQLite state([facet 细节](./durable-objects-zh.md#会话策略与-facet));client 需要重连并重新执行应用握手。每一次 promotion 都算,包括 worker 级 secret 变更触发的那一次;namespace secret 不会 promote,因此也不会重启会话。 -CLI 会校验 control 确实应用了该策略。deploy 响应没有回显时,version 会被保留且不会 promote;promote 响应没有确认时,promotion 已经发生——新 version 已经生效,其会话可能仍固定在旧 version 上,CLI 会失败以免这个差异被忽略。两种情况都说明 control 比当前 CLI 旧。 +`restart` 部署会被校验两次:deploy 响应没有回显该策略,说明 control 版本早于它,此时 version 保持 retained 且不会 promote;promotion 本身没有确认该策略时,version 已经生效而会话可能没有重启,CLI 会失败而不是让它悄悄过去。 ## 环境覆盖 @@ -157,9 +159,10 @@ Cron triggers 和 queue consumers 是 runtime dispatch 能力,只应声明在 | `worker_code_too_large` | 减少生成的 Worker code 大小,或拆分 worker。 | | `worker_code_invalid` | 按 control plane 返回的原因修正 Worker bundle 形状,包括 WDL 保留注入模块名。 | | `wrangler build failed` | 在项目里跑 `npx wrangler deploy --dry-run` 然后在那边修。 | -| 部署成功但 promote 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | -| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy`;version 已上传但未 promote。升级 control 前重跑没有意义;先升级 control,再重新部署。 | -| `control promoted the worker without confirming its restart session policy` | 新 version 已经生效,但其 session policy 未被确认;升级 control 后重新部署以获得确认的 restart。 | +| `the promotion outcome is unknown` | promote 遇到 timeout、传输失败、3xx/5xx 或未确认的 2xx。再次部署前先用 `wdl workers` 确认 active version。 | +| `control rejected the promotion` | control 拒绝了这个 version——常见于自定义 host 或 service binding 目标校验失败。按它报告的原因修复后重新部署。 | +| `control did not confirm session_policy = restart` | control 版本早于 `[wdl] session_policy`;version 已上传并被保留,但没有 promote。先升级 control 再重新部署。 | +| `control promoted the worker without confirming its restart session policy` | version 已经生效,但会话可能没有重启。让必须运行新版本的 client 重连,或在 control 能确认该策略后重新部署。 | | Worker URL 返回 404 | URL 缺了 `/` 这一段。 | | `wdl tail` 没有历史日志 | tail 是 live-only;先打开 `wdl tail ` 再触发请求。 | | `tail session_idle` / `tail session_expired` | control 回收了 live-tail stream;CLI 会自动重连,除非达到重连上限。 | diff --git a/docs/deploy.md b/docs/deploy.md index f4294aa..3ee8c63 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -153,6 +153,17 @@ changes only the control socket target and never a printed Worker origin. 7. **Deploy:** `wdl deploy .`. The CLI prints the upload, the promote, and the runtime URL — show that URL to the user. +A deploy is two requests: upload, then promote. The upload must name the version +it retained, and a promotion counts as done only when control answers with the +version it activated. Anything else — a timeout, a transport failure, a 3xx or +5xx from whatever answered, an unreadable body, or an acknowledgement for +another version — leaves the outcome unknown, and the CLI reports it as unknown: +control may already have promoted the version. Only a 4xx says control rejected +the promotion, in which case the version was not activated and traffic is +unchanged. The CLI reports what happened and stops; it never attempts recovery. +Check the active version with `wdl workers` before deploying again, since +another deploy uploads another version. + The manifest JSON that deploy uploads to control is capped at 32 MiB. Assets are embedded in that JSON request at deploy time; a large static file set can hit the control request cap first. Put bulk or frequently changing files in R2, not @@ -167,7 +178,10 @@ or redeploy/delete the retained version named in the error when one is shown. `[wdl] session_policy` decides what a promotion does to the worker's established sessions. It accepts `preserve` (default) or `restart` and inherits into -`[env.]` like `workers_dev`: +`[env.]` like `workers_dev`; an env's own `[wdl]` replaces the top-level +table whole. Under `preserve`, open WebSockets keep draining on the version they +connected to while that backend stays healthy, and loaded Durable Object facets +stay on the version that built them. To opt out of that: ```toml [wdl] @@ -181,12 +195,11 @@ reconnect and repeat their application handshake. Every promotion counts, including the one a worker-level secret change performs; namespace secrets do not promote, so they do not restart sessions. -The CLI checks that control applied the policy. When the deploy response does -not echo it, the version is retained and never promoted. When the promote -response does not confirm it, the promotion has already happened — the new -version is live, its sessions may still be pinned to the old one, and the CLI -fails so the discrepancy is not silent. Both cases mean the control plane is -older than this CLI. +A `restart` deploy is verified twice. If the deploy response does not echo the +policy, the control plane predates it: the version stays retained and is never +promoted. If the promotion itself does not confirm the policy, the version is +already live and its sessions may not have restarted, so the CLI fails rather +than let that pass silently. ## Environment overrides @@ -276,29 +289,30 @@ Deleting a worker does **not** delete R2 data — see [r2.md](./r2.md). ## Common errors -| Symptom | Cause / fix | -| --------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | -| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | -| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | -| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | -| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | -| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | -| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | -| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | -| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | -| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | -| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | -| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | -| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | -| Deploy succeeds but promote fails | Custom host or service-binding target validation issue; check the binding targets. | -| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy`; the version was uploaded but not promoted. Re-running before upgrading control cannot help; upgrade control, then deploy again. | -| `control promoted the worker without confirming its restart session policy` | The new version is live but its session policy is unconfirmed; upgrade control, then redeploy to obtain a confirmed restart. | -| Worker URL returns 404 | The URL is missing the `/` segment. | -| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | -| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | -| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | -| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | +| Symptom | Cause / fix | +| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `wdl: command not found` | The CLI is not on PATH. Inside the wdl-cli repo use `node /bin/wdl.js`; otherwise run `npm i -g @wdl-dev/cli`. | +| `Missing admin token` | No token resolved. Run `wdl token set --ns --control-url ` (recommended), or set `ADMIN_TOKEN` / pass `--token` / use the `[]` section of `.env`. | +| `401 unknown_token: unauthorized` | The token is invalid for this control plane / namespace. Re-check `ADMIN_TOKEN`. | +| `[vars] must be an object` | Use a `[vars]` table/object; arrays are invalid. | +| `[vars] : only string/number/boolean values are supported` | Remove nested values; move sensitive strings to a secret. | +| `binding name collision: ` | `[vars]`, explicit bindings, or the implicit `ASSETS` binding reused a runtime env name. Rename one of them. | +| `experimental_compat_flag_unsupported` | Remove the experimental workerd compatibility flag. | +| `compatibility_flag_unsupported` | Remove the unsupported compatibility flag named by control. | +| `python_workers_unsupported` | Python Workers are not supported by WDL; remove Python Worker modules. The CLI also fails fast on local `.py` modules. | +| `worker_env_too_large` | Reduce `[vars]`, secrets, or binding metadata; redeploy/delete any retained version named in the error. | +| `worker_code_too_large` | Reduce generated Worker code size or split the worker. | +| `worker_code_invalid` | Fix the Worker bundle shape reported by the control plane, including WDL-reserved injected module names. | +| `wrangler build failed` | Run `npx wrangler deploy --dry-run` inside the project and fix it there. | +| `the promotion outcome is unknown` | A timeout, transport failure, 3xx/5xx or unconfirmed 2xx answered the promote. Check the active version with `wdl workers` before deploying again. | +| `control rejected the promotion` | Control refused this version — often a custom host or service-binding target that failed validation. Fix what it reported, then deploy again. | +| `control did not confirm session_policy = restart` | The control plane predates `[wdl] session_policy`; the version was uploaded and retained but not promoted. Upgrade control, then deploy again. | +| `control promoted the worker without confirming its restart session policy` | The version is live but its sessions may not have restarted. Reconnect clients that must run it, or deploy again once control confirms the policy. | +| Worker URL returns 404 | The URL is missing the `/` segment. | +| `wdl tail` has no history | Tail is live-only; open `wdl tail ` before triggering the request. | +| `tail session_idle` / `tail session_expired` | Control reclaimed the live-tail stream; the CLI reconnects automatically unless the reconnect cap is reached. | +| Namespace secret did not take effect | NS-level secrets do not force-bump workers; redeploy once or use a worker-level secret. | +| Service binding still hits the old target | Bindings are pinned at caller deploy time; redeploy the caller. | ## Anti-patterns diff --git a/docs/durable-objects-zh.md b/docs/durable-objects-zh.md index 637f6c3..5c9555c 100644 --- a/docs/durable-objects-zh.md +++ b/docs/durable-objects-zh.md @@ -29,9 +29,9 @@ session_policy = "restart" `session_policy` 接受 `preserve` 或 `restart`,默认是 `preserve`。它在新 version 被 promote 时生效,只上传 bundle 时不会触发。该策略并非 Durable Object 专属:它约束的是 worker 的既有会话,因此没有 Durable Object 的纯 WebSocket worker 也可以设置它。 -使用 `restart` 时,WDL 会在旧 version facet 下一次 dispatch 时将其中止,但不会删除 SQLite state。Active HTTP/RPC call 可能失败,已有 WebSocket 会在 promote 时立即以 `1012` 关闭,而不是等到 backend 丢失;client 必须重连并重新执行应用握手。下一次 invocation 会使用同一份持久化 storage 构造 active class version。这与 Cloudflare 的默认行为一致——部署新代码会重启每个 Durable Object。由已被取代的 version 排定的 alarm 会改由 active version 执行;`preserve` 则让它们留在排定它的 version 上。 +使用 `restart` 时,WDL 会在旧 version facet 下一次 dispatch 时将其中止,但不会删除 SQLite state。Active HTTP/RPC call 可能失败,已有 WebSocket 会在 promote 时以 `1012` 关闭,而不是等到 backend 丢失;client 必须重连并重新执行应用握手。下一次 invocation 会使用同一份持久化 storage 构造 active class version。这与 Cloudflare 的默认行为一致——部署新代码会重启每个 Durable Object。由已被取代的 version 排定的 alarm 会改由 active version 执行;`preserve` 则让它们留在排定它的 version 上。 -Promotion 与 route 变更在同一个事务中原子提交该策略;后续的 `preserve` promotion 可以覆盖尚未被观察到的 restart 工作,但无法撤销已经发生的关闭或 facet abort。如果 `wdl deploy` 在 promote 前失败,之前的 version 继续服务;重新运行同一个 `wdl deploy` 即可重试——除非失败原因是 control 未确认该策略,那需要先升级 control。 +Promotion 与 route 变更在同一个事务中原子提交该策略;后续的 `preserve` promotion 可以覆盖尚未被观察到的 restart 工作,但无法撤销已经发生的关闭或 facet abort。deploy 失败会留下什么、以及如何恢复,见 [deploy-zh.md](./deploy-zh.md#常见错误)。 ## Worker 代码 diff --git a/docs/durable-objects.md b/docs/durable-objects.md index d217a90..1e60ccc 100644 --- a/docs/durable-objects.md +++ b/docs/durable-objects.md @@ -54,10 +54,9 @@ version instead; `preserve` keeps them on the version that scheduled them. Promotion commits the policy atomically with the route change; a later `preserve` promotion supersedes restart work that has not been observed yet, but -cannot undo a close or a facet abort that already happened. If `wdl deploy` -fails before promotion, the previous version keeps serving; re-run the same -`wdl deploy` to retry — unless it failed because control did not confirm the -policy, which needs a control upgrade first. +cannot undo a close or a facet abort that already happened. What a failed deploy +leaves behind, and how to recover from it, is in +[deploy.md](./deploy.md#common-errors). ## Worker code diff --git a/lib/output.js b/lib/output.js index 304cfef..39f2206 100644 --- a/lib/output.js +++ b/lib/output.js @@ -55,8 +55,8 @@ export function escapeTerminalText(value) { export function formatDiagnosticValue(value) { // JSON.stringify renders NaN and ±Infinity as "null", hiding what was written. if (typeof value === "number" && !Number.isFinite(value)) return escapeTerminalText(String(value)); - // TOML datetimes parse into Date; quoting their ISO form would read as a string. - // toISOString() throws on an invalid Date, and a diagnostic must never throw. + // TOML datetimes parse into Date; quoting their ISO form would read as a string, + // and toISOString() throws on the invalid Date this narrowing also admits. if (value instanceof Date) { const stamp = Number.isNaN(value.getTime()) ? "invalid" : value.toISOString(); return escapeTerminalText(`datetime ${stamp}`); diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index a5d24ee..4a002da 100644 --- a/lib/wrangler-pack.js +++ b/lib/wrangler-pack.js @@ -99,7 +99,7 @@ const WRANGLER_OUTPUT_MAX_BUFFER = 10 * 1024 * 1024; */ function normalizeVars(vars) { if (vars == null) return {}; - if (typeof vars !== "object" || Array.isArray(vars)) { + if (!asRecord(vars)) { throw new CliError("[vars] must be an object"); } const normalized = manifestMap(); diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index c2366bc..df58c4a 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -230,7 +230,7 @@ export function parseSessionPolicy(cfg, configRel = "wrangler config") { if (!wdl) throw new Error(`${shownConfig}: [wdl] must be a table`); for (const key of Object.keys(wdl)) { if (key !== "session_policy") { - throw new Error(`${shownConfig}: [wdl] has unsupported field ${formatDiagnosticValue(key)}`); + throw new Error(`${shownConfig}: [wdl] contains unknown field(s): ${formatDiagnosticValue(key)}`); } } // Only an absent field means preserve; an explicit null is a typo, not a default. diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 1cb9fb7..4e31d75 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -730,7 +730,7 @@ test("wrangler binding parser diagnostics escape terminal controls", () => { ); assertThrowsNoRawTerminalControls( () => parseSessionPolicy({ wdl: { [bad]: true } }, badConfigRel), - /\[wdl\] has unsupported field/, + /\[wdl\] contains unknown field\(s\)/, "session policy diagnostics" ); assertThrowsNoRawTerminalControls( @@ -1620,7 +1620,7 @@ test("parseSessionPolicy validates the [wdl] session policy", () => { assert.throws(() => parseSessionPolicy({ wdl: { session_policy: "replace" } }), /must be "preserve" or "restart"/); assert.throws( () => parseSessionPolicy({ wdl: { session_policy: "restart", typo: true } }), - /\[wdl\] has unsupported field "typo"/ + /\[wdl\] contains unknown field\(s\): "typo"/ ); // An explicit null is rejected at the field and at the table boundary. assert.throws(() => parseSessionPolicy({ wdl: { session_policy: null } }), /must be "preserve" or "restart"/); @@ -1633,6 +1633,62 @@ test("parseSessionPolicy validates the [wdl] session policy", () => { assert.throws(() => parseSessionPolicy({ wdl: { session_policy: tomlDate } }), /got datetime 2026-08-04/); }); +test("runDeployCommand rejects a bare TOML datetime where a table belongs", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-datetime-table-")); + 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"', "assets = 2026-08-04"].join("\n") + ); + + let execCalled = false; + await assert.rejects( + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), + /\[assets\] must be a table/ + ); + assert.equal(execCalled, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand rejects a bare TOML datetime where [vars] belongs", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-datetime-vars-")); + 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"', "vars = 2026-08-04"].join("\n") + ); + + let execCalled = false; + await assert.rejects( + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + execFile: () => { + execCalled = true; + throw new Error("execFile should not be called"); + }, + }), + /\[vars\] must be an object/ + ); + assert.equal(execCalled, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("a bare TOML datetime is never mistaken for a table", () => { const tomlDate = parseToml("v = 2026-08-04").v; assert.throws(() => parseTriggers(tomlDate), /\[triggers\] must be a table/); @@ -2862,6 +2918,116 @@ test("runDeployCommand rejects a non-table [assets] before bundling", async () = } }); +for (const { label, body } of [ + { label: "a null body", body: null }, + { label: "no version", body: { warnings: [] } }, + { label: "a non-string version", body: { version: 7 } }, +]) { + test(`runDeployCommand refuses to promote when the deploy response has ${label}`, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-unnamed-version-")); + 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"\nmain = "src/index.js"\n'); + + let calls = 0; + await assert.rejects( + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + calls += 1; + return response(body); + }, + }), + /deploy failed: control's response named no version/ + ); + assert.equal(calls, 1, "nothing may reach promote"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +} + +test("runDeployCommand reports an unknown promotion the way it was requested", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-unknown-restart-")); + try { + mkdirSync(path.join(dir, "src"), { recursive: true }); + writeFileSync(path.join(dir, "src", "index.js"), "export default {}"); + writeFileSync(path.join(dir, "wrangler.toml"), RESTART_SESSION_POLICY_TOML); + + /** @type {string[]} */ + const stderrLines = []; + /** @type {RecordedFetch[]} */ + const calls = []; + await assert.rejects( + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => stderrLines.push(line), + execFile: fakeWranglerExecFile, + controlFetch: async ( + /** @type {string} */ url, + /** @type {import("../../lib/control-fetch.js").ControlFetchInit} */ init = {} + ) => { + calls.push({ url, init }); + if (calls.length === 1) return response({ version: "v9", sessionPolicy: "restart" }, 201); + throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + }, + }), + /socket hang up/ + ); + + assert.equal( + stderrLines.join(""), + "note: the promotion outcome is unknown; control may have promoted this version already " + + "and closed existing sessions." + ); + assert.equal(calls.length, 2); + assert.match(calls[1].url, /\/promote$/); + // The promote leg carries the credentials the upload used. + assert.deepEqual(calls[1].init.headers, calls[0].init.headers); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("runDeployCommand escapes a control-supplied version in the promote confirmation error", async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-confirm-escaping-")); + 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"\nmain = "src/index.js"\n'); + + let calls = 0; + await assert.rejects( + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: () => {}, + execFile: fakeWranglerExecFile, + controlFetch: async () => { + calls += 1; + if (calls === 1) return response({ version: `v9${ESC}[2J` }, 201); + return response({ active: true, version: "v8" }, 200); + }, + }), + (/** @type {Error} */ err) => { + assertNoRawTerminalControls(err.message, "promote confirmation error"); + assert.match(err.message, /did not confirm v9\\u001b\[2J is active/); + return true; + } + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("runDeployCommand rejects a malformed [wdl] before bundling", async () => { const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-session-policy-invalid-")); try { @@ -2952,6 +3118,8 @@ test("runDeployCommand preserves the local control scheme and port in the Worker return fetchCount === 1 ? response({ version: "v1", warnings: [] }) : response({ + active: true, + version: "v1", platformDomain: "workers.local", workersDev: true, urls: { @@ -3000,6 +3168,8 @@ test("runDeployCommand preserves canonical URL authorities and route-pattern suf return fetchCount === 1 ? response({ version: "v1", warnings: [], workersDev: true }) : response({ + active: true, + version: "v1", platformDomain: "workers.example", workersDev: true, urls: { @@ -3055,6 +3225,8 @@ test("runDeployCommand omits non-canonical URL hints without failing a promoted return fetchCount === 1 ? response({ version: "v1", warnings: [], workersDev: true }) : response({ + active: true, + version: "v1", platformDomain: "workers.example", workersDev: true, urls: { @@ -3157,7 +3329,11 @@ test("runDeployCommand does not promote when control omits the workers_dev opt-o return response({ version: "v1", warnings: [] }); }, }), - /control did not confirm workers_dev = false.*NOT promoted/ + (/** @type {Error} */ err) => { + assert.match(err.message, /control did not confirm workers_dev = false, so nothing was promoted/); + assert.match(err.message, /the uploaded version was retained/); + return true; + } ); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /\/deploy$/); @@ -3230,7 +3406,7 @@ test("runDeployCommand detects local control by hostname only", async () => { fetchCount += 1; return fetchCount === 1 ? response({ version: "v1", warnings: [] }) - : response({ platformDomain: "workers.example" }); + : response({ active: true, version: "v1", platformDomain: "workers.example" }); }, }); @@ -3270,7 +3446,7 @@ test("runDeployCommand uses the default port from a local control URL", async () fetchCount += 1; return fetchCount === 1 ? response({ version: "v1", warnings: [] }) - : response({ platformDomain: "workers.local" }); + : response({ active: true, version: "v1", platformDomain: "workers.local" }); }, }); @@ -3483,7 +3659,7 @@ test("runDeployCommand rejects vars that collide with the implicit assets bindin execFile: fakeWranglerExecFile, controlFetch: async () => { fetched = true; - return response({}); + return response({ active: true, version: "v2" }); }, }), /binding name collision: ASSETS/ @@ -3521,7 +3697,7 @@ test("runDeployCommand rejects explicit bindings that collide with the implicit execFile: fakeWranglerExecFile, controlFetch: async () => { fetched = true; - return response({}); + return response({ active: true, version: "v2" }); }, }), /binding name collision: ASSETS/ @@ -3592,7 +3768,7 @@ test("runDeployCommand rejects vars that collide with empty declared assets", as execFile: fakeWranglerExecFile, controlFetch: async () => { fetched = true; - return response({}); + return response({ active: true, version: "v2" }); }, }), /binding name collision: ASSETS/ @@ -3717,7 +3893,7 @@ test("runDeployCommand passes through wrangler output in verbose mode", async () mkdirSync(outDir, { recursive: true }); writeFileSync(path.join(outDir, "index.js"), "export default {}"); }, - controlFetch: async () => response({ version: "v1", warnings: [] }), + controlFetch: async () => response({ active: true, version: "v1", warnings: [] }), }); assert.equal(execCalls.length, 2); @@ -3827,7 +4003,7 @@ test("runDeployCommand warns with wdl secret hints for missing caller secrets", ], }); } - return response({}); + return response({ active: true, version: "v2" }); }, }); @@ -4209,7 +4385,7 @@ test("runDeployCommand projects unknown deploy warnings before printing", async ], }); } - return response({}); + return response({ active: true, version: "v2" }); }, }); @@ -4228,38 +4404,100 @@ test("serializeDeployManifest enforces the control request body cap", () => { ); }); -test("runDeployCommand explains a failed promote after upload", async () => { - const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-promote-fail-")); - 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"\nmain = "src/index.js"\n'); +for (const { label, promote, rejected, expected } of [ + { + label: "a 4xx rejection", + promote: () => response({ error: "version_not_found" }, 404), + rejected: true, + expected: /promote failed: 404 version_not_found/, + }, + { + label: "an acknowledgement without active", + promote: () => response({ version: "v9", platformDomain: "workers.example", urls: {} }, 200), + rejected: false, + expected: /promote failed: response did not confirm v9 is active/, + }, + { + label: "an acknowledgement for a different version", + promote: () => response({ active: true, version: "v8", platformDomain: "workers.example", urls: {} }, 200), + rejected: false, + expected: /promote failed: response did not confirm v9 is active/, + }, + { + label: "a 3xx redirect", + promote: () => response("", 302), + rejected: false, + expected: /promote failed: 302/, + }, + { + label: "a 2xx body that is not an object", + promote: () => response(null, 200), + rejected: false, + expected: /promote failed: response did not confirm v9 is active/, + }, + { + label: "a 5xx response", + promote: () => response({ error: "promote_failed", message: "routing unavailable" }, 503), + rejected: false, + expected: /promote failed: 503 promote_failed: routing unavailable/, + }, + { + label: "a transport failure", + promote: () => { + throw Object.assign(new Error("socket hang up"), { code: "ECONNRESET" }); + }, + rejected: false, + expected: /socket hang up/, + }, + { + label: "an unreadable 2xx body", + promote: () => response("truncated", 200), + rejected: false, + expected: /promote failed: response is not valid JSON/, + }, +]) { + test(`runDeployCommand reports the promotion outcome after ${label}`, async () => { + const dir = mkdtempSync(path.join(tmpdir(), "wdl-run-deploy-promote-outcome-")); + 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"\nmain = "src/index.js"\n'); - /** @type {string[]} */ - const stderrLines = []; - let fetchCount = 0; - await assert.rejects( - () => - runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { - env: { ADMIN_TOKEN: "tok" }, - stdout: () => {}, - stderr: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), - execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetchCount += 1; - if (fetchCount === 1) return response({ version: "v9", warnings: [] }); - return response({ error: "promote_failed", message: "routing unavailable" }, 503); - }, - }), - /promote failed: 503 promote_failed: routing unavailable/ - ); + /** @type {string[]} */ + const stderrLines = []; + let fetchCount = 0; + await assert.rejects( + () => + runDeployCommand([dir, "--ns", "demo", "--control-url", "http://ctl.test"], { + env: { ADMIN_TOKEN: "tok" }, + stdout: () => {}, + stderr: (/** @type {string} */ line) => stderrLines.push(line), + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + if (fetchCount === 1) return response({ version: "v9", warnings: [] }); + return promote(); + }, + }), + expected + ); - assert.equal(fetchCount, 2); - assert.ok(stderrLines.some((line) => /version v9 was uploaded and retained but NOT promoted/.test(line))); - } finally { - rmSync(dir, { recursive: true, force: true }); - } -}); + assert.equal(fetchCount, 2); + const note = stderrLines.join(""); + // The note states the outcome and nothing else: no command, no name the + // CLI assembled, no advice. + assert.equal( + note, + rejected + ? "note: control rejected the promotion, so this version was not activated and traffic is unchanged; " + + "it may still be retained." + : "note: the promotion outcome is unknown; control may have promoted this version already." + ); + } finally { + rmSync(dir, { recursive: true, force: true }); + } + }); +} const RESTART_SESSION_POLICY_TOML = [ 'name = "api"', @@ -4409,7 +4647,11 @@ test("runDeployCommand does not promote when control omits the restart session p return response({ version: "v3", warnings: [] }); }, }), - /control did not confirm session_policy = restart.*NOT promoted/ + (/** @type {Error} */ err) => { + assert.match(err.message, /control did not confirm session_policy = restart, so nothing was promoted/); + assert.match(err.message, /the uploaded version was retained/); + return true; + } ); assert.equal(fetchCalls.length, 1); assert.match(fetchCalls[0].url, /\/deploy$/); @@ -4524,7 +4766,7 @@ new_classes = ["Room"] }, controlFetch: async () => { fetchCount += 1; - return fetchCount === 1 ? response({ version: "v1" }) : response({}); + return fetchCount === 1 ? response({ version: "v1" }) : response({ active: true, version: "v1" }); }, }); @@ -4697,7 +4939,7 @@ test("runDeployCommand escapes a control-supplied version before printing", asyn controlFetch: async () => { fetchCount += 1; if (fetchCount === 1) return response({ version: "v1\u001b[2J", warnings: [] }); - return response({ platformDomain: "wdl.sh" }); + return response({ active: true, version: "v1\u001b[2J", platformDomain: "wdl.sh" }); }, }); diff --git a/tests/unit/helpers.js b/tests/unit/helpers.js index 0de28f3..48ebb29 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -100,13 +100,20 @@ export function response(body, status = 200) { } // A deploy issues at most two control calls: deploy, then promote if the CLI -// accepts the deploy response. Record them and answer the first call with the -// deploy body and any later call with the promote body. +// accepts the deploy response. Record both, answer them in that order, and +// reject anything further. +// Control acknowledges a promotion with the version it activated, so that shape +// is the default; a case that needs a different one overrides it. /** * @param {unknown} deployBody * @param {unknown} promoteBody */ export function deployPromoteFetch(deployBody, promoteBody) { + const acknowledged = { + active: true, + version: /** @type {{ version?: unknown }} */ (deployBody)?.version, + .../** @type {Record} */ (promoteBody ?? {}), + }; /** @type {ControlCall[]} */ const calls = []; return { @@ -114,7 +121,15 @@ export function deployPromoteFetch(deployBody, promoteBody) { /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} [init] */ controlFetch: async (url, init = {}) => { calls.push({ url, init }); - return response(calls.length === 1 ? deployBody : promoteBody); + if (calls.length === 1) { + assert.match(url, /\/deploy$/); + return response(deployBody, 201); + } + if (calls.length === 2) { + assert.match(url, /\/promote$/); + return response(acknowledged); + } + throw new Error(`unexpected control call #${calls.length}: ${url}`); }, }; } From e76c45c08b95e41a9fe0dcca7ba556c93b3294ad Mon Sep 17 00:00:00 2001 From: Lu Zhang Date: Thu, 6 Aug 2026 23:18:42 +0800 Subject: [PATCH 5/5] Match the [wdl] parser to its sibling config checks Report every unknown key in [wdl] at once and render them the way the binding parsers do, and narrow the promote restart sequence once instead of re-coercing it for the type checker. Co-Authored-By: Claude Fable 5 Signed-off-by: Lu Zhang --- commands/deploy.js | 6 ++++-- lib/wrangler/config.js | 8 ++++---- tests/unit/cli-deploy.test.js | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/commands/deploy.js b/commands/deploy.js index 29b8bec..3b4b2c5 100644 --- a/commands/deploy.js +++ b/commands/deploy.js @@ -165,11 +165,13 @@ export async function postArtifactToControl({ context, ns, workerName, manifest, "the platform-domain URL may still be active." ); } + const restartSequence = promoteBody.restartSequence; if ( sessionPolicyRestartRequested && (promoteBody.sessionPolicy !== "restart" || - !Number.isSafeInteger(promoteBody.restartSequence) || - Number(promoteBody.restartSequence) <= 0) + typeof restartSequence !== "number" || + !Number.isSafeInteger(restartSequence) || + restartSequence <= 0) ) { throw new CliError( "control promoted the worker without confirming its restart session policy; " + diff --git a/lib/wrangler/config.js b/lib/wrangler/config.js index df58c4a..49413ce 100644 --- a/lib/wrangler/config.js +++ b/lib/wrangler/config.js @@ -228,10 +228,10 @@ export function parseSessionPolicy(cfg, configRel = "wrangler config") { const shownConfig = escapeTerminalText(configRel); const wdl = asRecord(cfg.wdl); if (!wdl) throw new Error(`${shownConfig}: [wdl] must be a table`); - for (const key of Object.keys(wdl)) { - if (key !== "session_policy") { - throw new Error(`${shownConfig}: [wdl] contains unknown field(s): ${formatDiagnosticValue(key)}`); - } + const unknownKeys = Object.keys(wdl).filter((key) => key !== "session_policy"); + if (unknownKeys.length > 0) { + const shownKeys = unknownKeys.map((key) => escapeTerminalText(key)).join(", "); + throw new Error(`${shownConfig}: [wdl] contains unknown field(s): ${shownKeys}`); } // Only an absent field means preserve; an explicit null is a typo, not a default. const mode = wdl.session_policy === undefined ? "preserve" : wdl.session_policy; diff --git a/tests/unit/cli-deploy.test.js b/tests/unit/cli-deploy.test.js index 4e31d75..5c12520 100644 --- a/tests/unit/cli-deploy.test.js +++ b/tests/unit/cli-deploy.test.js @@ -1619,8 +1619,8 @@ test("parseSessionPolicy validates the [wdl] session policy", () => { assert.throws(() => parseSessionPolicy({ wdl: [] }), /\[wdl\] must be a table/); assert.throws(() => parseSessionPolicy({ wdl: { session_policy: "replace" } }), /must be "preserve" or "restart"/); assert.throws( - () => parseSessionPolicy({ wdl: { session_policy: "restart", typo: true } }), - /\[wdl\] contains unknown field\(s\): "typo"/ + () => parseSessionPolicy({ wdl: { session_policy: "restart", typo: true, other: 1 } }), + /\[wdl\] contains unknown field\(s\): typo, other/ ); // An explicit null is rejected at the field and at the table boundary. assert.throws(() => parseSessionPolicy({ wdl: { session_policy: null } }), /must be "preserve" or "restart"/);