@opennextjs/cloudflare 1.0.4 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/dist/api/cloudflare-context.d.ts +5 -0
  2. package/dist/api/config.d.ts +12 -2
  3. package/dist/api/config.js +9 -2
  4. package/dist/api/durable-objects/bucket-cache-purge.d.ts +7 -0
  5. package/dist/api/durable-objects/bucket-cache-purge.js +75 -0
  6. package/dist/api/durable-objects/bucket-cache-purge.spec.js +121 -0
  7. package/dist/api/overrides/cache-purge/index.d.ts +12 -0
  8. package/dist/api/overrides/cache-purge/index.js +26 -0
  9. package/dist/api/overrides/internal.d.ts +2 -0
  10. package/dist/api/overrides/internal.js +52 -0
  11. package/dist/api/overrides/queue/do-queue.js +1 -1
  12. package/dist/api/overrides/queue/queue-cache.d.ts +36 -0
  13. package/dist/api/overrides/queue/queue-cache.js +93 -0
  14. package/dist/api/overrides/queue/queue-cache.spec.d.ts +1 -0
  15. package/dist/api/overrides/queue/queue-cache.spec.js +92 -0
  16. package/dist/api/overrides/tag-cache/d1-next-tag-cache.js +2 -1
  17. package/dist/api/overrides/tag-cache/do-sharded-tag-cache.d.ts +20 -0
  18. package/dist/api/overrides/tag-cache/do-sharded-tag-cache.js +70 -7
  19. package/dist/api/overrides/tag-cache/do-sharded-tag-cache.spec.js +81 -1
  20. package/dist/cli/build/bundle-server.d.ts +1 -1
  21. package/dist/cli/build/bundle-server.js +16 -38
  22. package/dist/cli/build/open-next/compileDurableObjects.js +1 -0
  23. package/dist/cli/build/open-next/createServerBundle.js +9 -10
  24. package/dist/cli/build/patches/index.d.ts +0 -1
  25. package/dist/cli/build/patches/index.js +0 -1
  26. package/dist/cli/build/patches/investigated/index.d.ts +0 -1
  27. package/dist/cli/build/patches/investigated/index.js +0 -1
  28. package/dist/cli/build/patches/plugins/load-manifest.d.ts +3 -1
  29. package/dist/cli/build/patches/plugins/load-manifest.js +49 -7
  30. package/dist/cli/build/patches/plugins/next-server.d.ts +25 -0
  31. package/dist/cli/build/patches/plugins/next-server.js +110 -0
  32. package/dist/cli/build/patches/plugins/next-server.spec.d.ts +1 -0
  33. package/dist/cli/build/patches/plugins/next-server.spec.js +429 -0
  34. package/dist/cli/build/patches/plugins/open-next.d.ts +8 -0
  35. package/dist/cli/build/patches/plugins/open-next.js +39 -0
  36. package/dist/cli/templates/init.js +5 -0
  37. package/dist/cli/templates/shims/throw.d.ts +2 -0
  38. package/dist/cli/templates/shims/throw.js +2 -0
  39. package/dist/cli/templates/worker.d.ts +1 -0
  40. package/dist/cli/templates/worker.js +2 -0
  41. package/package.json +3 -3
  42. package/dist/cli/build/patches/investigated/patch-cache.d.ts +0 -14
  43. package/dist/cli/build/patches/investigated/patch-cache.js +0 -40
  44. package/dist/cli/build/patches/plugins/build-id.d.ts +0 -6
  45. package/dist/cli/build/patches/plugins/build-id.js +0 -29
  46. package/dist/cli/build/patches/plugins/build-id.spec.js +0 -82
  47. package/dist/cli/build/patches/plugins/eval-manifest.d.ts +0 -7
  48. package/dist/cli/build/patches/plugins/eval-manifest.js +0 -61
  49. package/dist/cli/build/patches/to-investigate/inline-middleware-manifest.d.ts +0 -6
  50. package/dist/cli/build/patches/to-investigate/inline-middleware-manifest.js +0 -15
  51. /package/dist/{cli/build/patches/plugins/build-id.spec.d.ts → api/durable-objects/bucket-cache-purge.spec.d.ts} +0 -0
@@ -1,4 +1,5 @@
1
1
  import type { GetPlatformProxyOptions } from "wrangler";
2
+ import type { BucketCachePurge } from "./durable-objects/bucket-cache-purge.js";
2
3
  import type { DOQueueHandler } from "./durable-objects/queue.js";
3
4
  import type { DOShardedTagCache } from "./durable-objects/sharded-tag-cache.js";
4
5
  import type { PREFIX_ENV_NAME as KV_CACHE_PREFIX_ENV_NAME } from "./overrides/incremental-cache/kv-incremental-cache.js";
@@ -21,6 +22,10 @@ declare global {
21
22
  NEXT_CACHE_DO_QUEUE_RETRY_INTERVAL_MS?: string;
22
23
  NEXT_CACHE_DO_QUEUE_MAX_RETRIES?: string;
23
24
  NEXT_CACHE_DO_QUEUE_DISABLE_SQLITE?: string;
25
+ NEXT_CACHE_DO_PURGE?: DurableObjectNamespace<BucketCachePurge>;
26
+ NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS?: string;
27
+ CACHE_PURGE_ZONE_ID?: string;
28
+ CACHE_PURGE_API_TOKEN?: string;
24
29
  }
25
30
  }
26
31
  export type CloudflareContext<CfProperties extends Record<string, unknown> = IncomingRequestCfProperties, Context = ExecutionContext> = {
@@ -1,6 +1,6 @@
1
1
  import type { BuildOptions } from "@opennextjs/aws/build/helper";
2
- import { BaseOverride, LazyLoadedOverride, OpenNextConfig as AwsOpenNextConfig } from "@opennextjs/aws/types/open-next";
3
- import type { IncrementalCache, Queue, TagCache } from "@opennextjs/aws/types/overrides";
2
+ import { BaseOverride, LazyLoadedOverride, OpenNextConfig as AwsOpenNextConfig, type RoutePreloadingBehavior } from "@opennextjs/aws/types/open-next";
3
+ import type { CDNInvalidationHandler, IncrementalCache, Queue, TagCache } from "@opennextjs/aws/types/overrides";
4
4
  export type Override<T extends BaseOverride> = "dummy" | T | LazyLoadedOverride<T>;
5
5
  /**
6
6
  * Cloudflare specific overrides.
@@ -20,12 +20,22 @@ export type CloudflareOverrides = {
20
20
  * Sets the revalidation queue implementation
21
21
  */
22
22
  queue?: "direct" | Override<Queue>;
23
+ /**
24
+ * Sets the automatic cache purge implementation
25
+ */
26
+ cachePurge?: Override<CDNInvalidationHandler>;
23
27
  /**
24
28
  * Enable cache interception
25
29
  * Should be `false` when PPR is used
26
30
  * @default false
27
31
  */
28
32
  enableCacheInterception?: boolean;
33
+ /**
34
+ * Route preloading behavior.
35
+ * Using a value other than "none" can result in higher CPU usage on cold starts.
36
+ * @default "none"
37
+ */
38
+ routePreloadingBehavior?: RoutePreloadingBehavior;
29
39
  };
30
40
  /**
31
41
  * Defines the OpenNext configuration that targets the Cloudflare adapter
@@ -5,7 +5,7 @@
5
5
  * @returns the OpenNext configuration object
6
6
  */
7
7
  export function defineCloudflareConfig(config = {}) {
8
- const { incrementalCache, tagCache, queue, enableCacheInterception = false } = config;
8
+ const { incrementalCache, tagCache, queue, cachePurge, enableCacheInterception = false, routePreloadingBehavior = "none", } = config;
9
9
  return {
10
10
  default: {
11
11
  override: {
@@ -15,8 +15,9 @@ export function defineCloudflareConfig(config = {}) {
15
15
  incrementalCache: resolveIncrementalCache(incrementalCache),
16
16
  tagCache: resolveTagCache(tagCache),
17
17
  queue: resolveQueue(queue),
18
+ cdnInvalidation: resolveCdnInvalidation(cachePurge),
18
19
  },
19
- routePreloadingBehavior: "withWaitUntil",
20
+ routePreloadingBehavior,
20
21
  },
21
22
  // node:crypto is used to compute cache keys
22
23
  edgeExternals: ["node:crypto"],
@@ -57,6 +58,12 @@ function resolveQueue(value = "dummy") {
57
58
  }
58
59
  return typeof value === "function" ? value : () => value;
59
60
  }
61
+ function resolveCdnInvalidation(value = "dummy") {
62
+ if (typeof value === "string") {
63
+ return value;
64
+ }
65
+ return typeof value === "function" ? value : () => value;
66
+ }
60
67
  /**
61
68
  * @param buildOpts build options from AWS
62
69
  * @returns The OpenConfig specific to cloudflare
@@ -0,0 +1,7 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ export declare class BucketCachePurge extends DurableObject<CloudflareEnv> {
3
+ bufferTimeInSeconds: number;
4
+ constructor(state: DurableObjectState, env: CloudflareEnv);
5
+ purgeCacheByTags(tags: string[]): Promise<void>;
6
+ alarm(): Promise<void>;
7
+ }
@@ -0,0 +1,75 @@
1
+ import { DurableObject } from "cloudflare:workers";
2
+ import { internalPurgeCacheByTags } from "../overrides/internal";
3
+ const DEFAULT_BUFFER_TIME_IN_SECONDS = 5;
4
+ // https://developers.cloudflare.com/cache/how-to/purge-cache/#hostname-tag-prefix-url-and-purge-everything-limits
5
+ const MAX_NUMBER_OF_TAGS_PER_PURGE = 100;
6
+ export class BucketCachePurge extends DurableObject {
7
+ bufferTimeInSeconds;
8
+ constructor(state, env) {
9
+ super(state, env);
10
+ this.bufferTimeInSeconds = env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS
11
+ ? parseInt(env.NEXT_CACHE_DO_PURGE_BUFFER_TIME_IN_SECONDS)
12
+ : DEFAULT_BUFFER_TIME_IN_SECONDS; // Default buffer time
13
+ // Initialize the sql table if it doesn't exist
14
+ state.blockConcurrencyWhile(async () => {
15
+ state.storage.sql.exec(`
16
+ CREATE TABLE IF NOT EXISTS cache_purge (
17
+ tag TEXT NOT NULL
18
+ );
19
+ CREATE UNIQUE INDEX IF NOT EXISTS tag_index ON cache_purge (tag);
20
+ `);
21
+ });
22
+ }
23
+ async purgeCacheByTags(tags) {
24
+ for (const tag of tags) {
25
+ // Insert the tag into the sql table
26
+ this.ctx.storage.sql.exec(`
27
+ INSERT OR REPLACE INTO cache_purge (tag)
28
+ VALUES (?)`, [tag]);
29
+ }
30
+ const nextAlarm = await this.ctx.storage.getAlarm();
31
+ if (!nextAlarm) {
32
+ // Set an alarm to trigger the cache purge
33
+ this.ctx.storage.setAlarm(Date.now() + this.bufferTimeInSeconds * 1000);
34
+ }
35
+ }
36
+ async alarm() {
37
+ let tags = this.ctx.storage.sql
38
+ .exec(`
39
+ SELECT * FROM cache_purge LIMIT ${MAX_NUMBER_OF_TAGS_PER_PURGE}
40
+ `)
41
+ .toArray();
42
+ do {
43
+ if (tags.length === 0) {
44
+ // No tags to purge, we can stop
45
+ return;
46
+ }
47
+ const result = await internalPurgeCacheByTags(this.env, tags.map((row) => row.tag));
48
+ // For every other error, we just remove the tags from the sql table
49
+ // and continue
50
+ if (result === "rate-limit-exceeded") {
51
+ // Rate limit exceeded, we need to wait for the next alarm
52
+ // and try again
53
+ // We throw here to take advantage of the built-in retry
54
+ throw new Error("Rate limit exceeded");
55
+ }
56
+ // Delete the tags from the sql table
57
+ this.ctx.storage.sql.exec(`
58
+ DELETE FROM cache_purge
59
+ WHERE tag IN (${tags.map(() => "?").join(",")})
60
+ `, tags.map((row) => row.tag));
61
+ if (tags.length < MAX_NUMBER_OF_TAGS_PER_PURGE) {
62
+ // If we have less than MAX_NUMBER_OF_TAGS_PER_PURGE tags, we can stop
63
+ tags = [];
64
+ }
65
+ else {
66
+ // Otherwise, we need to get the next 100 tags
67
+ tags = this.ctx.storage.sql
68
+ .exec(`
69
+ SELECT * FROM cache_purge LIMIT ${MAX_NUMBER_OF_TAGS_PER_PURGE}
70
+ `)
71
+ .toArray();
72
+ }
73
+ } while (tags.length >= 0);
74
+ }
75
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import * as internal from "../overrides/internal";
3
+ import { BucketCachePurge } from "./bucket-cache-purge";
4
+ vi.mock("cloudflare:workers", () => ({
5
+ DurableObject: class {
6
+ ctx;
7
+ env;
8
+ constructor(ctx, env) {
9
+ this.ctx = ctx;
10
+ this.env = env;
11
+ }
12
+ },
13
+ }));
14
+ const createBucketCachePurge = () => {
15
+ const mockState = {
16
+ waitUntil: vi.fn(),
17
+ blockConcurrencyWhile: vi.fn().mockImplementation(async (fn) => fn()),
18
+ storage: {
19
+ setAlarm: vi.fn(),
20
+ getAlarm: vi.fn(),
21
+ sql: {
22
+ exec: vi.fn().mockImplementation(() => ({
23
+ one: vi.fn(),
24
+ toArray: vi.fn().mockReturnValue([]),
25
+ })),
26
+ },
27
+ },
28
+ };
29
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
30
+ return new BucketCachePurge(mockState, {});
31
+ };
32
+ describe("BucketCachePurge", () => {
33
+ it("should block concurrency while creating the table", async () => {
34
+ const cache = createBucketCachePurge();
35
+ // @ts-expect-error - testing private method
36
+ expect(cache.ctx.blockConcurrencyWhile).toHaveBeenCalled();
37
+ // @ts-expect-error - testing private method
38
+ expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith(expect.stringContaining("CREATE TABLE IF NOT EXISTS cache_purge"));
39
+ });
40
+ describe("purgeCacheByTags", () => {
41
+ it("should insert tags into the sql table", async () => {
42
+ const cache = createBucketCachePurge();
43
+ const tags = ["tag1", "tag2"];
44
+ await cache.purgeCacheByTags(tags);
45
+ // @ts-expect-error - testing private method
46
+ expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith(expect.stringContaining("INSERT OR REPLACE INTO cache_purge"), [tags[0]]);
47
+ // @ts-expect-error - testing private method
48
+ expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith(expect.stringContaining("INSERT OR REPLACE INTO cache_purge"), [tags[1]]);
49
+ });
50
+ it("should set an alarm if no alarm is set", async () => {
51
+ const cache = createBucketCachePurge();
52
+ // @ts-expect-error - testing private method
53
+ cache.ctx.storage.getAlarm.mockResolvedValueOnce(null);
54
+ await cache.purgeCacheByTags(["tag"]);
55
+ // @ts-expect-error - testing private method
56
+ expect(cache.ctx.storage.setAlarm).toHaveBeenCalled();
57
+ });
58
+ it("should not set an alarm if one is already set", async () => {
59
+ const cache = createBucketCachePurge();
60
+ // @ts-expect-error - testing private method
61
+ cache.ctx.storage.getAlarm.mockResolvedValueOnce(true);
62
+ await cache.purgeCacheByTags(["tag"]);
63
+ // @ts-expect-error - testing private method
64
+ expect(cache.ctx.storage.setAlarm).not.toHaveBeenCalled();
65
+ });
66
+ });
67
+ describe("alarm", () => {
68
+ it("should purge cache by tags and delete them from the sql table", async () => {
69
+ const cache = createBucketCachePurge();
70
+ // @ts-expect-error - testing private method
71
+ cache.ctx.storage.sql.exec.mockReturnValueOnce({
72
+ toArray: () => [{ tag: "tag1" }, { tag: "tag2" }],
73
+ });
74
+ await cache.alarm();
75
+ // @ts-expect-error - testing private method
76
+ expect(cache.ctx.storage.sql.exec).toHaveBeenCalledWith(expect.stringContaining("DELETE FROM cache_purge"), ["tag1", "tag2"]);
77
+ });
78
+ it("should not purge cache if no tags are found", async () => {
79
+ const cache = createBucketCachePurge();
80
+ // @ts-expect-error - testing private method
81
+ cache.ctx.storage.sql.exec.mockReturnValueOnce({
82
+ toArray: () => [],
83
+ });
84
+ await cache.alarm();
85
+ // @ts-expect-error - testing private method
86
+ expect(cache.ctx.storage.sql.exec).not.toHaveBeenCalledWith(expect.stringContaining("DELETE FROM cache_purge"), []);
87
+ });
88
+ it("should call internalPurgeCacheByTags with the correct tags", async () => {
89
+ const cache = createBucketCachePurge();
90
+ const tags = ["tag1", "tag2"];
91
+ // @ts-expect-error - testing private method
92
+ cache.ctx.storage.sql.exec.mockReturnValueOnce({
93
+ toArray: () => tags.map((tag) => ({ tag })),
94
+ });
95
+ const internalPurgeCacheByTagsSpy = vi.spyOn(internal, "internalPurgeCacheByTags");
96
+ await cache.alarm();
97
+ expect(internalPurgeCacheByTagsSpy).toHaveBeenCalledWith(
98
+ // @ts-expect-error - testing private method
99
+ cache.env, tags);
100
+ // @ts-expect-error - testing private method 1st is constructor, 2nd is to get the tags and 3rd is to delete them
101
+ expect(cache.ctx.storage.sql.exec).toHaveBeenCalledTimes(3);
102
+ });
103
+ it("should continue until all tags are purged", async () => {
104
+ const cache = createBucketCachePurge();
105
+ const tags = Array.from({ length: 100 }, (_, i) => `tag${i}`);
106
+ // @ts-expect-error - testing private method
107
+ cache.ctx.storage.sql.exec.mockReturnValueOnce({
108
+ toArray: () => tags.map((tag) => ({ tag })),
109
+ });
110
+ const internalPurgeCacheByTagsSpy = vi.spyOn(internal, "internalPurgeCacheByTags");
111
+ await cache.alarm();
112
+ expect(internalPurgeCacheByTagsSpy).toHaveBeenCalledWith(
113
+ // @ts-expect-error - testing private method
114
+ cache.env, tags);
115
+ // @ts-expect-error - testing private method 1st is constructor, 2nd is to get the tags and 3rd is to delete them, 4th is to get the next 100 tags
116
+ expect(cache.ctx.storage.sql.exec).toHaveBeenCalledTimes(4);
117
+ // @ts-expect-error - testing private method
118
+ expect(cache.ctx.storage.sql.exec).toHaveBeenLastCalledWith(expect.stringContaining("SELECT * FROM cache_purge LIMIT 100"));
119
+ });
120
+ });
121
+ });
@@ -0,0 +1,12 @@
1
+ interface PurgeOptions {
2
+ type: "durableObject" | "direct";
3
+ }
4
+ export declare const purgeCache: ({ type }: PurgeOptions) => {
5
+ name: string;
6
+ invalidatePaths(paths: {
7
+ initialPath: string;
8
+ rawPath: string;
9
+ resolvedRoutes: import("@opennextjs/aws/types/open-next").ResolvedRoute[];
10
+ }[]): Promise<void>;
11
+ };
12
+ export {};
@@ -0,0 +1,26 @@
1
+ import { getCloudflareContext } from "../../cloudflare-context";
2
+ import { debugCache, internalPurgeCacheByTags } from "../internal.js";
3
+ export const purgeCache = ({ type = "direct" }) => {
4
+ return {
5
+ name: "cloudflare",
6
+ async invalidatePaths(paths) {
7
+ const { env } = getCloudflareContext();
8
+ const tags = paths.map((path) => `_N_T_${path.rawPath}`);
9
+ debugCache("cdnInvalidation", "Invalidating paths:", tags);
10
+ if (type === "direct") {
11
+ await internalPurgeCacheByTags(env, tags);
12
+ }
13
+ else {
14
+ const durableObject = env.NEXT_CACHE_DO_PURGE;
15
+ if (!durableObject) {
16
+ debugCache("cdnInvalidation", "No durable object found. Skipping cache purge.");
17
+ return;
18
+ }
19
+ const id = durableObject.idFromName("cache-purge");
20
+ const obj = durableObject.get(id);
21
+ await obj.purgeCacheByTags(tags);
22
+ }
23
+ debugCache("cdnInvalidation", "Invalidated paths:", tags);
24
+ },
25
+ };
26
+ };
@@ -12,3 +12,5 @@ export type KeyOptions = {
12
12
  buildId: string | undefined;
13
13
  };
14
14
  export declare function computeCacheKey(key: string, options: KeyOptions): string;
15
+ export declare function purgeCacheByTags(tags: string[]): Promise<void>;
16
+ export declare function internalPurgeCacheByTags(env: CloudflareEnv, tags: string[]): Promise<"missing-credentials" | "rate-limit-exceeded" | "purge-failed" | "purge-success">;
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { getCloudflareContext } from "../cloudflare-context.js";
2
3
  export const debugCache = (name, ...args) => {
3
4
  if (process.env.NEXT_PRIVATE_DEBUG_CACHE) {
4
5
  console.log(`[${name}] `, ...args);
@@ -11,3 +12,54 @@ export function computeCacheKey(key, options) {
11
12
  const hash = createHash("sha256").update(key).digest("hex");
12
13
  return `${prefix}/${buildId}/${hash}.${cacheType}`.replace(/\/+/g, "/");
13
14
  }
15
+ export async function purgeCacheByTags(tags) {
16
+ const { env } = getCloudflareContext();
17
+ // We have a durable object for purging cache
18
+ // We should use it
19
+ if (env.NEXT_CACHE_DO_PURGE) {
20
+ const durableObject = env.NEXT_CACHE_DO_PURGE;
21
+ const id = durableObject.idFromName("cache-purge");
22
+ const obj = durableObject.get(id);
23
+ await obj.purgeCacheByTags(tags);
24
+ }
25
+ else {
26
+ // We don't have a durable object for purging cache
27
+ // We should use the API directly
28
+ await internalPurgeCacheByTags(env, tags);
29
+ }
30
+ }
31
+ export async function internalPurgeCacheByTags(env, tags) {
32
+ if (!env.CACHE_PURGE_ZONE_ID && !env.CACHE_PURGE_API_TOKEN) {
33
+ // THIS IS A NO-OP
34
+ debugCache("purgeCacheByTags", "No cache zone ID or API token provided. Skipping cache purge.");
35
+ return "missing-credentials";
36
+ }
37
+ try {
38
+ const response = await fetch(`https://api.cloudflare.com/client/v4/zones/${env.CACHE_PURGE_ZONE_ID}/purge_cache`, {
39
+ headers: {
40
+ Authorization: `Bearer ${env.CACHE_PURGE_API_TOKEN}`,
41
+ "Content-Type": "application/json",
42
+ },
43
+ method: "POST",
44
+ body: JSON.stringify({
45
+ tags,
46
+ }),
47
+ });
48
+ if (response.status === 429) {
49
+ // Rate limit exceeded
50
+ debugCache("purgeCacheByTags", "Rate limit exceeded. Skipping cache purge.");
51
+ return "rate-limit-exceeded";
52
+ }
53
+ const bodyResponse = (await response.json());
54
+ if (!bodyResponse.success) {
55
+ debugCache("purgeCacheByTags", "Cache purge failed. Errors:", bodyResponse.errors.map((error) => `${error.code}: ${error.message}`));
56
+ return "purge-failed";
57
+ }
58
+ debugCache("purgeCacheByTags", "Cache purged successfully for tags:", tags);
59
+ return "purge-success";
60
+ }
61
+ catch (error) {
62
+ console.error("Error purging cache by tags:", error);
63
+ return "purge-failed";
64
+ }
65
+ }
@@ -1,7 +1,7 @@
1
1
  import { IgnorableError } from "@opennextjs/aws/utils/error.js";
2
2
  import { getCloudflareContext } from "../../cloudflare-context";
3
3
  export default {
4
- name: "do-queue",
4
+ name: "durable-queue",
5
5
  send: async (msg) => {
6
6
  const durableObject = getCloudflareContext().env.NEXT_CACHE_DO_QUEUE;
7
7
  if (!durableObject)
@@ -0,0 +1,36 @@
1
+ import type { Queue, QueueMessage } from "@opennextjs/aws/types/overrides";
2
+ interface QueueCachingOptions {
3
+ /**
4
+ * The TTL for the regional cache in seconds.
5
+ * @default 5
6
+ */
7
+ regionalCacheTtlSec?: number;
8
+ /**
9
+ * Whether to wait for the queue ack before returning.
10
+ * When set to false, the cache will be populated asap and the queue will be called after.
11
+ * When set to true, the cache will be populated only after the queue ack is received.
12
+ * @default false
13
+ */
14
+ waitForQueueAck?: boolean;
15
+ }
16
+ declare class QueueCache implements Queue {
17
+ private originalQueue;
18
+ readonly name: string;
19
+ readonly regionalCacheTtlSec: number;
20
+ readonly waitForQueueAck: boolean;
21
+ cache: Cache | undefined;
22
+ localCache: Map<string, number>;
23
+ constructor(originalQueue: Queue, options: QueueCachingOptions);
24
+ send(msg: QueueMessage): Promise<void>;
25
+ private getCache;
26
+ private getCacheUrlString;
27
+ private getCacheKey;
28
+ private putToCache;
29
+ private isInCache;
30
+ /**
31
+ * Remove any value older than the TTL from the local cache
32
+ */
33
+ private clearLocalCache;
34
+ }
35
+ declare const _default: (originalQueue: Queue, opts?: QueueCachingOptions) => QueueCache;
36
+ export default _default;
@@ -0,0 +1,93 @@
1
+ import { error } from "@opennextjs/aws/adapters/logger.js";
2
+ const DEFAULT_QUEUE_CACHE_TTL_SEC = 5;
3
+ class QueueCache {
4
+ originalQueue;
5
+ name;
6
+ regionalCacheTtlSec;
7
+ waitForQueueAck;
8
+ cache;
9
+ // Local mapping from key to insertedAtSec
10
+ localCache = new Map();
11
+ constructor(originalQueue, options) {
12
+ this.originalQueue = originalQueue;
13
+ this.name = `cached-${originalQueue.name}`;
14
+ this.regionalCacheTtlSec = options.regionalCacheTtlSec ?? DEFAULT_QUEUE_CACHE_TTL_SEC;
15
+ this.waitForQueueAck = options.waitForQueueAck ?? false;
16
+ }
17
+ async send(msg) {
18
+ try {
19
+ const isCached = await this.isInCache(msg);
20
+ if (isCached) {
21
+ return;
22
+ }
23
+ if (!this.waitForQueueAck) {
24
+ await this.putToCache(msg);
25
+ await this.originalQueue.send(msg);
26
+ }
27
+ else {
28
+ await this.originalQueue.send(msg);
29
+ await this.putToCache(msg);
30
+ }
31
+ }
32
+ catch (e) {
33
+ error("Error sending message to queue", e);
34
+ }
35
+ finally {
36
+ this.clearLocalCache();
37
+ }
38
+ }
39
+ async getCache() {
40
+ if (!this.cache) {
41
+ this.cache = await caches.open("durable-queue");
42
+ }
43
+ return this.cache;
44
+ }
45
+ getCacheUrlString(msg) {
46
+ return `queue/${msg.MessageGroupId}/${msg.MessageDeduplicationId}`;
47
+ }
48
+ getCacheKey(msg) {
49
+ return "http://local.cache" + this.getCacheUrlString(msg);
50
+ }
51
+ async putToCache(msg) {
52
+ this.localCache.set(this.getCacheUrlString(msg), Date.now());
53
+ const cacheKey = this.getCacheKey(msg);
54
+ const cache = await this.getCache();
55
+ await cache.put(cacheKey, new Response(null, {
56
+ status: 200,
57
+ headers: {
58
+ "Cache-Control": `max-age=${this.regionalCacheTtlSec}`,
59
+ // Tag cache is set to the value of the soft tag assigned by Next.js
60
+ // This way you can invalidate this cache as well as any other regional cache
61
+ "Cache-Tag": `_N_T_/${msg.MessageBody.url}`,
62
+ },
63
+ }));
64
+ }
65
+ async isInCache(msg) {
66
+ if (this.localCache.has(this.getCacheUrlString(msg))) {
67
+ const insertedAt = this.localCache.get(this.getCacheUrlString(msg));
68
+ if (Date.now() - insertedAt < this.regionalCacheTtlSec * 1000) {
69
+ return true;
70
+ }
71
+ this.localCache.delete(this.getCacheUrlString(msg));
72
+ return false;
73
+ }
74
+ const cacheKey = this.getCacheKey(msg);
75
+ const cache = await this.getCache();
76
+ const cachedResponse = await cache.match(cacheKey);
77
+ if (cachedResponse) {
78
+ return true;
79
+ }
80
+ }
81
+ /**
82
+ * Remove any value older than the TTL from the local cache
83
+ */
84
+ clearLocalCache() {
85
+ const insertAtSecMax = Date.now() - this.regionalCacheTtlSec * 1000;
86
+ for (const [key, insertAtSec] of this.localCache.entries()) {
87
+ if (insertAtSec < insertAtSecMax) {
88
+ this.localCache.delete(key);
89
+ }
90
+ }
91
+ }
92
+ }
93
+ export default (originalQueue, opts = {}) => new QueueCache(originalQueue, opts);
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,92 @@
1
+ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
2
+ import queueCache from "./queue-cache";
3
+ const mockedQueue = {
4
+ name: "mocked-queue",
5
+ send: vi.fn(),
6
+ };
7
+ const generateMessage = () => ({
8
+ MessageGroupId: "test",
9
+ MessageBody: {
10
+ eTag: "test",
11
+ url: "test",
12
+ host: "test",
13
+ lastModified: Date.now(),
14
+ },
15
+ MessageDeduplicationId: "test",
16
+ });
17
+ const mockedPut = vi.fn();
18
+ const mockedMatch = vi.fn().mockReturnValue(null);
19
+ describe("queue-cache", () => {
20
+ beforeEach(() => {
21
+ // @ts-ignore
22
+ globalThis.caches = {
23
+ open: vi.fn().mockReturnValue({
24
+ put: mockedPut,
25
+ match: mockedMatch,
26
+ }),
27
+ };
28
+ });
29
+ afterEach(() => {
30
+ vi.resetAllMocks();
31
+ });
32
+ test("should send the message to the original queue", async () => {
33
+ const msg = generateMessage();
34
+ const queue = queueCache(mockedQueue, {});
35
+ expect(queue.name).toBe("cached-mocked-queue");
36
+ await queue.send(msg);
37
+ expect(mockedQueue.send).toHaveBeenCalledWith(msg);
38
+ });
39
+ test("should use the local cache", async () => {
40
+ const msg = generateMessage();
41
+ const queue = queueCache(mockedQueue, {});
42
+ await queue.send(msg);
43
+ expect(queue.localCache.size).toBe(1);
44
+ expect(queue.localCache.has(`queue/test/test`)).toBe(true);
45
+ expect(mockedPut).toHaveBeenCalled();
46
+ const spiedHas = vi.spyOn(queue.localCache, "has");
47
+ await queue.send(msg);
48
+ expect(spiedHas).toHaveBeenCalled();
49
+ expect(mockedQueue.send).toHaveBeenCalledTimes(1);
50
+ expect(mockedMatch).toHaveBeenCalledTimes(1);
51
+ });
52
+ test("should clear the local cache after 5s", async () => {
53
+ vi.useFakeTimers();
54
+ const msg = generateMessage();
55
+ const queue = queueCache(mockedQueue, {});
56
+ await queue.send(msg);
57
+ expect(queue.localCache.size).toBe(1);
58
+ expect(queue.localCache.has(`queue/test/test`)).toBe(true);
59
+ vi.advanceTimersByTime(5001);
60
+ const alteredMsg = generateMessage();
61
+ alteredMsg.MessageGroupId = "test2";
62
+ await queue.send(alteredMsg);
63
+ expect(queue.localCache.size).toBe(1);
64
+ console.log(queue.localCache);
65
+ expect(queue.localCache.has(`queue/test2/test`)).toBe(true);
66
+ expect(queue.localCache.has(`queue/test/test`)).toBe(false);
67
+ vi.useRealTimers();
68
+ });
69
+ test("should use the regional cache if not in local cache", async () => {
70
+ const msg = generateMessage();
71
+ const queue = queueCache(mockedQueue, {});
72
+ await queue.send(msg);
73
+ expect(mockedMatch).toHaveBeenCalledTimes(1);
74
+ expect(mockedPut).toHaveBeenCalledTimes(1);
75
+ expect(queue.localCache.size).toBe(1);
76
+ expect(queue.localCache.has(`queue/test/test`)).toBe(true);
77
+ // We need to delete the local cache to test the regional cache
78
+ queue.localCache.delete(`queue/test/test`);
79
+ const spiedHas = vi.spyOn(queue.localCache, "has");
80
+ await queue.send(msg);
81
+ expect(spiedHas).toHaveBeenCalled();
82
+ expect(mockedMatch).toHaveBeenCalledTimes(2);
83
+ });
84
+ test("should return early if the message is in the regional cache", async () => {
85
+ const msg = generateMessage();
86
+ const queue = queueCache(mockedQueue, {});
87
+ mockedMatch.mockReturnValueOnce(new Response(null, { status: 200 }));
88
+ const spiedSend = mockedQueue.send;
89
+ await queue.send(msg);
90
+ expect(spiedSend).not.toHaveBeenCalled();
91
+ });
92
+ });
@@ -1,6 +1,6 @@
1
1
  import { error } from "@opennextjs/aws/adapters/logger.js";
2
2
  import { getCloudflareContext } from "../../cloudflare-context.js";
3
- import { debugCache, FALLBACK_BUILD_ID } from "../internal.js";
3
+ import { debugCache, FALLBACK_BUILD_ID, purgeCacheByTags } from "../internal.js";
4
4
  export const NAME = "d1-next-mode-tag-cache";
5
5
  export const BINDING_NAME = "NEXT_TAG_CACHE_D1";
6
6
  export class D1NextModeTagCache {
@@ -52,6 +52,7 @@ export class D1NextModeTagCache {
52
52
  await db.batch(tags.map((tag) => db
53
53
  .prepare(`INSERT INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`)
54
54
  .bind(this.getCacheKey(tag), Date.now())));
55
+ await purgeCacheByTags(tags);
55
56
  }
56
57
  getConfig() {
57
58
  const db = getCloudflareContext().env[BINDING_NAME];