diff --git a/.changeset/purple-penguins-hide.md b/.changeset/purple-penguins-hide.md new file mode 100644 index 00000000..80d1c2dc --- /dev/null +++ b/.changeset/purple-penguins-hide.md @@ -0,0 +1,5 @@ +--- +"@opennextjs/cloudflare": patch +--- + +feat: add a sharded SQLite Durable object implementation for the tag cache diff --git a/examples/e2e/app-router/open-next.config.ts b/examples/e2e/app-router/open-next.config.ts index fe53a284..c442f8fc 100644 --- a/examples/e2e/app-router/open-next.config.ts +++ b/examples/e2e/app-router/open-next.config.ts @@ -2,9 +2,10 @@ import { defineCloudflareConfig } from "@opennextjs/cloudflare"; import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache"; import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache"; import doQueue from "@opennextjs/cloudflare/durable-queue"; +import shardedTagCache from "@opennextjs/cloudflare/do-sharded-tag-cache"; export default defineCloudflareConfig({ incrementalCache: kvIncrementalCache, - tagCache: d1TagCache, + tagCache: shardedTagCache({ numberOfShards: 12, regionalCache: true }), queue: doQueue, }); diff --git a/examples/e2e/app-router/wrangler.jsonc b/examples/e2e/app-router/wrangler.jsonc index 0884846f..abb7ff27 100644 --- a/examples/e2e/app-router/wrangler.jsonc +++ b/examples/e2e/app-router/wrangler.jsonc @@ -13,13 +13,17 @@ { "name": "NEXT_CACHE_REVALIDATION_DURABLE_OBJECT", "class_name": "DurableObjectQueueHandler" + }, + { + "name": "NEXT_CACHE_D1_SHARDED", + "class_name": "DOShardedTagCache" } ] }, "migrations": [ { "tag": "v1", - "new_sqlite_classes": ["DurableObjectQueueHandler"] + "new_sqlite_classes": ["DurableObjectQueueHandler", "DOShardedTagCache"] } ], "kv_namespaces": [ diff --git a/packages/cloudflare/package.json b/packages/cloudflare/package.json index 615addcf..b3b3a833 100644 --- a/packages/cloudflare/package.json +++ b/packages/cloudflare/package.json @@ -73,7 +73,7 @@ "dependencies": { "@ast-grep/napi": "^0.36.1", "@dotenvx/dotenvx": "catalog:", - "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@773", + "@opennextjs/aws": "https://pkg.pr.new/@opennextjs/aws@778", "enquirer": "^2.4.1", "glob": "catalog:", "yaml": "^2.7.0" diff --git a/packages/cloudflare/src/api/cloudflare-context.ts b/packages/cloudflare/src/api/cloudflare-context.ts index 506cd795..b95e16fc 100644 --- a/packages/cloudflare/src/api/cloudflare-context.ts +++ b/packages/cloudflare/src/api/cloudflare-context.ts @@ -1,6 +1,7 @@ import type { Context, RunningCodeOptions } from "node:vm"; import type { DurableObjectQueueHandler } from "./durable-objects/queue"; +import { DOShardedTagCache } from "./durable-objects/sharded-tag-cache"; declare global { interface CloudflareEnv { @@ -16,6 +17,9 @@ declare global { NEXT_CACHE_REVALIDATION_WORKER?: Service; // Durable Object namespace to use for the durable object queue handler NEXT_CACHE_REVALIDATION_DURABLE_OBJECT?: DurableObjectNamespace; + // Durables object namespace to use for the sharded tag cache + NEXT_CACHE_D1_SHARDED?: DurableObjectNamespace; + // Asset binding ASSETS?: Fetcher; diff --git a/packages/cloudflare/src/api/do-sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/do-sharded-tag-cache.spec.ts new file mode 100644 index 00000000..c7572787 --- /dev/null +++ b/packages/cloudflare/src/api/do-sharded-tag-cache.spec.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import doShardedTagCache from "./do-sharded-tag-cache"; + +const hasBeenRevalidatedMock = vi.fn(); +const writeTagsMock = vi.fn(); +const idFromNameMock = vi.fn(); +const getMock = vi + .fn() + .mockReturnValue({ hasBeenRevalidated: hasBeenRevalidatedMock, writeTags: writeTagsMock }); +const waitUntilMock = vi.fn().mockImplementation(async (fn) => fn()); +vi.mock("./cloudflare-context", () => ({ + getCloudflareContext: () => ({ + env: { NEXT_CACHE_D1_SHARDED: { idFromName: idFromNameMock, get: getMock } }, + ctx: { waitUntil: waitUntilMock }, + }), +})); + +describe("DOShardedTagCache", () => { + afterEach(() => vi.clearAllMocks()); + + describe("generateShardId", () => { + it("should generate a shardId", () => { + const cache = doShardedTagCache(); + const expectedResult = new Map(); + expectedResult.set("shard-1", ["tag1"]); + expectedResult.set("shard-2", ["tag2"]); + expect(cache.generateShards(["tag1", "tag2"])).toEqual(expectedResult); + }); + + it("should group tags by shard", () => { + const cache = doShardedTagCache(); + const expectedResult = new Map(); + expectedResult.set("shard-1", ["tag1", "tag6"]); + expect(cache.generateShards(["tag1", "tag6"])).toEqual(expectedResult); + }); + + it("should generate the same shardId for the same tag", () => { + const cache = doShardedTagCache(); + const firstResult = cache.generateShards(["tag1"]); + const secondResult = cache.generateShards(["tag1", "tag3", "tag4"]); + expect(firstResult.get("shard-1")).toEqual(secondResult.get("shard-1")); + }); + }); + + describe("hasBeenRevalidated", () => { + beforeEach(() => { + globalThis.openNextConfig = { + dangerous: { disableTagCache: false }, + }; + }); + it("should return false if the cache is disabled", async () => { + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + }; + const cache = doShardedTagCache(); + const result = await cache.hasBeenRevalidated(["tag1"]); + expect(result).toBe(false); + expect(idFromNameMock).not.toHaveBeenCalled(); + }); + + it("should return false if stub return false", async () => { + const cache = doShardedTagCache(); + cache.getFromRegionalCache = vi.fn(); + hasBeenRevalidatedMock.mockImplementationOnce(() => false); + const result = await cache.hasBeenRevalidated(["tag1"], 123456); + expect(cache.getFromRegionalCache).toHaveBeenCalled(); + expect(idFromNameMock).toHaveBeenCalled(); + expect(hasBeenRevalidatedMock).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("should return true if stub return true", async () => { + const cache = doShardedTagCache(); + cache.getFromRegionalCache = vi.fn(); + hasBeenRevalidatedMock.mockImplementationOnce(() => true); + const result = await cache.hasBeenRevalidated(["tag1"], 123456); + expect(cache.getFromRegionalCache).toHaveBeenCalled(); + expect(idFromNameMock).toHaveBeenCalled(); + expect(hasBeenRevalidatedMock).toHaveBeenCalledWith(["tag1"], 123456); + expect(result).toBe(true); + }); + + it("should return false if it throws", async () => { + const cache = doShardedTagCache(); + cache.getFromRegionalCache = vi.fn(); + hasBeenRevalidatedMock.mockImplementationOnce(() => { + throw new Error("error"); + }); + const result = await cache.hasBeenRevalidated(["tag1"], 123456); + expect(cache.getFromRegionalCache).toHaveBeenCalled(); + expect(idFromNameMock).toHaveBeenCalled(); + expect(hasBeenRevalidatedMock).toHaveBeenCalled(); + expect(result).toBe(false); + }); + + it("Should return from the cache if it was found there", async () => { + const cache = doShardedTagCache(); + cache.getFromRegionalCache = vi.fn().mockReturnValueOnce(new Response("true")); + const result = await cache.hasBeenRevalidated(["tag1"], 123456); + expect(result).toBe(true); + expect(idFromNameMock).not.toHaveBeenCalled(); + expect(hasBeenRevalidatedMock).not.toHaveBeenCalled(); + }); + + it("should try to put the result in the cache if it was not revalidated", async () => { + const cache = doShardedTagCache(); + cache.getFromRegionalCache = vi.fn(); + cache.putToRegionalCache = vi.fn(); + hasBeenRevalidatedMock.mockImplementationOnce(() => false); + const result = await cache.hasBeenRevalidated(["tag1"], 123456); + expect(result).toBe(false); + + expect(waitUntilMock).toHaveBeenCalled(); + expect(cache.putToRegionalCache).toHaveBeenCalled(); + }); + + it("should call all the shards", async () => { + const cache = doShardedTagCache(); + cache.getFromRegionalCache = vi.fn(); + const result = await cache.hasBeenRevalidated(["tag1", "tag2"], 123456); + expect(result).toBe(false); + expect(idFromNameMock).toHaveBeenCalledTimes(2); + expect(hasBeenRevalidatedMock).toHaveBeenCalledTimes(2); + }); + }); + + describe("writeTags", () => { + beforeEach(() => { + globalThis.openNextConfig = { + dangerous: { disableTagCache: false }, + }; + }); + it("should return early if the cache is disabled", async () => { + globalThis.openNextConfig = { + dangerous: { disableTagCache: true }, + }; + const cache = doShardedTagCache(); + await cache.writeTags(["tag1"]); + expect(idFromNameMock).not.toHaveBeenCalled(); + expect(writeTagsMock).not.toHaveBeenCalled(); + }); + + it("should write the tags to the cache", async () => { + const cache = doShardedTagCache(); + await cache.writeTags(["tag1"]); + expect(idFromNameMock).toHaveBeenCalled(); + expect(writeTagsMock).toHaveBeenCalled(); + expect(writeTagsMock).toHaveBeenCalledWith(["tag1"]); + }); + + it("should write the tags to the cache for multiple shards", async () => { + const cache = doShardedTagCache(); + await cache.writeTags(["tag1", "tag2"]); + expect(idFromNameMock).toHaveBeenCalledTimes(2); + expect(writeTagsMock).toHaveBeenCalledTimes(2); + expect(writeTagsMock).toHaveBeenCalledWith(["tag1"]); + expect(writeTagsMock).toHaveBeenCalledWith(["tag2"]); + }); + + it("should call deleteRegionalCache", async () => { + const cache = doShardedTagCache(); + cache.deleteRegionalCache = vi.fn(); + await cache.writeTags(["tag1"]); + expect(cache.deleteRegionalCache).toHaveBeenCalled(); + expect(cache.deleteRegionalCache).toHaveBeenCalledWith("shard-1", ["tag1"]); + }); + }); + + describe("getCacheInstance", () => { + it("should return undefined by default", async () => { + const cache = doShardedTagCache(); + expect(await cache.getCacheInstance()).toBeUndefined(); + }); + + it("should try to return the cache instance if regional cache is enabled", async () => { + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue("cache"), + }; + const cache = doShardedTagCache({ numberOfShards: 4, regionalCache: true }); + expect(cache.localCache).toBeUndefined(); + expect(await cache.getCacheInstance()).toBe("cache"); + expect(cache.localCache).toBe("cache"); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + }); + + describe("getFromRegionalCache", () => { + it("should return undefined if regional cache is disabled", async () => { + const cache = doShardedTagCache(); + expect(await cache.getFromRegionalCache("shard-1", ["tag1"])).toBeUndefined(); + }); + + it("should call .match on the cache", async () => { + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = { + open: vi.fn().mockResolvedValue({ + match: vi.fn().mockResolvedValue("response"), + }), + }; + const cache = doShardedTagCache({ numberOfShards: 4, regionalCache: true }); + expect(await cache.getFromRegionalCache("shard-1", ["tag1"])).toBe("response"); + // @ts-expect-error - Defined on cloudfare context + globalThis.caches = undefined; + }); + }); +}); diff --git a/packages/cloudflare/src/api/do-sharded-tag-cache.ts b/packages/cloudflare/src/api/do-sharded-tag-cache.ts new file mode 100644 index 00000000..cfb6b59a --- /dev/null +++ b/packages/cloudflare/src/api/do-sharded-tag-cache.ts @@ -0,0 +1,194 @@ +import { debug, error } from "@opennextjs/aws/adapters/logger.js"; +import { generateShardId } from "@opennextjs/aws/core/routing/queue.js"; +import type { OpenNextConfig } from "@opennextjs/aws/types/open-next"; +import type { NextModeTagCache } from "@opennextjs/aws/types/overrides.js"; +import { IgnorableError } from "@opennextjs/aws/utils/error.js"; + +import { getCloudflareContext } from "./cloudflare-context"; + +interface ShardedD1TagCacheOptions { + /** + * The number of shards that will be used. + * 1 shards means 1 durable object instance. + * The number of requests made to Durable Objects will scale linearly with the number of shards. + * For example, a request involving 5 tags may access between 1 and 5 shards, with the upper limit being the lesser of the number of tags or the number of shards + * @default 4 + */ + numberOfShards: number; + /** + * Whether to enable a regional cache on a per-shard basis + * Because of the way tags are implemented in Next.js, some shards will have more requests than others. For these cases, it is recommended to enable the regional cache. + * @default false + */ + regionalCache?: boolean; + /** + * The TTL for the regional cache in seconds + * Increasing this value will reduce the number of requests to the Durable Object, but it could make `revalidateTags`/`revalidatePath` call being longer to take effect + * @default 5 + */ + regionalCacheTtlSec?: number; +} +class ShardedD1TagCache implements NextModeTagCache { + readonly mode = "nextMode" as const; + readonly name = "sharded-d1-tag-cache"; + localCache?: Cache; + + constructor(private opts: ShardedD1TagCacheOptions = { numberOfShards: 4 }) {} + + private getDurableObjectStub(shardId: string) { + const durableObject = getCloudflareContext().env.NEXT_CACHE_D1_SHARDED; + if (!durableObject) throw new IgnorableError("No durable object binding for cache revalidation"); + + const id = durableObject.idFromName(shardId); + return durableObject.get(id); + } + + /** + * Same tags are guaranteed to be in the same shard + * @param tags + * @returns A map of shardId to tags + */ + generateShards(tags: string[]) { + // For each tag, we generate a message group id + const messageGroupIds = tags.map((tag) => ({ + shardId: generateShardId(tag, this.opts.numberOfShards, "shard"), + tag, + })); + // We group the tags by shard + const shards = new Map(); + for (const { shardId, tag } of messageGroupIds) { + const tags = shards.get(shardId) ?? []; + tags.push(tag); + shards.set(shardId, tags); + } + return shards; + } + + private async getConfig() { + const cfEnv = getCloudflareContext().env; + const db = cfEnv.NEXT_CACHE_D1_SHARDED; + + if (!db) debug("No Durable object found"); + const isDisabled = !!(globalThis as unknown as { openNextConfig: OpenNextConfig }).openNextConfig + .dangerous?.disableTagCache; + + if (!db || isDisabled) { + return { isDisabled: true as const }; + } + + return { + isDisabled: false as const, + db, + }; + } + + /** + * This function checks if the tags have been revalidated + * It is never supposed to throw and in case of error, it will return false + * @param tags + * @param lastModified default to `Date.now()` + * @returns + */ + async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { + const { isDisabled } = await this.getConfig(); + if (isDisabled) return false; + try { + const shards = this.generateShards(tags); + // We then create a new durable object for each shard + const shardsResult = await Promise.all( + Array.from(shards.entries()).map(async ([shardId, shardedTags]) => { + const cachedValue = await this.getFromRegionalCache(shardId, shardedTags); + if (cachedValue) { + return (await cachedValue.text()) === "true"; + } + const stub = this.getDurableObjectStub(shardId); + const _hasBeenRevalidated = await stub.hasBeenRevalidated(shardedTags, lastModified); + //TODO: Do we want to cache the result if it has been revalidated ? + // If we do so, we risk causing cache MISS even though it has been revalidated elsewhere + // On the other hand revalidating a tag that is used in a lot of places will cause a lot of requests + if (!_hasBeenRevalidated) { + getCloudflareContext().ctx.waitUntil( + this.putToRegionalCache(shardId, shardedTags, _hasBeenRevalidated) + ); + } + return _hasBeenRevalidated; + }) + ); + return shardsResult.some((result) => result); + } catch (e) { + error("Error while checking revalidation", e); + return false; + } + } + + /** + * This function writes the tags to the cache + * Due to the way shards and regional cache are implemented, the regional cache may not be properly invalidated + * @param tags + * @returns + */ + async writeTags(tags: string[]): Promise { + const { isDisabled } = await this.getConfig(); + if (isDisabled) return; + const shards = this.generateShards(tags); + // We then create a new durable object for each shard + await Promise.all( + Array.from(shards.entries()).map(async ([shardId, shardedTags]) => { + const stub = this.getDurableObjectStub(shardId); + await stub.writeTags(shardedTags); + // Depending on the shards and the tags, deleting from the regional cache will not work for every tag + await this.deleteRegionalCache(shardId, shardedTags); + }) + ); + } + + // Cache API + async getCacheInstance() { + if (!this.localCache && this.opts.regionalCache) { + this.localCache = await caches.open("sharded-d1-tag-cache"); + } + return this.localCache; + } + + async getCacheKey(shardId: string, tags: string[]) { + return new Request( + new URL(`shard/${shardId}?tags=${encodeURIComponent(tags.join(";"))}`, "http://local.cache") + ); + } + + async getFromRegionalCache(shardId: string, tags: string[]) { + try { + if (!this.opts.regionalCache) return; + const cache = await this.getCacheInstance(); + if (!cache) return; + const key = await this.getCacheKey(shardId, tags); + return cache.match(key); + } catch (e) { + error("Error while fetching from regional cache", e); + return; + } + } + + async putToRegionalCache(shardId: string, tags: string[], hasBeenRevalidated: boolean) { + if (!this.opts.regionalCache) return; + const cache = await this.getCacheInstance(); + if (!cache) return; + const key = await this.getCacheKey(shardId, tags); + await cache.put( + key, + new Response(`${hasBeenRevalidated}`, { + headers: { "cache-control": `max-age=${this.opts.regionalCacheTtlSec ?? 5}` }, + }) + ); + } + + async deleteRegionalCache(shardId: string, tags: string[]) { + if (!this.opts.regionalCache) return; + const cache = await this.getCacheInstance(); + if (!cache) return; + const key = await this.getCacheKey(shardId, tags); + await cache.delete(key); + } +} + +export default (opts?: ShardedD1TagCacheOptions) => new ShardedD1TagCache(opts); diff --git a/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts new file mode 100644 index 00000000..f5ff7c9c --- /dev/null +++ b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DOShardedTagCache } from "./sharded-tag-cache"; + +vi.mock("cloudflare:workers", () => ({ + DurableObject: class { + ctx: DurableObjectState; + env: CloudflareEnv; + constructor(ctx: DurableObjectState, env: CloudflareEnv) { + this.ctx = ctx; + this.env = env; + } + }, +})); + +const createDOShardedTagCache = () => { + const mockState = { + waitUntil: vi.fn(), + blockConcurrencyWhile: vi.fn().mockImplementation(async (fn) => fn()), + storage: { + setAlarm: vi.fn(), + getAlarm: vi.fn(), + sql: { + exec: vi.fn().mockImplementation(() => ({ + one: vi.fn(), + })), + }, + }, + }; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new DOShardedTagCache(mockState as any, {}); +}; + +describe("DOShardedTagCache class", () => { + it("should block concurrency while creating the table", async () => { + const cache = createDOShardedTagCache(); + // @ts-expect-error - testing private method + expect(cache.ctx.blockConcurrencyWhile).toHaveBeenCalled(); + expect(cache.sql.exec).toHaveBeenCalledWith( + `CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER)` + ); + }); +}); diff --git a/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts new file mode 100644 index 00000000..eae900c7 --- /dev/null +++ b/packages/cloudflare/src/api/durable-objects/sharded-tag-cache.ts @@ -0,0 +1,36 @@ +import { DurableObject } from "cloudflare:workers"; + +export class DOShardedTagCache extends DurableObject { + sql: SqlStorage; + + constructor(state: DurableObjectState, env: CloudflareEnv) { + super(state, env); + this.sql = state.storage.sql; + state.blockConcurrencyWhile(async () => { + this.sql.exec(`CREATE TABLE IF NOT EXISTS revalidations (tag TEXT PRIMARY KEY, revalidatedAt INTEGER)`); + }); + } + + async hasBeenRevalidated(tags: string[], lastModified?: number): Promise { + const result = this.sql + .exec<{ + cnt: number; + }>( + `SELECT COUNT(*) as cnt FROM revalidations WHERE tag IN (${tags.map(() => "?").join(", ")}) AND revalidatedAt > ?`, + ...tags, + lastModified ?? Date.now() + ) + .one(); + return result.cnt > 0; + } + + async writeTags(tags: string[]): Promise { + tags.forEach((tag) => { + this.sql.exec( + `INSERT OR REPLACE INTO revalidations (tag, revalidatedAt) VALUES (?, ?)`, + tag, + Date.now() + ); + }); + } +} diff --git a/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts b/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts index b129ec3a..7a9b13ad 100644 --- a/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts +++ b/packages/cloudflare/src/cli/build/open-next/compileDurableObjects.ts @@ -6,7 +6,10 @@ import { type BuildOptions, esbuildSync, getPackagePath } from "@opennextjs/aws/ export function compileDurableObjects(buildOpts: BuildOptions) { const _require = createRequire(import.meta.url); - const entryPoints = [_require.resolve("@opennextjs/cloudflare/durable-objects/queue")]; + const entryPoints = [ + _require.resolve("@opennextjs/cloudflare/durable-objects/queue"), + _require.resolve("@opennextjs/cloudflare/durable-objects/sharded-tag-cache"), + ]; const { outputDir } = buildOpts; diff --git a/packages/cloudflare/src/cli/templates/worker.ts b/packages/cloudflare/src/cli/templates/worker.ts index 585db93e..a51165cb 100644 --- a/packages/cloudflare/src/cli/templates/worker.ts +++ b/packages/cloudflare/src/cli/templates/worker.ts @@ -19,6 +19,8 @@ Object.defineProperty(globalThis, Symbol.for("__cloudflare-context__"), { //@ts-expect-error: Will be resolved by wrangler build export { DurableObjectQueueHandler } from "./.build/durable-objects/queue.js"; +//@ts-expect-error: Will be resolved by wrangler build +export { DOShardedTagCache } from "./.build/durable-objects/sharded-tag-cache.js"; // Populate process.env on the first request let processEnvPopulated = false; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7b31632a..30af6da2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -898,8 +898,8 @@ importers: specifier: 'catalog:' version: 1.31.0 '@opennextjs/aws': - specifier: https://pkg.pr.new/@opennextjs/aws@773 - version: https://pkg.pr.new/@opennextjs/aws@773 + specifier: https://pkg.pr.new/@opennextjs/aws@778 + version: https://pkg.pr.new/@opennextjs/aws@778 enquirer: specifier: ^2.4.1 version: 2.4.1 @@ -3809,8 +3809,8 @@ packages: resolution: {integrity: sha512-8H4FeoxeLb24N2iWO9H3Tp8ln16YG1V3c+gIzwi+5lc+PRie/5TEjNOd1x1LLc/O9s0P2i4JjEQiDk8MFBI4TA==} hasBin: true - '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@773': - resolution: {tarball: https://pkg.pr.new/@opennextjs/aws@773} + '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@778': + resolution: {tarball: https://pkg.pr.new/@opennextjs/aws@778} version: 3.5.2 hasBin: true @@ -13044,7 +13044,7 @@ snapshots: - aws-crt - supports-color - '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@773': + '@opennextjs/aws@https://pkg.pr.new/@opennextjs/aws@778': dependencies: '@aws-sdk/client-cloudfront': 3.398.0 '@aws-sdk/client-dynamodb': 3.699.0