diff --git a/.changeset/spicy-seas-appear.md b/.changeset/spicy-seas-appear.md new file mode 100644 index 00000000..b7e797c0 --- /dev/null +++ b/.changeset/spicy-seas-appear.md @@ -0,0 +1,20 @@ +--- +"@opennextjs/cloudflare": patch +--- + +fix: deployed worker unable to invoke itself in memory queue + +In deployments, Cloudflare Workers are unable to invoke workers on the same account via fetch, and the recommended way to call a worker is to use a service binding. This change switches to use service bindings for the memory queue to avoid issues with worker-to-worker subrequests. + +To continue using the memory queue, add a service binding to your wrangler config for the binding `NEXT_CACHE_REVALIDATION_WORKER`. + +```json +{ + "services": [ + { + "binding": "NEXT_CACHE_REVALIDATION_WORKER", + "service": "" + } + ] +} +``` diff --git a/examples/e2e/app-pages-router/wrangler.json b/examples/e2e/app-pages-router/wrangler.json index e599b5eb..ea0c7e69 100644 --- a/examples/e2e/app-pages-router/wrangler.json +++ b/examples/e2e/app-pages-router/wrangler.json @@ -13,5 +13,11 @@ "binding": "NEXT_CACHE_WORKERS_KV", "id": "" } + ], + "services": [ + { + "binding": "NEXT_CACHE_REVALIDATION_WORKER", + "service": "app-pages-router" + } ] } diff --git a/examples/e2e/app-router/wrangler.json b/examples/e2e/app-router/wrangler.json index 9375b695..25be2dde 100644 --- a/examples/e2e/app-router/wrangler.json +++ b/examples/e2e/app-router/wrangler.json @@ -20,5 +20,11 @@ "database_id": "NEXT_CACHE_D1", "database_name": "NEXT_CACHE_D1" } + ], + "services": [ + { + "binding": "NEXT_CACHE_REVALIDATION_WORKER", + "service": "app-router" + } ] } diff --git a/examples/e2e/pages-router/wrangler.json b/examples/e2e/pages-router/wrangler.json index d0f531f2..15316fc4 100644 --- a/examples/e2e/pages-router/wrangler.json +++ b/examples/e2e/pages-router/wrangler.json @@ -13,5 +13,11 @@ "binding": "NEXT_CACHE_WORKERS_KV", "id": "" } + ], + "services": [ + { + "binding": "NEXT_CACHE_REVALIDATION_WORKER", + "service": "pages-router" + } ] } diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index bc1833ef..f13a6a5b 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -6,6 +6,7 @@ declare global { NEXT_CACHE_D1?: D1Database; NEXT_CACHE_D1_TAGS_TABLE?: string; NEXT_CACHE_D1_REVALIDATIONS_TABLE?: string; + NEXT_CACHE_REVALIDATION_WORKER?: Service; ASSETS?: Fetcher; } } diff --git a/packages/cloudflare/src/api/memory-queue.spec.ts b/packages/cloudflare/src/api/memory-queue.spec.ts index 5e8e2310..52876d6f 100644 --- a/packages/cloudflare/src/api/memory-queue.spec.ts +++ b/packages/cloudflare/src/api/memory-queue.spec.ts @@ -1,10 +1,17 @@ import { generateMessageGroupId } from "@opennextjs/aws/core/routing/queue.js"; import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; -import cache, { DEFAULT_REVALIDATION_TIMEOUT_MS } from "./memory-queue"; +import cache, { DEFAULT_REVALIDATION_TIMEOUT_MS } from "./memory-queue.js"; vi.mock("./.next/prerender-manifest.json", () => Promise.resolve({ preview: { previewModeId: "id" } })); +const mockServiceWorkerFetch = vi.fn(); +vi.mock("./cloudflare-context", () => ({ + getCloudflareContext: () => ({ + env: { NEXT_CACHE_REVALIDATION_WORKER: { fetch: mockServiceWorkerFetch } }, + }), +})); + describe("MemoryQueue", () => { beforeAll(() => { vi.useFakeTimers(); @@ -21,7 +28,7 @@ describe("MemoryQueue", () => { }); vi.advanceTimersByTime(DEFAULT_REVALIDATION_TIMEOUT_MS); await firstRequest; - expect(globalThis.internalFetch).toHaveBeenCalledTimes(1); + expect(mockServiceWorkerFetch).toHaveBeenCalledTimes(1); const secondRequest = cache.send({ MessageBody: { host: "test.local", url: "/test" }, @@ -30,7 +37,7 @@ describe("MemoryQueue", () => { }); vi.advanceTimersByTime(1); await secondRequest; - expect(globalThis.internalFetch).toHaveBeenCalledTimes(2); + expect(mockServiceWorkerFetch).toHaveBeenCalledTimes(2); }); it("should process revalidations for multiple paths", async () => { @@ -41,7 +48,7 @@ describe("MemoryQueue", () => { }); vi.advanceTimersByTime(1); await firstRequest; - expect(globalThis.internalFetch).toHaveBeenCalledTimes(1); + expect(mockServiceWorkerFetch).toHaveBeenCalledTimes(1); const secondRequest = cache.send({ MessageBody: { host: "test.local", url: "/test" }, @@ -50,7 +57,7 @@ describe("MemoryQueue", () => { }); vi.advanceTimersByTime(1); await secondRequest; - expect(globalThis.internalFetch).toHaveBeenCalledTimes(2); + expect(mockServiceWorkerFetch).toHaveBeenCalledTimes(2); }); it("should de-dupe revalidations", async () => { @@ -68,6 +75,6 @@ describe("MemoryQueue", () => { ]; vi.advanceTimersByTime(1); await Promise.all(requests); - expect(globalThis.internalFetch).toHaveBeenCalledTimes(1); + expect(mockServiceWorkerFetch).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/cloudflare/src/api/memory-queue.ts b/packages/cloudflare/src/api/memory-queue.ts index 2a0b6adf..4ebd0e38 100644 --- a/packages/cloudflare/src/api/memory-queue.ts +++ b/packages/cloudflare/src/api/memory-queue.ts @@ -1,5 +1,8 @@ import logger from "@opennextjs/aws/logger.js"; import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides.js"; +import { IgnorableError } from "@opennextjs/aws/utils/error.js"; + +import { getCloudflareContext } from "./cloudflare-context"; export const DEFAULT_REVALIDATION_TIMEOUT_MS = 10_000; @@ -7,6 +10,8 @@ export const DEFAULT_REVALIDATION_TIMEOUT_MS = 10_000; * The Memory Queue offers basic ISR revalidation by directly requesting a revalidation of a route. * * It offers basic support for in-memory de-duping per isolate. + * + * A service binding called `NEXT_CACHE_REVALIDATION_WORKER` that points to your worker is required. */ export class MemoryQueue implements Queue { readonly name = "memory-queue"; @@ -16,6 +21,9 @@ export class MemoryQueue implements Queue { constructor(private opts = { revalidationTimeoutMs: DEFAULT_REVALIDATION_TIMEOUT_MS }) {} async send({ MessageBody: { host, url }, MessageGroupId }: QueueMessage): Promise { + const service = getCloudflareContext().env.NEXT_CACHE_REVALIDATION_WORKER; + if (!service) throw new IgnorableError("No service binding for cache revalidation worker"); + if (this.revalidatedPaths.has(MessageGroupId)) return; this.revalidatedPaths.set( @@ -30,7 +38,7 @@ export class MemoryQueue implements Queue { // TODO: Drop the import - https://github.com/opennextjs/opennextjs-cloudflare/issues/361 // @ts-ignore const manifest = await import("./.next/prerender-manifest.json"); - await globalThis.internalFetch(`${protocol}://${host}${url}`, { + await service.fetch(`${protocol}://${host}${url}`, { method: "HEAD", headers: { "x-prerender-revalidate": manifest.preview.previewModeId,