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/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. 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..c2da5ff 100644 --- a/src/tests/container.test.ts +++ b/src/tests/container.test.ts @@ -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