From 41206f96093daea9faf7d44e5d3dcfb1be9bcca7 Mon Sep 17 00:00:00 2001 From: "murilo.moura" Date: Tue, 11 Aug 2026 01:53:27 -0300 Subject: [PATCH 1/3] fix: support relative container fetch URLs --- .changeset/fix-relative-container-fetch.md | 5 +++++ src/lib/container.ts | 3 ++- src/tests/container.test.ts | 15 +++++++++++++++ 3 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 .changeset/fix-relative-container-fetch.md diff --git a/.changeset/fix-relative-container-fetch.md b/.changeset/fix-relative-container-fetch.md new file mode 100644 index 0000000..b8b6253 --- /dev/null +++ b/.changeset/fix-relative-container-fetch.md @@ -0,0 +1,5 @@ +--- +'@cloudflare/containers': patch +--- + +Support relative URL paths in `containerFetch`. diff --git a/src/lib/container.ts b/src/lib/container.ts index 44c2f0d..67c8677 100644 --- a/src/lib/container.ts +++ b/src/lib/container.ts @@ -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/ @@ -1662,7 +1663,7 @@ export class Container extends DurableObject { 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' diff --git a/src/tests/container.test.ts b/src/tests/container.test.ts index 141017d..ea02f1e 100644 --- a/src/tests/container.test.ts +++ b/src/tests/container.test.ts @@ -312,6 +312,21 @@ describe('Container', () => { expect(tcpPort.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object)); }); + describe('relative URL requests', () => { + test('accepts a relative URL documented by the public API', 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.objectContaining({ url: 'http://container/api/data' }) + ); + }); + }); + 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 From c1a2d92f7e7885c8a5482549ada74d94d29bf415 Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Fri, 21 Aug 2026 17:28:39 -0700 Subject: [PATCH 2/3] expand containerFetch relative URL test coverage Flatten the single-test describe block into the file's existing naming convention and drop the objectContaining assertion that duplicated the exact-URL check on the first argument. Add coverage for a relative URL combined with init and an explicit port, and a guard asserting absolute URLs are still passed through unchanged, which is the behavior most at risk from resolving against a base origin. --- src/tests/container.test.ts | 55 +++++++++++++++++++++++++++++-------- 1 file changed, 44 insertions(+), 11 deletions(-) diff --git a/src/tests/container.test.ts b/src/tests/container.test.ts index ea02f1e..c2da5ff 100644 --- a/src/tests/container.test.ts +++ b/src/tests/container.test.ts @@ -312,19 +312,52 @@ describe('Container', () => { expect(tcpPort.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object)); }); - describe('relative URL requests', () => { - test('accepts a relative URL documented by the public API', async ({ mockCtx, container }) => { - mockCtx.container.running = true; - mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() }); + 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'); + await container.containerFetch('/api/data'); - const tcpPort = mockCtx.container.getTcpPort.mock.results[0].value; - expect(tcpPort.fetch).toHaveBeenCalledWith( - 'http://container/api/data', - expect.objectContaining({ url: 'http://container/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 }) => { From 679b95ae4a0b20d0b59ecaa05b1bbf3a477e24a8 Mon Sep 17 00:00:00 2001 From: Matt Johnson-Pint Date: Fri, 21 Aug 2026 17:28:43 -0700 Subject: [PATCH 3/3] document the Host header for relative containerFetch paths Relative paths resolve against http://container, so the container sees Host: container. Routing is unaffected, but applications that validate the Host header need to allow it or use an absolute URL. --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index c220388..75493b9 100644 --- a/README.md +++ b/README.md @@ -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.