From 34c61b5a8468ad1b8d6225f3a2e82a3c33faa08a Mon Sep 17 00:00:00 2001 From: Naresh Date: Mon, 18 May 2026 12:50:20 +0100 Subject: [PATCH] Add non-waking container fetch Expose fetchIfRunning so callers can proxy to an already healthy container without using wake-capable fetch paths. This lets consumers serve passive traffic without accidentally starting stopped containers. --- .changeset/non-waking-container-forward.md | 5 ++ src/lib/container.ts | 22 ++++++++ src/tests/container.test.ts | 65 ++++++++++++++++++++++ 3 files changed, 92 insertions(+) create mode 100644 .changeset/non-waking-container-forward.md diff --git a/.changeset/non-waking-container-forward.md b/.changeset/non-waking-container-forward.md new file mode 100644 index 0000000..e012fea --- /dev/null +++ b/.changeset/non-waking-container-forward.md @@ -0,0 +1,5 @@ +--- +"@cloudflare/containers": patch +--- + +Add a non-starting `fetchIfRunning()` helper that only proxies requests to already-running, healthy containers without waking stopped containers. diff --git a/src/lib/container.ts b/src/lib/container.ts index 8c51e76..0ffa14d 100644 --- a/src/lib/container.ts +++ b/src/lib/container.ts @@ -1140,6 +1140,24 @@ export class Container extends DurableObject { // HTTP // ============ + /** + * Forward a request to the container only if it is already running and healthy. + * + * This method never starts the container or waits for ports to become ready. + */ + public async fetchIfRunning(request: Request, port = this.defaultPort): Promise { + if (port === undefined) { + throw new Error('No port specified for container fetch'); + } + + const state = await this.state.getState(); + if (!this.container.running || state.status !== 'healthy') { + return new Response('Container is not running', { status: 503 }); + } + + return await this.forwardToTcpPort(request, port); + } + /** * Send a request to the container (HTTP or WebSocket) using standard fetch API signature * @@ -1193,6 +1211,10 @@ export class Container extends DurableObject { } } + return await this.forwardToTcpPort(request, port); + } + + private async forwardToTcpPort(request: Request, port: number): Promise { const tcpPort = this.container.getTcpPort(port); // Create URL for the container request diff --git a/src/tests/container.test.ts b/src/tests/container.test.ts index f59654d..fcffae1 100644 --- a/src/tests/container.test.ts +++ b/src/tests/container.test.ts @@ -154,6 +154,71 @@ describe('Container', () => { expect(tcpPort.fetch).toHaveBeenCalledWith(expect.any(String), expect.any(Object)); }); + for (const { name, running, status } of [ + { name: 'stopped container', running: false, status: 'stopped' }, + { name: 'running but non-healthy container', running: true, status: 'running' }, + ] as const) { + test(`fetchIfRunning should not start or forward ${name}`, async ({ mockCtx, container }) => { + mockCtx.container.running = running; + mockCtx.storage.get.mockResolvedValue({ status, lastChange: Date.now() }); + + const response = await container.fetchIfRunning( + new Request('https://example.com/test'), + 8080 + ); + + expect(response.status).toBe(503); + expect(mockCtx.container.start).not.toHaveBeenCalled(); + expect(mockCtx.container.getTcpPort).not.toHaveBeenCalled(); + }); + } + + test('fetchIfRunning should use default port when port is omitted', async ({ + mockCtx, + container, + }) => { + mockCtx.container.running = true; + mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() }); + + const response = await container.fetchIfRunning(new Request('https://example.com/test')); + + expect(response.status).toBe(200); + expect(mockCtx.container.getTcpPort).toHaveBeenCalledWith(8080); + }); + + test('fetchIfRunning should bypass the startup path', async ({ mockCtx, container }) => { + using containerFetchSpy = vi.spyOn(container, 'containerFetch'); + mockCtx.container.running = true; + mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() }); + + const response = await container.fetchIfRunning(new Request('https://example.com/test'), 8080); + + expect(response.status).toBe(200); + expect(containerFetchSpy).not.toHaveBeenCalled(); + expect(mockCtx.container.start).not.toHaveBeenCalled(); + expect(mockCtx.container.getTcpPort).toHaveBeenCalledWith(8080); + }); + + test('fetchIfRunning should forward WebSocket requests', async ({ mockCtx, container }) => { + mockCtx.container.running = true; + mockCtx.storage.get.mockResolvedValue({ status: 'healthy', lastChange: Date.now() }); + + const response = await container.fetchIfRunning( + new Request('https://example.com/ws', { + headers: new Headers({ + Upgrade: 'websocket', + Connection: 'Upgrade', + }), + }), + 8080 + ); + + const tcpPort = mockCtx.container.getTcpPort.mock.results[0].value; + expect(tcpPort.fetch).toHaveBeenCalled(); + expect(webSocketPairSpy).toHaveBeenCalledOnce(); + expect(response.status).toBe(200); + }); + 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