Skip to content
5 changes: 5 additions & 0 deletions .changeset/witty-baboons-smile.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": minor
---

feat: basic in-memory de-duping revalidation queue
3 changes: 2 additions & 1 deletion examples/e2e/app-router/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import type { OpenNextConfig } from "@opennextjs/aws/types/open-next.js";
import cache from "@opennextjs/cloudflare/kv-cache";
import memoryQueue from "@opennextjs/cloudflare/memory-queue";

const config: OpenNextConfig = {
default: {
override: {
wrapper: "cloudflare-node",
converter: "edge",
incrementalCache: async () => cache,
queue: "direct",
queue: () => memoryQueue,
// Unused implementation
tagCache: "dummy",
},
Expand Down
73 changes: 73 additions & 0 deletions packages/cloudflare/src/api/memory-queue.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
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";

vi.mock("./.next/prerender-manifest.json", () => Promise.resolve({ preview: { previewModeId: "id" } }));

describe("MemoryQueue", () => {
beforeAll(() => {
vi.useFakeTimers();
globalThis.internalFetch = vi.fn().mockReturnValue(new Promise((res) => setTimeout(() => res(true), 1)));
});

afterEach(() => vi.clearAllMocks());

it("should process revalidations for a path", async () => {
const firstRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(DEFAULT_REVALIDATION_TIMEOUT_MS);
await firstRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);

const secondRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(1);
await secondRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(2);
});

it("should process revalidations for multiple paths", async () => {
const firstRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(1);
await firstRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);

const secondRequest = cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/other"),
MessageDeduplicationId: "",
});
vi.advanceTimersByTime(1);
await secondRequest;
expect(globalThis.internalFetch).toHaveBeenCalledTimes(2);
});

it("should de-dupe revalidations", async () => {
const requests = [
cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
}),
cache.send({
MessageBody: { host: "test.local", url: "/test" },
MessageGroupId: generateMessageGroupId("/test"),
MessageDeduplicationId: "",
}),
];
vi.advanceTimersByTime(1);
await Promise.all(requests);
expect(globalThis.internalFetch).toHaveBeenCalledTimes(1);
});
});
49 changes: 49 additions & 0 deletions packages/cloudflare/src/api/memory-queue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import logger from "@opennextjs/aws/logger.js";
import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides.js";

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.
*/
export class MemoryQueue implements Queue {
readonly name = "memory-queue";

revalidatedPaths = new Map<string, ReturnType<typeof setTimeout>>();

constructor(private opts = { revalidationTimeoutMs: DEFAULT_REVALIDATION_TIMEOUT_MS }) {}

async send({ MessageBody: { host, url }, MessageGroupId }: QueueMessage): Promise<void> {
if (this.revalidatedPaths.has(MessageGroupId)) return;

this.revalidatedPaths.set(
MessageGroupId,
// force remove to allow new revalidations incase something went wrong
setTimeout(() => this.revalidatedPaths.delete(MessageGroupId), this.opts.revalidationTimeoutMs)
);

try {
const protocol = host.includes("localhost") ? "http" : "https";

// 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}`, {
method: "HEAD",
headers: {
"x-prerender-revalidate": manifest.preview.previewModeId,
"x-isr": "1",
},
});
} catch (e) {
logger.error(e);
} finally {
clearTimeout(this.revalidatedPaths.get(MessageGroupId));
this.revalidatedPaths.delete(MessageGroupId);
}
}
}

export default new MemoryQueue();
6 changes: 4 additions & 2 deletions packages/cloudflare/src/cli/build/utils/ensure-cf-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
typeof config.default?.override?.incrementalCache === "function",
dftUseDummyTagCache: config.default?.override?.tagCache === "dummy",
dftMaybeUseQueue:
config.default?.override?.queue === "dummy" || config.default?.override?.queue === "direct",
config.default?.override?.queue === "dummy" ||
config.default?.override?.queue === "direct" ||
typeof config.default?.override?.queue === "function",
disableCacheInterception: config.dangerous?.enableCacheInterception !== true,
mwIsMiddlewareExternal: config.middleware?.external == true,
mwUseCloudflareWrapper: config.middleware?.override?.wrapper === "cloudflare-edge",
Expand All @@ -37,7 +39,7 @@ export function ensureCloudflareConfig(config: OpenNextConfig) {
converter: "edge",
incrementalCache: "dummy" | function,
tagCache: "dummy",
queue: "dummy" | "direct",
queue: "dummy" | "direct" | function,
},
},

Expand Down
2 changes: 1 addition & 1 deletion packages/cloudflare/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"noPropertyAccessFromIndexSignature": false,
"outDir": "./dist",
"target": "ES2022",
"types": ["@cloudflare/workers-types"]
"types": ["@cloudflare/workers-types", "@opennextjs/aws/types/global.d.ts"]
},
"include": ["src/**/*.ts"]
}