Skip to content
Merged
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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