Skip to content

Commit

Permalink
allow setting fetch cache behavior, remove DeepReadOnly (#691)
Browse files Browse the repository at this point in the history
* remove DeepReadOnly

* allow setting fetch cache behaviour

* add explanation of fetch cache option

* add note

* downgrade changeset
  • Loading branch information
dferber90 committed Jun 12, 2024
1 parent d63d2de commit 6a592b5
Show file tree
Hide file tree
Showing 9 changed files with 143 additions and 56 deletions.
5 changes: 5 additions & 0 deletions .changeset/dull-pets-try.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vercel/edge-config": patch
---

remove DeepReadOnly type
5 changes: 5 additions & 0 deletions .changeset/thick-shirts-chew.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@vercel/edge-config": minor
---

allow setting fetch cache behaviour
41 changes: 35 additions & 6 deletions packages/edge-config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,36 @@ setTracerProvider(trace);

More verbose traces can be enabled by setting the `EDGE_CONFIG_TRACE_VERBOSE` environment variable to `true`.

## Caught a Bug?
## Fetch cache

1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device
2. Link the package to the global module directory: `npm link`
3. Within the module you want to test your local development instance of `@vercel/edge-config`, just link it to the dependencies: `npm link @vercel/edge-config`. Instead of the default one from npm, Node.js will now use your clone of `@vercel/edge-config`!
By default the Edge Config SDK will fetch with `no-store`, which triggers dynamic mode in Next.js ([docs](https://nextjs.org/docs/app/api-reference/functions/fetch#optionscache)).

As always, you can run the tests using: `npm test`
To use Edge Config with static pages, pass the `force-cache` option:

```js
import { createClient } from '@vercel/edge-config';

const edgeConfigClient = createClient(process.env.EDGE_CONFIG, {
cache: 'force-cache',
});

// then use the client as usual
edgeConfigClient.get('someKey');
```

**Note** This opts out of dynamic behavior, so the page might display stale values.

## A note for Vite users
## Notes

### Do not mutate return values

Cloning objects in JavaScript can be slow. That's why the Edge Config SDK uses an optimization which can lead to multiple calls reading the same key all receiving a reference to the same value.

For this reason the value read from Edge Config should never be mutated, otherwise they could affect other parts of the code base reading the same key, or a later request reading the same key.

If you need to modify, see the `clone` function described [here](#do-not-mutate-return-values).

### Usage with Vite

`@vercel/edge-config` reads database credentials from the environment variables on `process.env`. In general, `process.env` is automatically populated from your `.env` file during development, which is created when you run `vc env pull`. However, Vite does not expose the `.env` variables on `process.env.`

Expand Down Expand Up @@ -180,3 +201,11 @@ import { createClient } from '@vercel/edge-config';
+ const edgeConfig = createClient(EDGE_CONFIG);
await edgeConfig.get('someKey');
```

## Caught a Bug?

1. [Fork](https://help.github.com/articles/fork-a-repo/) this repository to your own GitHub account and then [clone](https://help.github.com/articles/cloning-a-repository/) it to your local device
2. Link the package to the global module directory: `npm link`
3. Within the module you want to test your local development instance of `@vercel/edge-config`, just link it to the dependencies: `npm link @vercel/edge-config`. Instead of the default one from npm, Node.js will now use your clone of `@vercel/edge-config`!

As always, you can run the tests using: `npm test`
3 changes: 1 addition & 2 deletions packages/edge-config/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,7 @@
"testEnvironment": "node"
},
"dependencies": {
"@vercel/edge-config-fs": "workspace:*",
"ts-essentials": "9.4.1"
"@vercel/edge-config-fs": "workspace:*"
},
"devDependencies": {
"@changesets/cli": "2.27.1",
Expand Down
31 changes: 30 additions & 1 deletion packages/edge-config/src/index.edge.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import fetchMock from 'jest-fetch-mock';
import { version as pkgVersion } from '../package.json';
import { cache } from './utils/fetch-with-cached-response';
import { get, has, digest, getAll } from './index';
import { get, has, digest, getAll, createClient } from './index';

const sdkVersion = typeof pkgVersion === 'string' ? pkgVersion : '';
const baseUrl = 'https://edge-config.vercel.com/ecfg-1';
Expand Down Expand Up @@ -468,3 +468,32 @@ describe('default Edge Config', () => {
});
});
});

describe('createClient', () => {
beforeEach(() => {
fetchMock.resetMocks();
cache.clear();
});

describe('when the request succeeds', () => {
it('should respect the fetch cache option', async () => {
fetchMock.mockResponse(JSON.stringify('awe1'));

const edgeConfig = createClient(process.env.EDGE_CONFIG, {
cache: 'force-cache',
});

await expect(edgeConfig.get('foo')).resolves.toEqual('awe1');

expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(`${baseUrl}/item/foo?version=1`, {
headers: new Headers({
Authorization: 'Bearer token-1',
'x-edge-config-vercel-env': 'test',
'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`,
}),
cache: 'force-cache',
});
});
});
});
31 changes: 31 additions & 0 deletions packages/edge-config/src/index.node.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -571,4 +571,35 @@ describe('createClient', () => {
});
});
});

describe('fetch cache', () => {
it('should respect the fetch cache option', async () => {
fetchMock.mockResponse(JSON.stringify('bar2'));
const edgeConfig = createClient(process.env.EDGE_CONFIG, {
cache: 'force-cache',
});
await expect(edgeConfig.get('foo')).resolves.toEqual('bar2');

// returns undefined as file does not exist
expect(readFile).toHaveBeenCalledTimes(1);
expect(readFile).toHaveBeenCalledWith(
'/opt/edge-config/ecfg-1.json',
'utf-8',
);

// ensure fetch was called with the right options
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(fetchMock).toHaveBeenCalledWith(
'https://edge-config.vercel.com/ecfg-1/item/foo?version=1',
{
cache: 'force-cache',
headers: new Headers({
Authorization: 'Bearer token-1',
'x-edge-config-sdk': `@vercel/edge-config@${sdkVersion}`,
'x-edge-config-vercel-env': 'test',
}),
},
);
});
});
});
61 changes: 34 additions & 27 deletions packages/edge-config/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { readFile } from '@vercel/edge-config-fs';
import type { DeepReadonly, DeepWritable } from 'ts-essentials';
import { name as sdkName, version as sdkVersion } from '../package.json';
import {
assertIsKey,
Expand Down Expand Up @@ -91,13 +90,13 @@ const getFileSystemEdgeConfig = trace(
const getPrivateEdgeConfig = trace(
async function getPrivateEdgeConfig(
connection: Connection,
): Promise<DeepReadonly<EmbeddedEdgeConfig> | null> {
): Promise<EmbeddedEdgeConfig | null> {
const privateEdgeConfig = Reflect.get(
globalThis,
privateEdgeConfigSymbol,
) as
| {
get: (id: string) => Promise<DeepReadonly<EmbeddedEdgeConfig> | null>;
get: (id: string) => Promise<EmbeddedEdgeConfig | null>;
}
| undefined;

Expand All @@ -124,6 +123,7 @@ function createGetInMemoryEdgeConfig(
shouldUseDevelopmentCache: boolean,
connection: Connection,
headers: Record<string, string>,
fetchCache: EdgeConfigClientOptions['cache'],
): () => Promise<EmbeddedEdgeConfig | null> {
// Functions as cache to keep track of the Edge Config.
let embeddedEdgeConfigPromise: Promise<EmbeddedEdgeConfig | null> | null =
Expand All @@ -144,7 +144,7 @@ function createGetInMemoryEdgeConfig(
`${connection.baseUrl}/items?version=${connection.version}`,
{
headers: new Headers(headers),
cache: 'no-store',
cache: fetchCache,
},
).then(async (res) => {
const digest = res.headers.get('x-edge-config-digest');
Expand Down Expand Up @@ -199,7 +199,7 @@ function createGetInMemoryEdgeConfig(
*/
async function getLocalEdgeConfig(
connection: Connection,
): Promise<DeepReadonly<EmbeddedEdgeConfig> | null> {
): Promise<EmbeddedEdgeConfig | null> {
const edgeConfig =
(await getPrivateEdgeConfig(connection)) ||
(await getFileSystemEdgeConfig(connection));
Expand Down Expand Up @@ -252,6 +252,13 @@ interface EdgeConfigClientOptions {
* This cache is not used in preview or production deployments as superior optimisations are applied there.
*/
disableDevelopmentCache?: boolean;

/**
* Sets a `cache` option on the `fetch` call made by Edge Config.
*
* Unlike Next.js, this defaults to `no-store`, as you most likely want to use Edge Config dynamically.
*/
cache?: 'no-store' | 'force-cache';
}

/**
Expand All @@ -267,7 +274,10 @@ interface EdgeConfigClientOptions {
export const createClient = trace(
function createClient(
connectionString: string | undefined,
options: EdgeConfigClientOptions = { staleIfError: 604800 /* one week */ },
options: EdgeConfigClientOptions = {
staleIfError: 604800 /* one week */,
cache: 'no-store',
},
): EdgeConfigClient {
if (!connectionString)
throw new Error('@vercel/edge-config: No connection string provided');
Expand Down Expand Up @@ -296,6 +306,8 @@ export const createClient = trace(
if (typeof options.staleIfError === 'number' && options.staleIfError > 0)
headers['cache-control'] = `stale-if-error=${options.staleIfError}`;

const fetchCache = options.cache || 'no-store';

/**
* While in development we use SWR-like behavior for the api client to
* reduce latency.
Expand All @@ -309,13 +321,14 @@ export const createClient = trace(
shouldUseDevelopmentCache,
connection,
headers,
fetchCache,
);

const api: Omit<EdgeConfigClient, 'connection'> = {
get: trace(
async function get<T = EdgeConfigValue>(
key: string,
): Promise<DeepReadonly<T> | undefined> {
): Promise<T | undefined> {
const localEdgeConfig =
(await getInMemoryEdgeConfig()) ||
(await getLocalEdgeConfig(connection));
Expand All @@ -327,19 +340,17 @@ export const createClient = trace(
// our original value, and so the reference changes.
//
// This makes it consistent with the real API.
return Promise.resolve(
localEdgeConfig.items[key] as DeepReadonly<T>,
);
return Promise.resolve(localEdgeConfig.items[key] as T);
}

assertIsKey(key);
return fetchWithCachedResponse(
`${baseUrl}/item/${key}?version=${version}`,
{
headers: new Headers(headers),
cache: 'no-store',
cache: fetchCache,
},
).then<DeepReadonly<T> | undefined, undefined>(async (res) => {
).then<T | undefined, undefined>(async (res) => {
if (res.ok) return res.json();
await consumeResponseBody(res);

Expand All @@ -353,7 +364,7 @@ export const createClient = trace(
throw new Error(ERRORS.EDGE_CONFIG_NOT_FOUND);
}
if (res.cachedResponseBody !== undefined)
return res.cachedResponseBody as DeepReadonly<T>;
return res.cachedResponseBody as T;
throw new UnexpectedNetworkError(res);
});
},
Expand All @@ -375,7 +386,7 @@ export const createClient = trace(
return fetch(`${baseUrl}/item/${key}?version=${version}`, {
method: 'HEAD',
headers: new Headers(headers),
cache: 'no-store',
cache: fetchCache,
}).then((res) => {
if (res.status === 401) throw new Error(ERRORS.UNAUTHORIZED);
if (res.status === 404) {
Expand All @@ -395,20 +406,18 @@ export const createClient = trace(
getAll: trace(
async function getAll<T = EdgeConfigItems>(
keys?: (keyof T)[],
): Promise<DeepReadonly<T>> {
): Promise<T> {
const localEdgeConfig =
(await getInMemoryEdgeConfig()) ||
(await getLocalEdgeConfig(connection));

if (localEdgeConfig) {
if (keys === undefined) {
return Promise.resolve(localEdgeConfig.items as DeepReadonly<T>);
return Promise.resolve(localEdgeConfig.items as T);
}

assertIsKeys(keys);
return Promise.resolve(
pick(localEdgeConfig.items, keys) as DeepReadonly<T>,
);
return Promise.resolve(pick(localEdgeConfig.items, keys) as T);
}

if (Array.isArray(keys)) assertIsKeys(keys);
Expand All @@ -421,17 +430,17 @@ export const createClient = trace(

// empty search keys array was given,
// so skip the request and return an empty object
if (search === '') return Promise.resolve({} as DeepReadonly<T>);
if (search === '') return Promise.resolve({} as T);

return fetchWithCachedResponse(
`${baseUrl}/items?version=${version}${
search === null ? '' : `&${search}`
}`,
{
headers: new Headers(headers),
cache: 'no-store',
cache: fetchCache,
},
).then<DeepReadonly<T>>(async (res) => {
).then<T>(async (res) => {
if (res.ok) return res.json();
await consumeResponseBody(res);

Expand Down Expand Up @@ -461,7 +470,7 @@ export const createClient = trace(
`${baseUrl}/digest?version=${version}`,
{
headers: new Headers(headers),
cache: 'no-store',
cache: fetchCache,
},
).then(async (res) => {
if (res.ok) return res.json() as Promise<string>;
Expand Down Expand Up @@ -556,10 +565,8 @@ export const digest: EdgeConfigClient['digest'] = (...args) => {
/**
* Safely clones a read-only Edge Config object and makes it mutable.
*/
export function clone<T = EdgeConfigValue>(
edgeConfigValue: T,
): DeepWritable<T> {
export function clone<T = EdgeConfigValue>(edgeConfigValue: T): T {
// Use JSON.parse and JSON.stringify instead of anything else due to
// the value possibly being a Proxy object.
return JSON.parse(JSON.stringify(edgeConfigValue)) as DeepWritable<T>;
return JSON.parse(JSON.stringify(edgeConfigValue)) as T;
}
8 changes: 2 additions & 6 deletions packages/edge-config/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import type { DeepReadonly } from 'ts-essentials';

export interface EmbeddedEdgeConfig {
digest: string;
items: Record<string, EdgeConfigValue>;
Expand Down Expand Up @@ -40,9 +38,7 @@ export interface EdgeConfigClient {
* @param key - the key to read
* @returns the value stored under the given key, or undefined
*/
get: <T = EdgeConfigValue>(
key: string,
) => Promise<DeepReadonly<T> | undefined>;
get: <T = EdgeConfigValue>(key: string) => Promise<T | undefined>;
/**
* Reads multiple or all values.
*
Expand All @@ -51,7 +47,7 @@ export interface EdgeConfigClient {
* @param keys - the keys to read
* @returns Returns all entries when called with no arguments or only entries matching the given keys otherwise.
*/
getAll: <T = EdgeConfigItems>(keys?: (keyof T)[]) => Promise<DeepReadonly<T>>;
getAll: <T = EdgeConfigItems>(keys?: (keyof T)[]) => Promise<T>;
/**
* Check if a given key exists in the Edge Config.
*
Expand Down
Loading

0 comments on commit 6a592b5

Please sign in to comment.