Skip to content

static outbound = ... (the documented form) silently fails to register under ES2022+ class-field semantics #247

Description

@iMagdy

Summary

Container.outbound is implemented as a static accessor pair, and its setter is the only
thing that writes the outbound handler registry. The README documents assigning it as a
static class field. Under useDefineForClassFields semantics — the default for
target: ES2022 or higher — a class field is installed with [[DefineOwnProperty]], which
creates an own property that shadows the inherited setter instead of invoking it.

The setter never runs, the registry is never written, ContainerProxy finds no handler, and
every 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.outbound even reads back the function you assigned — it is simply the own
property you just defined, not a registration.

Environment

  • @cloudflare/containers@0.3.7 (latest published at time of writing)
  • TypeScript with target: ES2022 or 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:

  • README line 311, under "To configure interception on the class itself":
    - static outbound = (req, env, ctx) => Response
  • README line 491, in the full TypeScript example:
    static outbound = (req: Request) => {
      return new Response(`Hi ${req.url}, I can't handle you`);
    };

Meanwhile dist/lib/container.d.ts:54-55 declares:

static get outbound(): OutboundHandler | undefined;
static set outbound(handler: OutboundHandler);

A user following the documented example with a modern tsconfig gets a silently
non-functional handler. The same source compiled at target: ES2021 works, so this also
breaks on a routine target bump with no code change.

Reproduction

The emit difference is the whole bug:

class B { static set outbound(h: unknown) { console.log("SETTER RAN"); } }
class C extends B { static outbound = () => {}; }
target emitted semantics setter runs
ES2021 C.outbound = () => {}; after the class [[Set]] yes — prints SETTER RAN
ES2022+ static outbound = () => {}; inside the class [[DefineOwnProperty]] no output

Against the real registry shape (dist/lib/container.js:37-41, 281-296, 1188):

const outboundHandlersRegistry = new Map();
const defaultOutboundHandlerNameRegistry = new Map();

class Container {
  static set outbound(handler) {                 // sole writer of the registry
    const key = '__outbound__';
    const existing = outboundHandlersRegistry.get(this.name) ?? {};
    outboundHandlersRegistry.set(this.name, { ...existing, [key]: handler });
    defaultOutboundHandlerNameRegistry.set(this.name, key);
  }
}
// ContainerProxy resolves by the className stamped from the instance (container.js:1188)
const resolve = (instance) => {
  const className = instance.constructor.name;
  const n = defaultOutboundHandlerNameRegistry.get(className);
  return n ? outboundHandlersRegistry.get(className)?.[n] : undefined;
};

const handler = () => new Response('intercepted');

class FieldForm extends Container { static outbound = handler; }   // README form
class AssignForm extends Container {}
AssignForm.outbound = handler;                                     // assignment form

Observed:

FieldForm   registry written? false   own shadowing prop? true    proxy resolves? false  -> 520
AssignForm  registry written? true                                proxy resolves? true   -> works
Full runnable reproduction (zero dependencies — node repro.mjs)
// Clean-room reproduction of two @cloudflare/containers@0.3.7 registry defects.
// Mirrors the library's exact accessor + registry shape (dist/lib/container.js:37-41, 281-296).
const outboundHandlersRegistry = new Map();
const defaultOutboundHandlerNameRegistry = new Map();

class Container {                                    // mirrors the real base class
  static get outbound() {
    const n = defaultOutboundHandlerNameRegistry.get(this.name);
    return n ? outboundHandlersRegistry.get(this.name)?.[n] : undefined;
  }
  static set outbound(handler) {                     // SOLE writer of the registry
    const key = '__outbound__';
    const existing = outboundHandlersRegistry.get(this.name) ?? {};
    outboundHandlersRegistry.set(this.name, { ...existing, [key]: handler });
    defaultOutboundHandlerNameRegistry.set(this.name, key);
  }
}
// ContainerProxy resolves by the className stamped from the INSTANCE (real: :1188)
const resolve = (instance) => {
  const className = instance.constructor.name;
  const n = defaultOutboundHandlerNameRegistry.get(className);
  return n ? outboundHandlersRegistry.get(className)?.[n] : undefined;
};
const handler = () => new Response('intercepted');

console.log('DEFECT 1 — static class FIELD shadows the inherited setter\n');
class FieldForm extends Container {
  static outbound = handler;                         // [[DefineOwnProperty]] under ES2022+
}
console.log('  registry written? ', outboundHandlersRegistry.has('FieldForm'));
console.log('  own prop (shadow)? ', Object.getOwnPropertyDescriptor(FieldForm, 'outbound')?.value === handler);
console.log('  proxy resolves?   ', resolve(new FieldForm()) !== undefined, ' <-- egress refused (520)');

class AssignForm extends Container {}
AssignForm.outbound = handler;                       // [[Set]] -> invokes inherited setter
console.log('\n  assignment form registry written?', outboundHandlersRegistry.has('AssignForm'));
console.log('  proxy resolves?   ', resolve(new AssignForm()) !== undefined, ' <-- works');

console.log('\nDEFECT 2 — subclassing changes the registry key; aliasing does not\n');
class Base extends Container {}
Base.outbound = handler;
const Alias = Base;                                  // export { Base as Alias }
class Sub extends Base {}                            // rename via subclass
console.log('  registered under:   ', [...defaultOutboundHandlerNameRegistry.keys()].join(', '));
console.log('  alias resolves?     ', resolve(new Alias()) !== undefined, ' <-- constructor.name still "Base"');
console.log('  subclass resolves?  ', resolve(new Sub()) !== undefined, ' <-- constructor.name is "Sub": MISS -> 520');

Suggested fixes

Any one of these would close it; the first two are cheap:

  1. Fix the README — show MyContainer.outbound = handler; as a statement after the class
    declaration, and note that the class-field form does not register under ES2022+.
  2. Detect and warn — on container start, if the constructor has an own outbound
    property and no registry entry exists for its name, throw or console.warn with the fix.
    This turns a silent 520 into a one-line diagnosis.
  3. Accept the own property — have the resolution path fall back to reading
    ctor.outbound when 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 means
renaming a container class by subclassing it:

class Base extends Container {}
Base.outbound = handler;          // registers under "Base"
class Sub extends Base {}         // instances stamp className "Sub" -> registry miss -> 520

silently loses interception, whereas re-exporting under an alias (export { Base as Sub })
preserves it because constructor.name is 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.

Activity

  1. 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
  2. iMagdy commented on Aug 21, 2026

    @iMagdy
    Author

    Two additions after digging further.

    1. All three static accessors are affected, not just outbound.

    outbound, outboundByHost and outboundHandlers are 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: true
    

    The 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 three static ones.

    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.md in line with
    docs/egress.md — no new convention required. Happy to open that PR if useful.

  3. JaneHanZhen commented on Oct 5, 2026

    @JaneHanZhen

    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 when enableInternet = true:

    • the constructor reads the own property (ctor.outboundByHost !== undefined), so usingInterception is true and the host is intercepted;
    • ContainerProxy reads outboundByHostRegistry, finds nothing, and in per-host mode falls through to if (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 tail the proxy invocations still show as Ok, 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 ContainerProxy resolve handlers from the class's own static properties (or snapshot them in the constructor) instead of a setter-only registry.

  4. added a commit that references this issue on Oct 5, 2026
  5. iMagdy commented on Oct 5, 2026

    @iMagdy
    Author

    Thanks for this report — and for the production confirmation. Your outboundByHost analysis 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) ContainerProxy finds no handler → egress refused Fail-closed: every outbound request 520s (Origin is disallowed)
    static outboundByHost = … (class field, with enableInternet = 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 tail shows Ok

    Both 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/outboundHandlers property 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions