diff --git a/.claude/skills/wdl-deploy/SKILL.md b/.claude/skills/wdl-deploy/SKILL.md index 43edcff..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 @@ -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..c2eddf6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,37 @@ ## Unreleased +### Added + +- `[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 3be0f1a..cbd853f 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,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 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 ` | @@ -858,14 +871,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..366af03 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,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 | -| 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 @@ -1208,25 +1234,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..3b4b2c5 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,21 @@ 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 + */ + // 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. @@ -67,14 +83,13 @@ 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; - 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, @@ -82,13 +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, - } = /** @type {{ version: unknown, warnings?: DeployWarning[], workersDev?: 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"), @@ -104,34 +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, so nothing was promoted; the uploaded version was retained." ); } writeStatusLine(stdout, `[3/3] promoting ${version}`); - /** @type {{ platformDomain?: unknown, workersDev?: unknown, urls?: unknown }} */ + /** @type {PromoteResponseBody} */ let promoteBody; + let promoteRejected = false; try { - promoteBody = /** @type {{ platformDomain?: unknown, workersDev?: unknown, urls?: unknown }} */ ( - 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) { @@ -140,6 +165,19 @@ 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" || + typeof restartSequence !== "number" || + !Number.isSafeInteger(restartSequence) || + restartSequence <= 0) + ) { + throw new CliError( + "control promoted the worker without confirming its restart session policy; " + + "the new version is live, but existing sessions may not have restarted." + ); + } return { version, platformDomain: promoteBody.platformDomain, @@ -154,7 +192,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); @@ -271,7 +309,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; @@ -287,7 +325,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/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..75e34b4 100644 --- a/docs/deploy-zh.md +++ b/docs/deploy-zh.md @@ -84,10 +84,25 @@ 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.]`。env 自己的 `[wdl]` 会整表覆盖顶层那张表。`preserve` 下,已打开的 WebSocket 会在 backend 仍健康时继续在原 version 上 drain,已加载的 Durable Object facet 也留在构造它的 version 上。要选择相反的行为: + +```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,因此也不会重启会话。 + +`restart` 部署会被校验两次:deploy 响应没有回显该策略,说明 control 版本早于它,此时 version 保持 retained 且不会 promote;promotion 本身没有确认该策略时,version 已经生效而会话可能没有重启,CLI 会失败而不是让它悄悄过去。 + ## 环境覆盖 当 wrangler 配置有 `[env.]` 段时,`--env `(或 `CLOUDFLARE_ENV`)是**必填**的 —— CLI 不会替你挑默认值。明确指定: @@ -107,9 +122,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 @@ -144,7 +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 失败 | 自定义主机或服务绑定的目标校验问题;检查绑定目标。 | +| `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 d3aa6d2..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 @@ -163,6 +174,33 @@ 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`; 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] +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. + +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 When the wrangler config has `[env.]` sections, `--env ` (or @@ -197,13 +235,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 +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. | -| 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 d8b8b66..5c9555c 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。deploy 失败会留下什么、以及如何恢复,见 [deploy-zh.md](./deploy-zh.md#常见错误)。 + ## Worker 代码 ```js diff --git a/docs/durable-objects.md b/docs/durable-objects.md index a070a66..1e60ccc 100644 --- a/docs/durable-objects.md +++ b/docs/durable-objects.md @@ -22,6 +22,42 @@ 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. What a failed deploy +leaves behind, and how to recover from it, is in +[deploy.md](./deploy.md#common-errors). + ## 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..39f2206 100644 --- a/lib/output.js +++ b/lib/output.js @@ -53,6 +53,14 @@ 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, + // 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}`); + } return escapeTerminalText(JSON.stringify(value)); } diff --git a/lib/wrangler-pack.js b/lib/wrangler-pack.js index f10303c..4a002da 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] @@ -96,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(); @@ -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..49413ce 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`); + 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; + 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..6394d9a 100644 --- a/lib/wrangler/utils.js +++ b/lib/wrangler/utils.js @@ -17,12 +17,15 @@ 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} */ 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/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" }, 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..5c12520 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\] contains unknown field\(s\)/, + "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,136 @@ 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, 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"/); + 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("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/); + 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 +2584,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 +2613,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 +2722,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 +2745,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 +2866,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 +2918,144 @@ 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 { + 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 { @@ -2855,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: { @@ -2903,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: { @@ -2958,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: { @@ -3000,26 +3269,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)); @@ -3066,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$/); @@ -3085,30 +3352,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/ ); @@ -3145,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" }); }, }); @@ -3185,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" }); }, }); @@ -3398,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/ @@ -3436,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/ @@ -3462,21 +3723,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)); @@ -3512,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/ @@ -3637,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); @@ -3747,7 +4003,7 @@ test("runDeployCommand warns with wdl secret hints for missing caller secrets", ], }); } - return response({}); + return response({ active: true, version: "v2" }); }, }); @@ -4129,7 +4385,7 @@ test("runDeployCommand projects unknown deploy warnings before printing", async ], }); } - return response({}); + return response({ active: true, version: "v2" }); }, }); @@ -4148,34 +4404,325 @@ 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-")); +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(line), + execFile: fakeWranglerExecFile, + controlFetch: async () => { + fetchCount += 1; + if (fetchCount === 1) return response({ version: "v9", warnings: [] }); + return promote(); + }, + }), + expected + ); + + 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"', + '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"), 'name = "api"\nmain = "src/index.js"\n'); + writeFileSync(path.join(dir, "wrangler.toml"), RESTART_SESSION_POLICY_TOML); /** @type {string[]} */ - const stderrLines = []; - let fetchCount = 0; + 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", "http://ctl.test"], { + 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: [] }); + }, + }), + (/** @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$/); + } 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: (/** @type {string} */ line) => stderrLines.push(/** @type {string} */ line), + stderr: () => {}, execFile: fakeWranglerExecFile, - controlFetch: async () => { - fetchCount += 1; - if (fetchCount === 1) return response({ version: "v9", warnings: [] }); - return response({ error: "promote_failed", message: "routing unavailable" }, 503); - }, + controlFetch, }), - /promote failed: 503 promote_failed: routing unavailable/ + /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, + }); - assert.equal(fetchCount, 2); - assert.ok(stderrLines.some((line) => /version v9 was uploaded and retained but NOT promoted/.test(line))); + 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 }); } @@ -4219,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" }); }, }); @@ -4318,21 +4865,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 +4899,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")); @@ -4402,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/cli-output.test.js b/tests/unit/cli-output.test.js index e31a5c3..a51e7c8 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,22 @@ 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.equal(formatDiagnosticValue(new Date(Number.NaN)), "datetime invalid"); + 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..48ebb29 100644 --- a/tests/unit/helpers.js +++ b/tests/unit/helpers.js @@ -99,6 +99,41 @@ 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 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 { + calls, + /** @param {string} url @param {import("../../lib/control-fetch.js").ControlFetchInit} [init] */ + controlFetch: async (url, init = {}) => { + calls.push({ url, init }); + 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}`); + }, + }; +} + // 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.