Repository navigation
static outbound = ... (the documented form) silently fails to register under ES2022+ class-field semantics #247
Description
Activity
- changed the title
[-]`static outbound = …` (the documented form) silently fails to register under ES2022+ class-field semantics[/-][+]`static outbound = ...` (the documented form) silently fails to register under ES2022+ class-field semantics[/+]on Aug 21, 2026 Two additions after digging further.
1. All three static accessors are affected, not just
outbound.outbound,outboundByHostandoutboundHandlersare all declared as static accessor pairs
(dist/lib/container.d.ts:50-55), and all three fail identically as class fields — none of
them writes its registry:class-field form -> outbound: false outboundHandlers: false outboundByHost: false assignment form -> outbound: true outboundHandlers: true outboundByHost: trueThe README documents all three in the class-field form: lines 311, 315, 319 in the reference
list, and 479, 485, 491 in the TypeScript example.Instance properties (
allowedHosts,deniedHosts,interceptHttps,enableInternet,
defaultPort) are plain properties, not accessors, so they are unaffected and correct as
class fields. The problem is confined to the threestaticones.2. The repo already documents the correct form — the two docs contradict each other.
docs/egress.md, linked from the README, uses the assignment form in all six of its examples
(lines 150, 165, 177, 310, 316, 322):MyContainer.outboundByHost = { ... }; MyContainer.outbound = (req, env, ctx) => { ... }; MyContainer.outboundHandlers = { ... };
So the fix for the documentation half is just to bring
README.mdin line with
docs/egress.md— no new convention required. Happy to open that PR if useful.- added a commit that references this issue
on Aug 21, 2026 - added a commit that references this issue
on Sep 16, 2026 Same defect applies to
static outboundByHost(also a setter-fed registry,container.d.ts:50-51, documented as a class field at README ~315 and ~479), and there it fails worse than 520 whenenableInternet = true:- the constructor reads the own property (
ctor.outboundByHost !== undefined), sousingInterceptionis true and the host is intercepted; ContainerProxyreadsoutboundByHostRegistry, finds nothing, and in per-host mode falls through toif (enableInternet) return fetch(request);- so requests meant for a virtual host (
state.internal→ R2 in our case) are forwarded to the public internet — a silent leak of request bodies towards whatever that hostname resolves to, not a closed failure.
In
wrangler tailthe proxy invocations still show asOk, which made this look like a platform data-path problem for a while: our container's state restore failed, the entrypoint exited, and every R2 upload vanished. Confirmed in production on 0.3.7 (wrangler 4.143,target: es2022); assigning after the class (MyContainer.outboundByHost = {...}) fixed it.Suggested fix covers both: have
ContainerProxyresolve handlers from the class's own static properties (or snapshot them in the constructor) instead of a setter-only registry.- the constructor reads the own property (
Thanks for this report — and for the production confirmation. Your
outboundByHostanalysis follows from exactly the registry shape documented upthread, and it changes the severity of this issue in a way worth consolidating for the maintainers.One root cause, two failure modes:
Accessor form What happens on registry miss Failure mode static outbound = …(class field)ContainerProxyfinds no handler → egress refusedFail-closed: every outbound request 520s ( Origin is disallowed)static outboundByHost = …(class field, withenableInternet = true)constructor reads the own property → interception armed → proxy falls through to fetch(request)Fail-open: requests meant for a virtual host are forwarded to the public internet — silent leak of request bodies; wrangler tailshowsOkBoth trace to the same design: the static setters are the sole writers of the handler registries, and
[[DefineOwnProperty]]under ES2022+ class-field semantics shadows them without error. Your case shows the second mode is not hypothetical — internal R2 traffic silently left the runtime boundary in production on 0.3.7 (wrangler 4.143,target: es2022).Two independent production codebases have now hit this. The fail-open variant deserves maintainer attention in particular: it converts a documentation-following configuration into a data-exposure path with no diagnostic anywhere.
On fixes: agreed that resolving handlers from the class's own static properties (or snapshotting them in the constructor) covers both accessors at once — that's option 3 in the original report, and your case argues for it over a docs-only fix. As an interim, option 2 (warn/throw at container start when the constructor has an own
outbound/outboundByHost/outboundHandlersproperty but no registry entry under its name) would have turned both incidents into a one-line diagnosis. PR #248 aligns the README examples with egress docs; I'll extend it across all three static accessors and add a caution note.- added a commit that references this issue
on Oct 5, 2026
Summary
Container.outboundis implemented as a static accessor pair, and its setter is the onlything that writes the outbound handler registry. The README documents assigning it as a
static class field. Under
useDefineForClassFieldssemantics — the default fortarget: ES2022or higher — a class field is installed with[[DefineOwnProperty]], whichcreates an own property that shadows the inherited setter instead of invoking it.
The setter never runs, the registry is never written,
ContainerProxyfinds no handler, andevery outbound request from the container fails closed with
520 Origin is disallowed.There is no error, no warning, and no type error. The class looks correctly configured and
MyContainer.outboundeven reads back the function you assigned — it is simply the ownproperty you just defined, not a registration.
Environment
@cloudflare/containers@0.3.7(latest published at time of writing)target: ES2022or higher (or any bundler emitting native class fields)Why this is a library bug and not user error
The README steers users directly into the failing form:
- static outbound = (req, env, ctx) => ResponseMeanwhile
dist/lib/container.d.ts:54-55declares:A user following the documented example with a modern
tsconfiggets a silentlynon-functional handler. The same source compiled at
target: ES2021works, so this alsobreaks on a routine
targetbump with no code change.Reproduction
The emit difference is the whole bug:
targetES2021C.outbound = () => {};after the class[[Set]]SETTER RANES2022+static outbound = () => {};inside the class[[DefineOwnProperty]]Against the real registry shape (
dist/lib/container.js:37-41,281-296,1188):Observed:
Full runnable reproduction (zero dependencies —
node repro.mjs)Suggested fixes
Any one of these would close it; the first two are cheap:
MyContainer.outbound = handler;as a statement after the classdeclaration, and note that the class-field form does not register under ES2022+.
outboundproperty and no registry entry exists for its name, throw or
console.warnwith the fix.This turns a silent 520 into a one-line diagnosis.
ctor.outboundwhen the registry misses, so both forms work.Related, lower severity: subclassing silently changes the registry key
Both the write side (
outboundHandlersRegistry.set(this.name, …)) and the read side(
className: this.constructor.name,container.js:1188) key on the class name. That meansrenaming a container class by subclassing it:
silently loses interception, whereas re-exporting under an alias (
export { Base as Sub })preserves it because
constructor.nameis unchanged.This may be working as intended, but the name-keying is not documented, and the failure mode
is identical to the one above: fail-closed 520s with no diagnostic. A sentence in the
outbound-interception docs would prevent it. This matters specifically because Cloudflare's
own recommended Durable Object rename procedure involves exporting a class under a second
name — which is safe as an alias and unsafe as a subclass, and nothing says so.