cloudflare / cloudflare/containers
`static outbound = ...` (the documented form) silently fails to register under ES2022+ class-field semantics
- Dominant language
- TypeScript
- Stars
- 270
- Forks
- 42
- Avg merge
- 1d 5h
- Merged PRs (30d)
- 4
Description
## 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:
```ts
static outbound = (req: Request) => {
return new Response(`Hi ${req.url}, I can't handle you`);
};
```
Meanwhile `dist/lib/container.d.ts:54-55` declares:
```ts
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:
```ts
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`):
```js
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)
```js
// 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:
```js
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.
Contributor guide
Research direction
Start with the README examples at lines 311 and 491, then inspect dist/lib/container.js around lines 37-41, 281-296, and 1188 plus dist/lib/container.d.ts lines 54-55. Run the zero-dependency node repro.mjs to confirm both registry misses. With maintainers, determine whether the fix is documentation, diagnostics, or resolution fallback, and include coverage for the chosen behavior and the subclassing case.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend, cloud
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 48/100