-
Notifications
You must be signed in to change notification settings - Fork 2
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
feat!: add getStore
method
#58
Changes from 7 commits
aeac46b
f40de5b
84aae50
7fe28c5
4fc010e
8abdfc5
bca8ebe
043a339
9cbbab8
b40e5af
474454c
9c1db4a
7bbe0fa
d69f977
79acfbd
d49098f
479af4e
99a0e4c
1a951aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -13,73 +13,143 @@ You can install `@netlify/blobs` via npm: | |
npm install @netlify/blobs | ||
``` | ||
|
||
### Requirements | ||
|
||
- Deno 1.30 and above or Node.js 16.0.0 and above | ||
- `fetch` in the global scope with a [fetch-compatible](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) | ||
interface | ||
|
||
## Usage | ||
|
||
To use the blob store, import the module and create an instance of the `Blobs` class. The constructor accepts an object | ||
with the following properties: | ||
To start reading and writing data, you must first get a reference to a store using the `getStore` method. | ||
|
||
| Property | Description | Required | | ||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- | | ||
| `authentication` | An object containing authentication credentials (see [Authentication](#authentication)) | **Yes** | | ||
| `siteID` | The Netlify site ID | **Yes** | | ||
| `context` | The [deploy context](https://docs.netlify.com/site-deploys/overview/#deploy-contexts) to use (defaults to `production`) | No | | ||
| `fetcher` | An implementation of a [fetch-compatible](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) module for making HTTP requests (defaults to `globalThis.fetch`) | No | | ||
This method takes an options object that lets you configure the store for different access modes. | ||
|
||
### Example | ||
### API access | ||
|
||
```javascript | ||
import assert from 'node:assert' | ||
import { Blobs } from '@netlify/blobs' | ||
|
||
const store = new Blobs({ | ||
authentication: { | ||
token: 'YOUR_NETLIFY_AUTH_TOKEN', | ||
}, | ||
siteID: 'YOUR_NETLIFY_SITE_ID', | ||
}) | ||
You can interact with the blob store through the [Netlify API](https://docs.netlify.com/api/get-started). This is the | ||
recommended method if you're looking for a strong-consistency way of accessing data, where latency is not mission | ||
critical (since requests will always go to a non-distributed origin). | ||
|
||
Create a store for API access by calling `getStore` with the following parameters: | ||
|
||
await store.set('some-key', 'Hello!') | ||
- `name` (string): Name of the store | ||
- `siteID` (string): ID of the Netlify site | ||
- `token` (string): [Personal access token](https://docs.netlify.com/api/get-started/#authentication) to access the | ||
Netlify API | ||
- `apiURL` (string): URL of the Netlify API (optional, defaults to `https://api.netlify.com`) | ||
|
||
const item = await store.get('some-key') | ||
```ts | ||
import { getStore } from '@netlify/blobs' | ||
|
||
assert.strictEqual(item, 'Hello!') | ||
const store = getStore({ | ||
name: 'my-store', | ||
siteID: 'MY_SITE_ID', | ||
token: 'MY_TOKEN', | ||
}) | ||
|
||
console.log(await store.get('some-key')) | ||
``` | ||
|
||
### Authentication | ||
### Edge access | ||
|
||
You can also interact with the blob store using a distributed network that caches entries at the edge. This is the | ||
recommended method if you're looking for fast reads across multiple locations, knowing that reads will be | ||
eventually-consistent with a drift of up to 60 seconds. | ||
|
||
Create a store for edge access by calling `getStore` with the following parameters: | ||
|
||
Authentication with the blob storage is done in one of two ways: | ||
- `name` (string): Name of the store | ||
- `siteID` (string): ID of the Netlify site | ||
- `token` (string): [Personal access token](https://docs.netlify.com/api/get-started/#authentication) to access the | ||
Netlify API | ||
- `edgeURL` (string): URL of the edge endpoint | ||
|
||
- Using a [Netlify API token](https://docs.netlify.com/api/get-started/#authentication) | ||
```ts | ||
import { Buffer } from 'node:buffer' | ||
|
||
```javascript | ||
import { Blobs } from '@netlify/blobs' | ||
import { getStore } from '@netlify/blobs' | ||
|
||
const store = new Blobs({ | ||
authentication: { | ||
token: 'YOUR_NETLIFY_API_TOKEN', | ||
}, | ||
siteID: 'YOUR_NETLIFY_SITE_ID', | ||
// Serverless function using the Lambda compatibility mode | ||
export const handler = async (event, context) => { | ||
const rawData = Buffer.from(context.clientContext.custom.blobs, 'base64') | ||
const data = JSON.parse(rawData.toString('ascii')) | ||
const store = getStore({ | ||
edgeURL: data.url, | ||
name: 'my-store', | ||
token: data.token, | ||
siteID: 'MY_SITE_ID', | ||
}) | ||
``` | ||
|
||
- Using a context object injected in Netlify Functions | ||
|
||
```javascript | ||
import { Blobs } from '@netlify/blobs' | ||
import type { Handler, HandlerEvent, HandlerContext } from '@netlify/functions' | ||
|
||
export const handler: Handler = async (event: HandlerEvent, context: HandlerContext) => { | ||
const store = new Blobs({ | ||
authentication: { | ||
contextURL: context.blobs.url, | ||
token: context.blobs.token, | ||
}, | ||
siteID: 'YOUR_NETLIFY_SITE_ID', | ||
}) | ||
const item = await store.get('some-key') | ||
|
||
return { | ||
statusCode: 200, | ||
body: item, | ||
} | ||
``` | ||
} | ||
``` | ||
|
||
### Environment-based configuration | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would move this first |
||
|
||
Rather than explicitly passing the configuration context to the `getStore` method, it can be read from the execution | ||
environment. This is particularly useful for setups where the configuration data is held by one system and the data | ||
needs to be accessed in another system, with no direct communication between the two. | ||
|
||
To do this, the system that holds the configuration data should set an environment variable called | ||
`NETLIFY_BLOBS_CONTEXT` with a Base64-encoded, JSON-stringified representation of an object with the following | ||
properties: | ||
|
||
- `apiURL` (optional) or `edgeURL`: URL of the Netlify API (for [API access](#api-access)) or the edge endpoint (for | ||
[Edge access](#edge-access)) | ||
- `token`: Access token for the corresponding access mode | ||
- `siteID`: ID of the Netlify site | ||
|
||
This data is automatically populated by Netlify in the execution environment for both serverless and edge functions. | ||
|
||
With this in place, the `getStore` method can be called just with the store name. No configuration object is required, | ||
since it'll be read from the environment. | ||
|
||
```ts | ||
import { getStore } from '@netlify/blobs' | ||
|
||
const store = getStore('my-store') | ||
|
||
console.log(await store.get('my-key')) | ||
``` | ||
|
||
### Deploy scope | ||
|
||
By default, stores exist at the site level, which means that data can be read and written across different deploys and | ||
deploy contexts. Users are responsible for managing that data, since the platform doesn't have enough information to | ||
know whether an item is still relevant or safe to delete. | ||
|
||
But sometimes it's useful to have data pegged to a specific deploy, and shift to the platform the responsibility of | ||
managing that data — keep it as long as the deploy is around, and wipe it if the deploy is deleted. | ||
|
||
You can opt-in to this behavior by supplying a `deployID` instead of a `name` to the `getStore` method. | ||
|
||
```ts | ||
import { assert } from 'node:assert' | ||
|
||
import { getStore } from '@netlify/blobs' | ||
|
||
// Using API access | ||
const store1 = getStore({ | ||
deployID: 'MY_DEPLOY_ID', | ||
token: 'MY_API_TOKEN', | ||
}) | ||
|
||
await store1.set('my-key', 'my value') | ||
|
||
// Using environment-based configuration | ||
const store2 = getStore({ | ||
deployID: 'MY_DEPLOY_ID', | ||
}) | ||
|
||
assert.equal(await store2.get('my-key'), 'my value') | ||
``` | ||
|
||
## API | ||
## Store API reference | ||
|
||
### `get(key: string, { type: string }): Promise<any>` | ||
|
||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,124 @@ | ||
import { Buffer } from 'node:buffer' | ||
import { env } from 'node:process' | ||
ascorbic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
import { fetchAndRetry } from './retry.ts' | ||
import { BlobInput, HTTPMethod } from './types.ts' | ||
ascorbic marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
||
// The name of the environment variable that holds the context in a Base64, | ||
// JSON-encoded object. If we ever need to change the encoding or the shape | ||
// of this object, we should bump the version and create a new variable, so | ||
// that the client knows how to consume the data and can advise the user to | ||
// update the client if needed. | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. NIT: please use JSDoc There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Done in 99a0e4c. |
||
export const NETLIFY_CONTEXT_VARIABLE = 'NETLIFY_BLOBS_CONTEXT' | ||
|
||
export interface Context { | ||
apiURL?: string | ||
edgeURL?: string | ||
siteID: string | ||
token: string | ||
} | ||
|
||
interface MakeStoreRequestOptions { | ||
body?: BlobInput | null | ||
headers?: Record<string, string> | ||
key: string | ||
method: HTTPMethod | ||
storeName: string | ||
} | ||
|
||
export class Client { | ||
private context?: Context | ||
|
||
constructor(context?: Context) { | ||
this.context = context | ||
} | ||
|
||
private getContext() { | ||
if (this.context) { | ||
return this.context | ||
} | ||
|
||
if (!env[NETLIFY_CONTEXT_VARIABLE]) { | ||
return | ||
} | ||
|
||
const data = Buffer.from(env[NETLIFY_CONTEXT_VARIABLE], 'base64').toString() | ||
|
||
try { | ||
return JSON.parse(data) as Context | ||
} catch { | ||
// no-op | ||
} | ||
} | ||
|
||
private static async getFinalRequest(context: Context, storeName: string, key: string, method: string) { | ||
const encodedKey = encodeURIComponent(key) | ||
|
||
if ('edgeURL' in context) { | ||
return { | ||
headers: { | ||
authorization: `Bearer ${context.token}`, | ||
}, | ||
url: `${context.edgeURL}/${context.siteID}/${storeName}/${encodedKey}`, | ||
} | ||
} | ||
|
||
const apiURL = `${context.apiURL ?? 'https://api.netlify.com'}/api/v1/sites/${ | ||
context.siteID | ||
}/blobs/${encodedKey}?context=${storeName}` | ||
const headers = { authorization: `Bearer ${context.token}` } | ||
const res = await fetch(apiURL, { headers, method }) | ||
|
||
if (res.status !== 200) { | ||
throw new Error(`${method} operation has failed: API returned a ${res.status} response`) | ||
} | ||
|
||
const { url } = await res.json() | ||
|
||
return { | ||
url, | ||
} | ||
} | ||
|
||
async makeRequest({ body, headers: extraHeaders, key, method, storeName }: MakeStoreRequestOptions) { | ||
const context = this.getContext() | ||
|
||
if (!context || !context.token || !context.siteID) { | ||
throw new Error("The blob store is unavailable because it's missing required configuration properties") | ||
} | ||
|
||
const { headers: baseHeaders = {}, url } = await Client.getFinalRequest(context, storeName, key, method) | ||
const headers: Record<string, string> = { | ||
...baseHeaders, | ||
...extraHeaders, | ||
} | ||
|
||
if (method === HTTPMethod.Put) { | ||
headers['cache-control'] = 'max-age=0, stale-while-revalidate=60' | ||
} | ||
|
||
const options: RequestInit = { | ||
body, | ||
headers, | ||
method, | ||
} | ||
|
||
if (body instanceof ReadableStream) { | ||
// @ts-expect-error Part of the spec, but not typed: | ||
// https://fetch.spec.whatwg.org/#enumdef-requestduplex | ||
options.duplex = 'half' | ||
} | ||
|
||
const res = await fetchAndRetry(url, options) | ||
|
||
if (res.status === 404 && method === HTTPMethod.Get) { | ||
return null | ||
} | ||
|
||
if (res.status !== 200) { | ||
throw new Error(`${method} operation has failed: store returned a ${res.status} response`) | ||
} | ||
|
||
return res | ||
} | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we still have to support node 14? @eduardoboucas