Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tame-icons-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": patch
---

feat: auto-populating d1 cache data
5 changes: 5 additions & 0 deletions .changeset/weak-houses-divide.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@opennextjs/cloudflare": patch
---

feat: r2 adapter for the incremental cache
9 changes: 7 additions & 2 deletions examples/e2e/app-router/open-next.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { defineCloudflareConfig } from "@opennextjs/cloudflare";
import d1TagCache from "@opennextjs/cloudflare/d1-tag-cache";
import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache";
// import kvIncrementalCache from "@opennextjs/cloudflare/kv-cache";
import r2IncrementalCache from "@opennextjs/cloudflare/r2-incremental-cache";
import memoryQueue from "@opennextjs/cloudflare/memory-queue";
import { withRegionalCache } from "@opennextjs/cloudflare/regional-cache";

export default defineCloudflareConfig({
incrementalCache: kvIncrementalCache,
incrementalCache: withRegionalCache(r2IncrementalCache, {
mode: "long-lived",
shouldLazilyUpdateOnCacheHit: true,
}),
tagCache: d1TagCache,
queue: memoryQueue,
});
3 changes: 1 addition & 2 deletions examples/e2e/app-router/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@
"lint": "next lint",
"clean": "rm -rf .turbo node_modules .next .open-next",
"d1:clean": "wrangler d1 execute NEXT_CACHE_D1 --command \"DROP TABLE IF EXISTS tags; DROP TABLE IF EXISTS revalidations\"",
"d1:setup": "wrangler d1 execute NEXT_CACHE_D1 --file .open-next/cloudflare/cache-assets-manifest.sql",
"build:worker": "pnpm opennextjs-cloudflare && pnpm d1:clean && pnpm d1:setup",
"build:worker": "pnpm d1:clean && pnpm opennextjs-cloudflare --populateCache=local",
"preview": "pnpm build:worker && pnpm wrangler dev",
"e2e": "playwright test -c e2e/playwright.config.ts"
},
Expand Down
7 changes: 7 additions & 0 deletions examples/e2e/app-router/wrangler.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,12 @@
"binding": "NEXT_CACHE_REVALIDATION_WORKER",
"service": "app-router"
}
],
"r2_buckets": [
{
"binding": "NEXT_CACHE_R2_BUCKET",
"bucket_name": "NEXT_CACHE_R2_BUCKET",
"preview_bucket_name": "NEXT_CACHE_R2_BUCKET"
}
]
}
4 changes: 4 additions & 0 deletions packages/cloudflare/src/api/cloudflare-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ declare global {
NEXT_CACHE_D1_TAGS_TABLE?: string;
NEXT_CACHE_D1_REVALIDATIONS_TABLE?: string;
NEXT_CACHE_REVALIDATION_WORKER?: Service;
// R2 bucket used for the incremental cache
NEXT_CACHE_R2_BUCKET?: R2Bucket;
// Prefix used for the R2 incremental cache bucket
NEXT_CACHE_R2_PREFIX?: string;
ASSETS?: Fetcher;
}
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cloudflare/src/api/internal/incremental-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { CacheValue } from "@opennextjs/aws/types/overrides.js";

export type IncrementalCacheEntry<IsFetch extends boolean> = {
value: CacheValue<IsFetch>;
lastModified: number;
};
80 changes: 80 additions & 0 deletions packages/cloudflare/src/api/r2-incremental-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
import type { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";
import { IgnorableError } from "@opennextjs/aws/utils/error.js";

import { getCloudflareContext } from "./cloudflare-context.js";

/**
* An instance of the Incremental Cache that uses an R2 bucket (`NEXT_CACHE_R2_BUCKET`) as it's
* underlying data store.
*
* The directory that the cache entries are stored in can be confused with the `NEXT_CACHE_R2_PREFIX`
* environment variable, and defaults to `incremental-cache`.
*
* The cache uses an instance of the Cache API (`incremental-cache`) to store a local version of the
* R2 cache entry to enable fast retrieval, with the cache being updated from R2 in the background.
*/
class R2IncrementalCache implements IncrementalCache {
readonly name = "r2-incremental-cache";

async get<IsFetch extends boolean = false>(
key: string,
isFetch?: IsFetch
): Promise<WithLastModified<CacheValue<IsFetch>> | null> {
const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET;
if (!r2) throw new IgnorableError("No R2 bucket");

debug(`Get ${key}`);

try {
const r2Object = await r2.get(this.getR2Key(key, isFetch));
if (!r2Object) return null;

return {
value: await r2Object.json(),
lastModified: r2Object.uploaded.getTime(),
};
} catch (e) {
error("Failed to get from cache", e);
return null;
}
}

async set<IsFetch extends boolean = false>(
key: string,
value: CacheValue<IsFetch>,
isFetch?: IsFetch
): Promise<void> {
const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET;
if (!r2) throw new IgnorableError("No R2 bucket");

debug(`Set ${key}`);

try {
await r2.put(this.getR2Key(key, isFetch), JSON.stringify(value));
} catch (e) {
error("Failed to set to cache", e);
}
}

async delete(key: string): Promise<void> {
const r2 = getCloudflareContext().env.NEXT_CACHE_R2_BUCKET;
if (!r2) throw new IgnorableError("No R2 bucket");

debug(`Delete ${key}`);

try {
await r2.delete(this.getR2Key(key));
} catch (e) {
error("Failed to delete from cache", e);
}
}

protected getR2Key(key: string, isFetch?: boolean): string {
const directory = getCloudflareContext().env.NEXT_CACHE_R2_PREFIX ?? "incremental-cache";

return `${directory}/${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`;
}
}

export default new R2IncrementalCache();
167 changes: 167 additions & 0 deletions packages/cloudflare/src/api/regional-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
import { debug, error } from "@opennextjs/aws/adapters/logger.js";
import { CacheValue, IncrementalCache, WithLastModified } from "@opennextjs/aws/types/overrides.js";

import { getCloudflareContext } from "./cloudflare-context.js";
import { IncrementalCacheEntry } from "./internal/incremental-cache.js";

const ONE_YEAR_IN_SECONDS = 31536000;
const ONE_MINUTE_IN_SECONDS = 60;

type Options = {
/**
* The mode to use for the regional cache.
*
* - `short-lived`: Re-use a cache entry for up to a minute after it has been retrieved.
* - `long-lived`: Re-use a cache entry until it is revalidated.
*/
mode: "short-lived" | "long-lived";
/**
* Whether the regional cache entry should be updated in the background or not when it experiences
* a cache hit.
*
* Defaults to `false` for the `short-lived` mode, and `true` for the `long-lived` mode.
*/
shouldLazilyUpdateOnCacheHit?: boolean;
};

class RegionalCache implements IncrementalCache {
public name: string;

protected localCache: Cache | undefined;

constructor(
private store: IncrementalCache,
private opts: Options
) {
this.name = this.store.name;

this.opts.shouldLazilyUpdateOnCacheHit ??= this.opts.mode === "long-lived";
}

async get<IsFetch extends boolean = false>(
key: string,
isFetch?: IsFetch
): Promise<WithLastModified<CacheValue<IsFetch>> | null> {
try {
const cache = await this.getCacheInstance();
const localCacheKey = this.getCacheKey(key, isFetch);

// Check for a cached entry as this will be faster than the store response.
const cachedResponse = await cache.match(localCacheKey);
if (cachedResponse) {
debug("Get - cached response");

// Re-fetch from the store and update the regional cache in the background
if (this.opts.shouldLazilyUpdateOnCacheHit) {
getCloudflareContext().ctx.waitUntil(
this.store.get(key, isFetch).then(async (rawEntry) => {
const { value, lastModified } = rawEntry ?? {};

if (value && typeof lastModified === "number") {
await this.putToCache(localCacheKey, { value, lastModified });
}
})
);
}

return cachedResponse.json();
}

const rawEntry = await this.store.get(key, isFetch);
const { value, lastModified } = rawEntry ?? {};
if (!value || typeof lastModified !== "number") return null;

// Update the locale cache after retrieving from the store.
getCloudflareContext().ctx.waitUntil(this.putToCache(localCacheKey, { value, lastModified }));

return { value, lastModified };
} catch (e) {
error("Failed to get from regional cache", e);
return null;
}
}

async set<IsFetch extends boolean = false>(
key: string,
value: CacheValue<IsFetch>,
isFetch?: IsFetch
): Promise<void> {
try {
await this.store.set(key, value, isFetch);

await this.putToCache(this.getCacheKey(key, isFetch), {
value,
// Note: `Date.now()` returns the time of the last IO rather than the actual time.
// See https://developers.cloudflare.com/workers/reference/security-model/
lastModified: Date.now(),
});
} catch (e) {
error(`Failed to get from regional cache`, e);
}
}

async delete(key: string): Promise<void> {
try {
await this.store.delete(key);

const cache = await this.getCacheInstance();
await cache.delete(this.getCacheKey(key));
} catch (e) {
error("Failed to delete from regional cache", e);
}
}

protected async getCacheInstance(): Promise<Cache> {
if (this.localCache) return this.localCache;

this.localCache = await caches.open("incremental-cache");
return this.localCache;
}

protected getCacheKey(key: string, isFetch?: boolean) {
return new Request(
new URL(
`${process.env.NEXT_BUILD_ID ?? "no-build-id"}/${key}.${isFetch ? "fetch" : "cache"}`,
"http://cache.local"
)
);
}

protected async putToCache(key: Request, entry: IncrementalCacheEntry<boolean>): Promise<void> {
const cache = await this.getCacheInstance();

const age =
this.opts.mode === "short-lived"
? ONE_MINUTE_IN_SECONDS
: entry.value.revalidate || ONE_YEAR_IN_SECONDS;

await cache.put(
key,
new Response(JSON.stringify(entry), {
headers: new Headers({ "cache-control": `max-age=${age}` }),
})
);
}
}

/**
* A regional cache will wrap an incremental cache and provide faster cache lookups for an entry
* when making requests within the region.
*
* The regional cache uses the Cache API.
*
* **WARNING:** If an entry is revalidated in one region, it will trigger an additional revalidation if
* a request is made to another region that has an entry stored in its regional cache.
*
* @param cache - Incremental cache instance.
* @param opts.mode - The mode to use for the regional cache.
* - `short-lived`: Re-use a cache entry for up to a minute after it has been retrieved.
* - `long-lived`: Re-use a cache entry until it is revalidated.
* @param opts.shouldLazilyUpdateOnCacheHit - Whether the regional cache entry should be updated in
* the background or not when it experiences a cache hit.
*
* Defaults to `false` for the `short-lived` mode, and `true` for the `long-lived` mode.
*/
export function withRegionalCache(cache: IncrementalCache, opts: Options) {
return new RegionalCache(cache, opts);
}
Loading