diff --git a/.changeset/neat-hornets-call.md b/.changeset/neat-hornets-call.md new file mode 100644 index 00000000..540bc7ae --- /dev/null +++ b/.changeset/neat-hornets-call.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +Add a new `withFilter` tag cache to allow to filter the tags used diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts new file mode 100644 index 00000000..4dec2868 --- /dev/null +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.spec.ts @@ -0,0 +1,119 @@ +import { NextModeTagCache } from "@opennextjs/aws/types/overrides"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { softTagFilter, withFilter } from "./tag-cache-filter"; + +const mockedTagCache = { + name: "mocked", + mode: "nextMode", + getPathsByTags: vi.fn(), + hasBeenRevalidated: vi.fn(), + writeTags: vi.fn(), +} satisfies NextModeTagCache; + +const filterFn = (tag: string) => tag.startsWith("valid_"); + +describe("withFilter", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should filter out tags based on writeTags", async () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn, + }); + + const tags = ["valid_tag", "invalid_tag"]; + + await tagCache.writeTags(tags); + expect(mockedTagCache.writeTags).toHaveBeenCalledWith(["valid_tag"]); + }); + + it("should not call writeTags if no tags are valid", async () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn, + }); + const tags = ["invalid_tag"]; + await tagCache.writeTags(tags); + expect(mockedTagCache.writeTags).not.toHaveBeenCalled(); + }); + + it("should filter out tags based on hasBeenRevalidated", async () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn, + }); + + const tags = ["valid_tag", "invalid_tag"]; + const lastModified = Date.now(); + + await tagCache.hasBeenRevalidated(tags, lastModified); + expect(mockedTagCache.hasBeenRevalidated).toHaveBeenCalledWith(["valid_tag"], lastModified); + }); + + it("should not call hasBeenRevalidated if no tags are valid", async () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn, + }); + const tags = ["invalid_tag"]; + const lastModified = Date.now(); + await tagCache.hasBeenRevalidated(tags, lastModified); + expect(mockedTagCache.hasBeenRevalidated).not.toHaveBeenCalled(); + }); + + it("should filter out tags based on getPathsByTags", async () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn, + }); + + const tags = ["valid_tag", "invalid_tag"]; + + await tagCache.getPathsByTags?.(tags); + expect(mockedTagCache.getPathsByTags).toHaveBeenCalledWith(["valid_tag"]); + }); + + it("should not call getPathsByTags if no tags are valid", async () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn, + }); + const tags = ["invalid_tag"]; + await tagCache.getPathsByTags?.(tags); + expect(mockedTagCache.getPathsByTags).not.toHaveBeenCalled(); + }); + + it("should return the correct name", () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn, + }); + + expect(tagCache.name).toBe("filtered-mocked"); + }); + + it("should not create a function if getPathsByTags is not defined", async () => { + const tagCache = withFilter({ + tagCache: { + ...mockedTagCache, + getPathsByTags: undefined, + }, + filterFn, + }); + + expect(tagCache.getPathsByTags).toBeUndefined(); + }); + + it("should filter soft tags", () => { + const tagCache = withFilter({ + tagCache: mockedTagCache, + filterFn: softTagFilter, + }); + + tagCache.writeTags(["valid_tag", "_N_T_/", "_N_T_/test", "_N_T_/layout"]); + expect(mockedTagCache.writeTags).toHaveBeenCalledWith(["valid_tag"]); + }); +}); diff --git a/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts new file mode 100644 index 00000000..f9a0acf8 --- /dev/null +++ b/packages/cloudflare/src/api/overrides/tag-cache/tag-cache-filter.ts @@ -0,0 +1,58 @@ +import { NextModeTagCache } from "@opennextjs/aws/types/overrides"; + +interface WithFilterOptions { + /** + * The original tag cache. + * Call to this will receive only the filtered tags. + */ + tagCache: NextModeTagCache; + /** + * The function to filter tags. + * @param tag The tag to filter. + * @returns true if the tag should be forwarded, false otherwise. + */ + filterFn: (tag: string) => boolean; +} + +/** + * Creates a new tag cache that filters tags based on the provided filter function. + * This is useful to remove tags that are not used by the app, this could reduce the number of requests to the underlying tag cache. + */ +export function withFilter({ tagCache, filterFn }: WithFilterOptions): NextModeTagCache { + return { + name: `filtered-${tagCache.name}`, + mode: "nextMode", + getPathsByTags: tagCache.getPathsByTags + ? async (tags) => { + const filteredTags = tags.filter(filterFn); + if (filteredTags.length === 0) { + return []; + } + return tagCache.getPathsByTags!(filteredTags); + } + : undefined, + hasBeenRevalidated: async (tags, lastModified) => { + const filteredTags = tags.filter(filterFn); + if (filteredTags.length === 0) { + return false; + } + return tagCache.hasBeenRevalidated(filteredTags, lastModified); + }, + writeTags: async (tags) => { + const filteredTags = tags.filter(filterFn); + if (filteredTags.length === 0) { + return; + } + return tagCache.writeTags(filteredTags); + }, + }; +} + +/** + * Filter function to exclude tags that start with "_N_T_". + * This is used to filter out internal soft tags. + * Can be used if `revalidatePath` is not used. + */ +export function softTagFilter(tag: string): boolean { + return !tag.startsWith("_N_T_"); +}