Skip to content

Commit

Permalink
feat: add netlify-blobs driver (#337)
Browse files Browse the repository at this point in the history
Co-authored-by: Pooya Parsa <[email protected]>
  • Loading branch information
ascorbic and pi0 authored Nov 14, 2023
1 parent db6c5b7 commit df064af
Show file tree
Hide file tree
Showing 8 changed files with 239 additions and 3 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,4 @@ tmp
/test.*
__*
.vercel
.netlify
55 changes: 55 additions & 0 deletions docs/content/6.drivers/netlify-blobs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Netlify Blobs

Store data in a [Netlify Blobs](https://docs.netlify.com/blobs/overview/) store. This is supported in both edge and Node.js runtimes, as well at during builds.

::alert{type="warning"}
Netlify Blobs are in beta.
::

```js
import { createStorage } from "unstorage";
import netlifyBlobsDriver from "unstorage/drivers/netlify-blobs";

const storage = createStorage({
driver: netlifyBlobsDriver({
name: "blob-store-name",
}),
});
```

You can create a deploy-scoped store by settings `deployScoped` option to `true`. This will mean that the deploy only has access to its own store. The store is managed alongside the deploy, with the same deploy previews, deletes, and rollbacks.

```js
import { createStorage } from "unstorage";
import netlifyBlobsDriver from "unstorage/drivers/netlify-blobs";

const storage = createStorage({
driver: netlifyBlobsDriver({
deployScoped: true,
}),
});
```

To use, you will need to install `@netlify/blobs` as dependency or devDependency in your project:

```json
{
"devDependencies": {
"@netlify/blobs": "*"
}
}
```

**Options:**

- `name` - The name of the store to use. It is created if needed. This is required except for deploy-scoped stores.
- `deployScoped` - If set to `true`, the store is scoped to the deploy. This means that it is only available from that deploy, and will be deleted or rolled-back alongside it.
- `siteID` - Required during builds, where it is available as `constants.SITE_ID`. At runtime this is set automatically.
- `token` - Required during builds, where it is available as `constants.NETLIFY_API_TOKEN`. At runtime this is set automatically.

**Advanced options:**

These are not normally needed, but are available for advanced use cases or for use in unit tests.

- `apiURL`
- `edgeURL`
5 changes: 5 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@
"@azure/storage-blob": "^12.17.0",
"@capacitor/preferences": "^5.0.6",
"@cloudflare/workers-types": "^4.20231025.0",
"@netlify/blobs": "^6.2.0",
"@planetscale/database": "^1.11.0",
"@types/ioredis-mock": "^8.2.5",
"@types/jsdom": "^21.1.5",
Expand Down Expand Up @@ -103,6 +104,7 @@
"@azure/keyvault-secrets": "^4.7.0",
"@azure/storage-blob": "^12.16.0",
"@capacitor/preferences": "^5.0.6",
"@netlify/blobs": "^6.2.0",
"@planetscale/database": "^1.11.0",
"@upstash/redis": "^1.23.4",
"@vercel/kv": "^0.2.3",
Expand Down Expand Up @@ -130,6 +132,9 @@
"@capacitor/preferences": {
"optional": true
},
"@netlify/blobs": {
"optional": true
},
"@planetscale/database": {
"optional": true
},
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

114 changes: 114 additions & 0 deletions src/drivers/netlify-blobs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { createError, createRequiredError, defineDriver } from "./utils";
import { getStore, getDeployStore } from "@netlify/blobs";
import type {
Store,
BlobResponseType,
SetOptions,
ListOptions,
} from "@netlify/blobs";
import { fetch } from "ofetch";

const DRIVER_NAME = "netlify-blobs";

type GetOptions = { type?: BlobResponseType };

export interface NetlifyBaseStoreOptions {
/** The name of the store to use. It is created if needed. This is required except for deploy-scoped stores. */
name?: string;
/** If set to `true`, the store is scoped to the deploy. This means that it is only available from that deploy, and will be deleted or rolled-back alongside it. */
deployScoped?: boolean;
/** Required during builds, where it is available as `constants.SITE_ID`. At runtime this is set automatically. */
siteID?: string;
/** Required during builds, where it is available as `constants.NETLIFY_API_TOKEN`. At runtime this is set automatically. */
token?: string;
/** Used for advanced use cases and unit tests */
apiURL?: string;
/** Used for advanced use cases and unit tests */
edgeURL?: string;
}

export interface NetlifyDeployStoreOptions extends NetlifyBaseStoreOptions {
name?: never;
deployScoped: true;
deployID?: string;
}

export interface NetlifyNamedStoreOptions extends NetlifyBaseStoreOptions {
name: string;
deployScoped?: false;
}

export type NetlifyStoreOptions =
| NetlifyDeployStoreOptions
| NetlifyNamedStoreOptions;

export default defineDriver(
({ deployScoped, name, ...opts }: NetlifyStoreOptions) => {
let store: Store;

const getClient = () => {
if (!store) {
if (deployScoped) {
if (name) {
throw createError(
DRIVER_NAME,
"deploy-scoped stores cannot have a name"
);
}
store = getDeployStore({ fetch, ...opts });
} else {
if (!name) {
throw createRequiredError(DRIVER_NAME, "name");
}
// Ensures that reserved characters are encoded
store = getStore({ name: encodeURIComponent(name), fetch, ...opts });
}
}
return store;
};

return {
name: DRIVER_NAME,
options: {},
async hasItem(key) {
return getClient().getMetadata(key).then(Boolean);
},
getItem: (key, tops?: GetOptions) => {
// @ts-expect-error has trouble with the overloaded types
return getClient().get(key, tops);
},
getMeta(key) {
return getClient().getMetadata(key);
},
getItemRaw(key, topts?: GetOptions) {
// @ts-expect-error has trouble with the overloaded types
return getClient().get(key, { type: topts?.type ?? "arrayBuffer" });
},
setItem(key, value, topts?: SetOptions) {
return getClient().set(key, value, topts);
},
setItemRaw(key, value: string | ArrayBuffer | Blob, topts?: SetOptions) {
return getClient().set(key, value, topts);
},
removeItem(key) {
return getClient().delete(key);
},
async getKeys(
base?: string,
tops?: Omit<ListOptions, "prefix" | "paginate">
) {
return (await getClient().list({ ...tops, prefix: base })).blobs.map(
(item) => item.key
);
},
async clear(base?: string) {
const client = getClient();
return Promise.allSettled(
(await client.list({ prefix: base })).blobs.map((item) =>
client.delete(item.key)
)
).then(() => {});
},
};
}
);
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const builtinDrivers = {
lruCache: "unstorage/drivers/lru-cache",
memory: "unstorage/drivers/memory",
mongodb: "unstorage/drivers/mongodb",
netlifyBlobs: "unstorage/drivers/netlify-blobs",
overlay: "unstorage/drivers/overlay",
planetscale: "unstorage/drivers/planetscale",
redis: "unstorage/drivers/redis",
Expand Down Expand Up @@ -71,6 +72,9 @@ export type BuiltinDriverOptions = {
lruCache: ExtractOpts<(typeof import("./drivers/lru-cache"))["default"]>;
memory: ExtractOpts<(typeof import("./drivers/memory"))["default"]>;
mongodb: ExtractOpts<(typeof import("./drivers/mongodb"))["default"]>;
netlifyBlobs: ExtractOpts<
(typeof import("./drivers/netlify-blobs"))["default"]
>;
overlay: ExtractOpts<(typeof import("./drivers/overlay"))["default"]>;
planetscale: ExtractOpts<(typeof import("./drivers/planetscale"))["default"]>;
redis: ExtractOpts<(typeof import("./drivers/redis"))["default"]>;
Expand Down
48 changes: 48 additions & 0 deletions test/drivers/netlify-blobs.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { afterAll, beforeAll, describe } from "vitest";
import driver from "../../src/drivers/netlify-blobs";
import { testDriver } from "./utils";
import { BlobsServer } from "@netlify/blobs";
import { resolve } from "path";
import { rm, mkdir } from "node:fs/promises";

describe("drivers: netlify-blobs", async () => {
const dataDir = resolve(__dirname, "tmp/netlify-blobs");
await rm(dataDir, { recursive: true, force: true }).catch(() => {});
await mkdir(dataDir, { recursive: true });

let server: BlobsServer;
const token = "mock";
const siteID = "1";
beforeAll(async () => {
server = new BlobsServer({
directory: dataDir,
debug: !true,
token,
port: 8971,
});
await server.start();
});

testDriver({
driver: driver({
name: "test",
edgeURL: `http://localhost:8971`,
token,
siteID,
}),
});

testDriver({
driver: driver({
deployScoped: true,
edgeURL: `http://localhost:8971`,
token,
siteID,
deployID: "test",
}),
});

afterAll(async () => {
await server.stop();
});
});
7 changes: 4 additions & 3 deletions test/drivers/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,10 +91,11 @@ export function testDriver(opts: TestOptions) {
const value = new Uint8Array([1, 2, 3]);
await ctx.storage.setItemRaw("/data/raw.bin", value);
const rValue = await ctx.storage.getItemRaw("/data/raw.bin");
if (rValue?.length !== value.length) {
console.log(rValue);
const rValueLen = rValue?.length || rValue?.byteLength;
if (rValueLen !== value.length) {
console.log("Invalid raw value length:", rValue, "Length:", rValueLen);
}
expect(rValue?.length).toBe(value.length);
expect(rValueLen).toBe(value.length);
expect(Buffer.from(rValue).toString("base64")).toBe(
Buffer.from(value).toString("base64")
);
Expand Down

0 comments on commit df064af

Please sign in to comment.