Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Vercel Data Cache - Middleware Adapter #3

Closed
wants to merge 2 commits into from
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
11 changes: 11 additions & 0 deletions core/app/api/temp-cache-headers/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { credentialsFromFetch } from '~/lib/vdc/credentials-from-fetch';

export async function GET(request: Request) {
const data = await credentialsFromFetch(request.headers);

return new Response(JSON.stringify(data), {
headers: {
'Content-Type': 'application/json',
},
});
}
42 changes: 42 additions & 0 deletions core/lib/kv/adapters/vercel-data-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { NextRequest } from 'next/server';

import { getComputeCache } from '~/lib/vdc';

import { KvAdapter, SetCommandOptions } from '../types';

export class VercelDataCacheAdapter implements KvAdapter {
private request?: NextRequest;

setRequest(request: NextRequest) {
this.request = request;
}

async mget<Data>(...keys: string[]) {
const computeCache = await getComputeCache<Data>(this.request);

const values = await Promise.all(
keys.map(async (key) => {
const value = await computeCache.get(key);

return value ?? null;
}),
);

return values;
}

async set<Data>(key: string, value: Data, opts?: SetCommandOptions) {
const computeCache = await getComputeCache<Data>(this.request);

// expiryTime is in milliseconds, so we need to convert it to seconds
const revalidate = opts?.expiryTime ? Number(opts.expiryTime) / 1000 : 0;

const response = await computeCache.set(key, value, { revalidate });

if (response === 'OK') {
return null;
}

return response;
}
}
37 changes: 32 additions & 5 deletions core/lib/kv/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { NextRequest } from 'next/server';

import { MemoryKvAdapter } from './adapters/memory';
import { KvAdapter, SetCommandOptions } from './types';

Expand All @@ -7,14 +9,23 @@ interface Config {

const memoryKv = new MemoryKvAdapter();

class KV<Adapter extends KvAdapter> implements KvAdapter {
export class KV<Adapter extends KvAdapter> implements KvAdapter {
private kv?: Adapter;
private memoryKv = memoryKv;

constructor(
private createAdapter: () => Promise<Adapter>,
private config: Config = {},
) {}
private request?: NextRequest,
) {
this.request = request;
}

setRequest(request: NextRequest) {
this.request = request;

return this;
}

async get<Data>(key: string) {
const [value] = await this.mget<Data>(key);
Expand Down Expand Up @@ -70,6 +81,10 @@ class KV<Adapter extends KvAdapter> implements KvAdapter {
this.kv = await this.createAdapter();
}

if (this.kv.setRequest && this.request) {
this.kv.setRequest(this.request);
}

return this.kv;
}

Expand All @@ -94,6 +109,12 @@ async function createKVAdapter() {
return new VercelKvAdapter();
}

if (process.env.ENABLE_VERCEL_DATA_CACHE_MIDDLEWARE === 'true') {
const { VercelDataCacheAdapter } = await import('./adapters/vercel-data-cache');

return new VercelDataCacheAdapter();
}

if (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) {
const { UpstashKvAdapter } = await import('./adapters/upstash');

Expand All @@ -103,10 +124,16 @@ async function createKVAdapter() {
return new MemoryKvAdapter();
}

const adapterInstance = new KV(createKVAdapter, {
const loggerConfig: Config = {
logger:
(process.env.NODE_ENV !== 'production' && process.env.KV_LOGGER !== 'false') ||
process.env.KV_LOGGER === 'true',
});
};

const adapterInstance = new KV(createKVAdapter, loggerConfig);

const createKV = (request: NextRequest) => {
return new KV(createKVAdapter, loggerConfig, request);
};

export { adapterInstance as kv };
export { adapterInstance as kv, createKV };
3 changes: 3 additions & 0 deletions core/lib/kv/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { NextRequest } from 'next/server';

export type SetCommandOptions = Record<string, unknown>;

export interface KvAdapter {
setRequest?: (request: NextRequest) => void;
mget<Data>(...keys: string[]): Promise<Array<Data | null>>;
set<Data>(key: string, value: Data, opts?: SetCommandOptions): Promise<Data | null>;
}
65 changes: 65 additions & 0 deletions core/lib/vdc/credentials-from-fetch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/* eslint-disable no-async-promise-executor */
/* eslint-disable no-console */
/* eslint-disable @typescript-eslint/no-base-to-string */
/* eslint-disable @typescript-eslint/no-misused-promises */
export async function credentialsFromFetch(headers: Headers) {
return new Promise<Record<string, string>>(async (resolve, reject) => {
if (process.env.NODE_ENV !== 'production') {
resolve({});

return;
}

const host = headers.get('x-vercel-sc-host');

if (!host) {
reject(new Error('Missing x-vercel-sc-host header'));
}

const basepath = headers.get('x-vercel-sc-basepath');
const original = globalThis.fetch;
const sentinelUrl = `https://vercel.com/robots.txt?id=${Math.random()}`;

globalThis.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
if (!input.toString().startsWith(`https://${host}/`)) {
return original(input, init);
}

const h = new Headers(init?.headers);
const url = h.get('x-vercel-cache-item-name');

if (url !== sentinelUrl) {
return original(input, init);
}

console.log('h', input, url, h);

const authorization = h.get('authorization');

if (!authorization) {
reject(new Error('Missing cache authorization header'));
}

resolve({
'x-vercel-sc-headers': JSON.stringify({
authorization: h.get('authorization'),
}),
'x-vercel-sc-host': host || '',
'x-vercel-sc-basepath': basepath || '',
});
globalThis.fetch = original;

return new Response(JSON.stringify({}), {
status: 510,
});
};

try {
await fetch(sentinelUrl, {
cache: 'force-cache',
});
} catch (e) {
console.info(e);
}
});
}
99 changes: 99 additions & 0 deletions core/lib/vdc/in-memory-cache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
/* eslint-disable @typescript-eslint/no-shadow */
/* eslint-disable @typescript-eslint/no-use-before-define */
/* eslint-disable @typescript-eslint/no-unnecessary-condition */
/* eslint-disable max-classes-per-file */
/* eslint-disable @typescript-eslint/no-empty-function */
/* eslint-disable no-restricted-syntax */
/* eslint-disable @typescript-eslint/consistent-type-assertions */
/* eslint-disable @typescript-eslint/require-await */
import { CacheHandler, CacheHandlerValue, IncrementalCacheValue, Revalidate } from './types';

let instance: InMemoryCacheHandlerInternal;

interface SetContext {
revalidate?: Revalidate;
fetchCache?: boolean;
fetchUrl?: string;
fetchIdx?: number;
tags?: string[];
isRoutePPREnabled?: boolean;
isFallback?: boolean;
}

export class InMemoryCacheHandler implements CacheHandler {
constructor() {
instance = instance ?? new InMemoryCacheHandlerInternal();
}
get(key: string) {
return instance.get(key);
}
set(key: string, data: IncrementalCacheValue | null, ctx: SetContext) {
return instance.set(key, data, ctx);
}
revalidateTag(tags: string | string[]) {
return instance.revalidateTag(tags);
}
resetRequestCache() {
instance.resetRequestCache();
}
}

class InMemoryCacheHandlerInternal implements CacheHandler {
readonly cache: Map<
string,
{
value: string;
lastModified: number;
tags: string[];
revalidate: number | undefined;
}
>;

constructor() {
this.cache = new Map();
}

async get(key: string): Promise<CacheHandlerValue | null> {
const content = this.cache.get(key);

if (content?.revalidate && content.lastModified + content.revalidate * 1000 < Date.now()) {
this.cache.delete(key);

return null;
}

if (content) {
return {
value: JSON.parse(content.value) as IncrementalCacheValue | null,
lastModified: content.lastModified,
age: Date.now() - content.lastModified,
};
}

return null;
}

async set(key: string, data: IncrementalCacheValue | null, ctx: SetContext) {
// This could be stored anywhere, like durable storage
this.cache.set(key, {
value: JSON.stringify(data),
lastModified: Date.now(),
tags: ctx.tags || [],
revalidate: ctx.revalidate ? ctx.revalidate : undefined,
});
}

async revalidateTag(tag: string | string[]) {
const tags = [tag].flat();

// Iterate over all entries in the cache
for (const [key, value] of this.cache) {
// If the value's tags include the specified tag, delete this entry
if (value.tags.some((tag: string) => tags.includes(tag))) {
this.cache.delete(key);
}
}
}

resetRequestCache() {}
}
Loading
Loading