Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-relative-container-fetch.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@cloudflare/containers': patch
---

Support relative URL paths in `containerFetch`.
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,12 @@ export class FetchStyleContainer extends Container {
}
```

Relative paths are resolved against an internal origin (`http://container`), so the container
receives `Host: container` for these requests. Routing is unaffected — the request is always
delivered to the selected port — but if your application validates the `Host` header (for example
Django's `ALLOWED_HOSTS` or Vite's `allowedHosts`), either allow `container` or pass an absolute
URL instead.

### Managing Container Idle Timeout

The Container class includes an automatic idle timeout feature that will shut down the container after a period of inactivity. This helps save resources when containers are not in use.
Expand Down
3 changes: 2 additions & 1 deletion src/lib/container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ const UNEXPECTED_EXIT_ERROR = 'container exited with unexpected exit code:';
const NOT_LISTENING_ERROR = 'the container is not listening';
const CONTAINER_STATE_KEY = '__CF_CONTAINER_STATE';
const OUTBOUND_CONFIGURATION_KEY = 'OUTBOUND_CONFIGURATION';
const CONTAINER_REQUEST_BASE_URL = 'http://container';

// maxRetries before scheduling next alarm is purposely set to 3,
// as according to DO docs at https://developers.cloudflare.com/durable-objects/api/alarms/
Expand Down Expand Up @@ -1662,7 +1663,7 @@ export class Container<Env = Cloudflare.Env> extends DurableObject<Env> {
port = typeof portOrInit === 'number' ? portOrInit : undefined;
} else {
// URL-based: containerFetch(url, init?, port?)
const url = typeof requestOrUrl === 'string' ? requestOrUrl : requestOrUrl.toString();
const url = new URL(requestOrUrl, CONTAINER_REQUEST_BASE_URL);
const init = typeof portOrInit === 'number' ? {} : portOrInit || {};
port =
typeof portOrInit === 'number'
Expand Down
48 changes: 48 additions & 0 deletions src/tests/container.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,54 @@ describe('Container', () => {
expect(tcpPort.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object));
});

test('containerFetch should accept a relative URL', async ({ mockCtx, container }) => {
mockCtx.container.running = true;
mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() });

await container.containerFetch('/api/data');

const tcpPort = mockCtx.container.getTcpPort.mock.results[0].value;
expect(tcpPort.fetch).toHaveBeenCalledWith('http://container/api/data', expect.any(Request));
});

test('containerFetch should accept a relative URL with init and an explicit port', async ({
mockCtx,
container,
}) => {
mockCtx.container.running = true;
mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() });

await container.containerFetch(
'/api/data?query=value',
{ method: 'POST', body: JSON.stringify({ query: 'example' }) },
9090
);

expect(mockCtx.container.getTcpPort).toHaveBeenCalledWith(9090);

const tcpPort = mockCtx.container.getTcpPort.mock.results[0].value;
expect(tcpPort.fetch).toHaveBeenCalledWith(
'http://container/api/data?query=value',
expect.any(Request)
);

const forwarded = tcpPort.fetch.mock.calls[0][1] as Request;
expect(forwarded.method).toBe('POST');
await expect(forwarded.text()).resolves.toBe(JSON.stringify({ query: 'example' }));
});

test('containerFetch should leave absolute URLs untouched', async ({ mockCtx, container }) => {
mockCtx.container.running = true;
mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() });

await container.containerFetch(new URL('https://example.com/admin'), { method: 'GET' }, 3000);

expect(mockCtx.container.getTcpPort).toHaveBeenCalledWith(3000);

const tcpPort = mockCtx.container.getTcpPort.mock.results[0].value;
expect(tcpPort.fetch).toHaveBeenCalledWith('http://example.com/admin', expect.any(Request));
});

test('containerFetch should return 429 when startup is rate limited', async ({ container }) => {
const mockRequest = new Request('https://example.com/test', { method: 'GET' });
using startSpy = vi
Expand Down
Loading