diff --git a/netlify/edge-functions/hello.ts b/netlify/edge-functions/hello.ts deleted file mode 100644 index 5cb3c8b..0000000 --- a/netlify/edge-functions/hello.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Context, Config } from '@netlify/edge-functions'; -import { getStore, type Store } from '@netlify/blobs'; - -export default async (request: Request, context: Context) => { - try { - const store: Store = await getStore('my-store'); - - const url = new URL(request.url); - const key = url.searchParams.get('key'); - - switch (request.method) { - case 'POST': - if (!key) { - return new Response('No key provided', { status: 400 }); - } - - const body = await request.json(); - await store.setJSON(key, body); - return new Response('Blob successfully stored', { status: 200 }); - case 'GET': - if (!key) { - const list = await store.list(); - - return new Response(JSON.stringify(list), { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }); - } - - const value = await store.get(key); - return new Response(value, { - status: 200, - headers: { - 'Content-Type': 'application/json', - }, - }); - default: - return new Response('Method not allowed', { status: 405 }); - } - } catch (e) { - console.error(e); - return new Response('Internal Error', { status: 500 }); - } -}; - -export const config: Config = { - path: '/*', -}; diff --git a/node_modules/.yarn-integrity b/node_modules/.yarn-integrity new file mode 100644 index 0000000..5cbc6a5 --- /dev/null +++ b/node_modules/.yarn-integrity @@ -0,0 +1,32 @@ +{ + "systemParams": "linux-x64-108", + "modulesFolders": [ + "node_modules", + "node_modules" + ], + "flags": [], + "linkedModules": [ + "@loadable/babel-plugin", + "@loadable/component", + "@loadable/server", + "@loadable/webpack-plugin", + "@lululemon/lapel", + "@module-federation/dashboard-plugin", + "@module-federation/nextjs-mf", + "@module-federation/nextjs-ssr", + "@module-federation/node", + "module-federation-examples-root", + "nx-ts-workspace" + ], + "topLevelPatterns": [ + "@netlify/blobs@^6.3.1", + "@netlify/edge-functions@^2.2.0", + "website@1.0.0" + ], + "lockfileEntries": { + "@netlify/blobs@^6.3.1": "https://registry.yarnpkg.com/@netlify/blobs/-/blobs-6.3.1.tgz#9ed1fd788ef3f23d749487830fc557504ca447c1", + "@netlify/edge-functions@^2.2.0": "https://registry.yarnpkg.com/@netlify/edge-functions/-/edge-functions-2.2.0.tgz#5f7f5c7602a7f98888a4b4421576ca609dad2083" + }, + "files": [], + "artifacts": {} +} \ No newline at end of file diff --git a/node_modules/@netlify/blobs/LICENSE b/node_modules/@netlify/blobs/LICENSE new file mode 100644 index 0000000..182fc3f --- /dev/null +++ b/node_modules/@netlify/blobs/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2022 Netlify + +MIT License + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/node_modules/@netlify/blobs/README.md b/node_modules/@netlify/blobs/README.md new file mode 100644 index 0000000..35300c5 --- /dev/null +++ b/node_modules/@netlify/blobs/README.md @@ -0,0 +1,424 @@ +[![Build](https://github.com/netlify/blobs/workflows/Build/badge.svg)](https://github.com/netlify/blobs/actions) +[![Node](https://img.shields.io/node/v/@netlify/blobs.svg?logo=node.js)](https://www.npmjs.com/package/@netlify/blobs) + +# @netlify/blobs + +A TypeScript client for Netlify Blobs. + +## Installation + +You can install `@netlify/blobs` via npm: + +```shell +npm install @netlify/blobs +``` + +### Requirements + +- Deno 1.30 and above or Node.js 16.0.0 and above + +## Usage + +To start reading and writing data, you must first get a reference to a store using the `getStore` method. + +This method takes an options object that lets you configure the store for different access modes. + +### Environment-based configuration + +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 a global variable called `netlifyBlobsContext` or 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')) +``` + +### API access + +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: + +- `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`) + +```ts +import { getStore } from '@netlify/blobs' + +const store = getStore({ + name: 'my-store', + siteID: 'MY_SITE_ID', + token: 'MY_TOKEN', +}) + +console.log(await store.get('some-key')) +``` + +### 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: + +- `name` (string): Name of the store +- `siteID` (string): ID of the Netlify site +- `token` (string): Access token to the edge endpoint +- `edgeURL` (string): URL of the edge endpoint + +```ts +import { Buffer } from 'node:buffer' + +import { getStore } from '@netlify/blobs' + +// Serverless function using the Lambda compatibility mode +export const handler = async (event) => { + const rawData = Buffer.from(event.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', + }) + const item = await store.get('some-key') + + return { + statusCode: 200, + body: item, + } +} +``` + +### 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 creating the store using the `getDeployStore` method. + +```ts +import { assert } from 'node:assert' + +import { getDeployStore } from '@netlify/blobs' + +// Using API access +const store1 = getDeployStore({ + deployID: 'MY_DEPLOY_ID', + token: 'MY_API_TOKEN', +}) + +await store1.set('my-key', 'my value') + +// Using environment-based configuration +const store2 = getDeployStore() + +assert.equal(await store2.get('my-key'), 'my value') +``` + +### Custom `fetch` + +The client uses [the web platform `fetch()`](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) to make HTTP +calls. By default, it will use any globally-defined instance of `fetch`, but you can choose to provide your own. + +You can do this by supplying a `fetch` property to the `getStore` method. + +```ts +import { fetch } from 'whatwg-fetch' + +import { getStore } from '@netlify/blobs' + +const store = getStore({ + fetch, + name: 'my-store', +}) + +console.log(await store.get('my-key')) +``` + +## Store API reference + +### `get(key: string, { type?: string }): Promise` + +Retrieves an object with the given key. + +Depending on the most convenient format for you to access the value, you may choose to supply a `type` property as a +second parameter, with one of the following values: + +- `arrayBuffer`: Returns the entry as an + [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) +- `blob`: Returns the entry as a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) +- `json`: Parses the entry as JSON and returns the resulting object +- `stream`: Returns the entry as a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) +- `text` (default): Returns the entry as a string of plain text + +If an object with the given key is not found, `null` is returned. + +```javascript +const entry = await store.get('some-key', { type: 'json' }) + +console.log(entry) +``` + +### `getWithMetadata(key: string, { etag?: string, type?: string }): Promise<{ data: any, etag: string, metadata: object }>` + +Retrieves an object with the given key, the [ETag value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag) +for the entry, and any metadata that has been stored with the entry. + +Depending on the most convenient format for you to access the value, you may choose to supply a `type` property as a +second parameter, with one of the following values: + +- `arrayBuffer`: Returns the entry as an + [`ArrayBuffer`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer) +- `blob`: Returns the entry as a [`Blob`](https://developer.mozilla.org/en-US/docs/Web/API/Blob) +- `json`: Parses the entry as JSON and returns the resulting object +- `stream`: Returns the entry as a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream) +- `text` (default): Returns the entry as a string of plain text + +If an object with the given key is not found, `null` is returned. + +```javascript +const blob = await store.getWithMetadata('some-key', { type: 'json' }) + +console.log(blob.data, blob.etag, blob.metadata) +``` + +The `etag` input parameter lets you implement conditional requests, where the blob is only returned if it differs from a +version you have previously obtained. + +```javascript +// Mock implementation of a system for locally persisting blobs and their etags +const cachedETag = getFromMockCache('my-key') + +// Get entry from the blob store only if its ETag is different from the one you +// have locally, which means the entry has changed since you last obtained it +const { data, etag } = await store.getWithMetadata('some-key', { etag: cachedETag }) + +if (etag === cachedETag) { + // `data` is `null` because the local blob is fresh +} else { + // `data` contains the new blob, store it locally alongside the new ETag + writeInMockCache('my-key', data, etag) +} +``` + +### `getMetadata(key: string, { etag?: string, type?: string }): Promise<{ etag: string, metadata: object }>` + +Retrieves any metadata associated with a given key and its +[ETag value](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag). + +If an object with the given key is not found, `null` is returned. + +This method can be used to check whether a key exists without having to actually retrieve it and transfer a +potentially-large blob. + +```javascript +const blob = await store.getMetadata('some-key') + +console.log(blob.etag, blob.metadata) +``` + +### `set(key: string, value: ArrayBuffer | Blob | string, { metadata?: object }): Promise` + +Creates an object with the given key and value. + +If an entry with the given key already exists, its value is overwritten. + +```javascript +await store.set('some-key', 'This is a string value') +``` + +### `setJSON(key: string, value: any, { metadata?: object }): Promise` + +Convenience method for creating a JSON-serialized object with the given key. + +If an entry with the given key already exists, its value is overwritten. + +```javascript +await store.setJSON('some-key', { + foo: 'bar', +}) +``` + +### `delete(key: string): Promise` + +Deletes an object with the given key, if one exists. The return value is always `undefined`, regardless of whether or +not there was an object to delete. + +```javascript +await store.delete('my-key') +``` + +### `list(options?: { directories?: boolean, paginate?: boolean. prefix?: string }): Promise<{ blobs: BlobResult[], directories: string[] }> | AsyncIterable<{ blobs: BlobResult[], directories: string[] }>` + +Returns a list of blobs in a given store. + +```javascript +const { blobs } = await store.list() + +// [ { etag: 'etag1', key: 'some-key' }, { etag: 'etag2', key: 'another-key' } ] +console.log(blobs) +``` + +To filter down the entries that should be returned, an optional `prefix` parameter can be supplied. When used, only the +entries whose key starts with that prefix are returned. + +```javascript +const { blobs } = await store.list({ prefix: 'some' }) + +// [ { etag: 'etag1', key: 'some-key' } ] +console.log(blobs) +``` + +Optionally, you can choose to group blobs together under a common prefix and then browse them hierarchically when +listing a store, just like grouping files in a directory. To do this, use the `/` character in your keys to group them +into directories. + +Take the following list of keys as an example: + +``` +cats/garfield.jpg +cats/tom.jpg +mice/jerry.jpg +mice/mickey.jpg +pink-panther.jpg +``` + +By default, calling `store.list()` will return all five keys. + +```javascript +const { blobs } = await store.list() + +// [ +// { etag: "etag1", key: "cats/garfield.jpg" }, +// { etag: "etag2", key: "cats/tom.jpg" }, +// { etag: "etag3", key: "mice/jerry.jpg" }, +// { etag: "etag4", key: "mice/mickey.jpg" }, +// { etag: "etag5", key: "pink-panther.jpg" }, +// ] +console.log(blobs) +``` + +But if you want to list entries hierarchically, use the `directories` parameter. + +```javascript +const { blobs, directories } = await store.list({ directories: true }) + +// [ { etag: "etag1", key: "pink-panther.jpg" } ] +console.log(blobs) + +// [ "cats", "mice" ] +console.log(directories) +``` + +To drill down into a directory and get a list of its items, you can use the directory name as the `prefix` value. + +```javascript +const { blobs, directories } = await store.list({ directories: true, prefix: 'cats/' }) + +// [ { etag: "etag1", key: "cats/garfield.jpg" }, { etag: "etag2", key: "cats/tom.jpg" } ] +console.log(blobs) + +// [ ] +console.log(directories) +``` + +Note that we're only interested in entries under the `cats` directory, which is why we're using a trailing slash. +Without it, other keys like `catsuit` would also match. + +For performance reasons, the server groups results into pages of up to 1,000 entries. By default, the `list()` method +automatically retrieves all pages, meaning you'll always get the full list of results. + +If you'd like to handle this pagination manually, you can supply the `paginate` parameter, which makes `list()` return +an [`AsyncIterator`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/AsyncIterator). + +```javascript +const blobs = [] + +for await (const entry of store.list({ paginate: true })) { + blobs.push(...entry.blobs) +} + +// [ +// { etag: "etag1", key: "cats/garfield.jpg" }, +// { etag: "etag2", key: "cats/tom.jpg" }, +// { etag: "etag3", key: "mice/jerry.jpg" }, +// { etag: "etag4", key: "mice/mickey.jpg" }, +// { etag: "etag5", key: "pink-panther.jpg" }, +// ] +console.log(blobs) +``` + +## Server API reference + +We provide a Node.js server that implements the Netlify Blobs server interface backed by the local filesystem. This is +useful if you want to write automated tests that involve the Netlify Blobs API without interacting with a live store. + +The `BlobsServer` export lets you construct and initialize a server. You can then use its address to initialize a store. + +```ts +import { BlobsServer, getStore } from '@netlify/blobs' + +// Choose any token for protecting your local server from +// extraneous requests +const token = 'some-token' + +// Create a server by providing a local directory where all +// blobs and metadata should be persisted +const server = new BlobsServer({ + directory: '/path/to/blobs/directory', + port: 1234, + token, +}) + +await server.start() + +// Get a store and provide the address of the local server +const store = getStore({ + edgeURL: 'http://localhost:1234', + name: 'my-store', + token, +}) + +await store.set('my-key', 'This is a local blob') + +console.log(await store.get('my-key')) +``` + +## Contributing + +Contributions are welcome! If you encounter any issues or have suggestions for improvements, please open an issue or +submit a pull request on the [GitHub repository](https://github.com/example/netlify-blobs). + +## License + +Netlify Blobs is open-source software licensed under the +[MIT license](https://github.com/example/netlify-blobs/blob/main/LICENSE). diff --git a/node_modules/@netlify/blobs/dist/main.cjs b/node_modules/@netlify/blobs/dist/main.cjs new file mode 100644 index 0000000..9b61e7f --- /dev/null +++ b/node_modules/@netlify/blobs/dist/main.cjs @@ -0,0 +1,866 @@ +"use strict"; +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __export = (target, all) => { + for (var name in all) + __defProp(target, name, { get: all[name], enumerable: true }); +}; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( + // If the importer is in node compatibility mode or this is not an ESM + // file that has been converted to a CommonJS file using a Babel- + // compatible transform (i.e. "__esModule" has not been set), then set + // "default" to the CommonJS "module.exports" for node compatibility. + isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, + mod +)); +var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod); + +// src/main.ts +var main_exports = {}; +__export(main_exports, { + BlobsServer: () => BlobsServer, + getDeployStore: () => getDeployStore, + getStore: () => getStore +}); +module.exports = __toCommonJS(main_exports); + +// src/environment.ts +var import_node_buffer = require("buffer"); +var import_node_process = require("process"); +var getEnvironmentContext = () => { + const context = globalThis.netlifyBlobsContext || import_node_process.env.NETLIFY_BLOBS_CONTEXT; + if (typeof context !== "string" || !context) { + return {}; + } + const data = import_node_buffer.Buffer.from(context, "base64").toString(); + try { + return JSON.parse(data); + } catch { + } + return {}; +}; +var MissingBlobsEnvironmentError = class extends Error { + constructor(requiredProperties) { + super( + `The environment has not been configured to use Netlify Blobs. To use it manually, supply the following properties when creating a store: ${requiredProperties.join( + ", " + )}` + ); + this.name = "MissingBlobsEnvironmentError"; + } +}; + +// src/metadata.ts +var import_node_buffer2 = require("buffer"); +var BASE64_PREFIX = "b64;"; +var METADATA_HEADER_INTERNAL = "x-amz-meta-user"; +var METADATA_HEADER_EXTERNAL = "netlify-blobs-metadata"; +var METADATA_MAX_SIZE = 2 * 1024; +var encodeMetadata = (metadata) => { + if (!metadata) { + return null; + } + const encodedObject = import_node_buffer2.Buffer.from(JSON.stringify(metadata)).toString("base64"); + const payload = `b64;${encodedObject}`; + if (METADATA_HEADER_EXTERNAL.length + payload.length > METADATA_MAX_SIZE) { + throw new Error("Metadata object exceeds the maximum size"); + } + return payload; +}; +var decodeMetadata = (header) => { + if (!header || !header.startsWith(BASE64_PREFIX)) { + return {}; + } + const encodedData = header.slice(BASE64_PREFIX.length); + const decodedData = import_node_buffer2.Buffer.from(encodedData, "base64").toString(); + const metadata = JSON.parse(decodedData); + return metadata; +}; +var getMetadataFromResponse = (response) => { + if (!response.headers) { + return {}; + } + const value = response.headers.get(METADATA_HEADER_EXTERNAL) || response.headers.get(METADATA_HEADER_INTERNAL); + try { + return decodeMetadata(value); + } catch { + throw new Error( + "An internal error occurred while trying to retrieve the metadata for an entry. Please try updating to the latest version of the Netlify Blobs client." + ); + } +}; + +// src/retry.ts +var DEFAULT_RETRY_DELAY = 5e3; +var MIN_RETRY_DELAY = 1e3; +var MAX_RETRY = 5; +var RATE_LIMIT_HEADER = "X-RateLimit-Reset"; +var fetchAndRetry = async (fetch, url, options, attemptsLeft = MAX_RETRY) => { + try { + const res = await fetch(url, options); + if (attemptsLeft > 0 && (res.status === 429 || res.status >= 500)) { + const delay = getDelay(res.headers.get(RATE_LIMIT_HEADER)); + await sleep(delay); + return fetchAndRetry(fetch, url, options, attemptsLeft - 1); + } + return res; + } catch (error) { + if (attemptsLeft === 0) { + throw error; + } + const delay = getDelay(); + await sleep(delay); + return fetchAndRetry(fetch, url, options, attemptsLeft - 1); + } +}; +var getDelay = (rateLimitReset) => { + if (!rateLimitReset) { + return DEFAULT_RETRY_DELAY; + } + return Math.max(Number(rateLimitReset) * 1e3 - Date.now(), MIN_RETRY_DELAY); +}; +var sleep = (ms) => new Promise((resolve2) => { + setTimeout(resolve2, ms); +}); + +// src/client.ts +var Client = class { + constructor({ apiURL, edgeURL, fetch, siteID, token }) { + this.apiURL = apiURL; + this.edgeURL = edgeURL; + this.fetch = fetch ?? globalThis.fetch; + this.siteID = siteID; + this.token = token; + if (!this.fetch) { + throw new Error( + "Netlify Blobs could not find a `fetch` client in the global scope. You can either update your runtime to a version that includes `fetch` (like Node.js 18.0.0 or above), or you can supply your own implementation using the `fetch` property." + ); + } + } + async getFinalRequest({ key, metadata, method, parameters = {}, storeName }) { + const encodedMetadata = encodeMetadata(metadata); + if (this.edgeURL) { + const headers = { + authorization: `Bearer ${this.token}` + }; + if (encodedMetadata) { + headers[METADATA_HEADER_INTERNAL] = encodedMetadata; + } + const path = key ? `/${this.siteID}/${storeName}/${key}` : `/${this.siteID}/${storeName}`; + const url2 = new URL(path, this.edgeURL); + for (const key2 in parameters) { + url2.searchParams.set(key2, parameters[key2]); + } + return { + headers, + url: url2.toString() + }; + } + const apiHeaders = { authorization: `Bearer ${this.token}` }; + const url = new URL(`/api/v1/sites/${this.siteID}/blobs`, this.apiURL ?? "https://api.netlify.com"); + for (const key2 in parameters) { + url.searchParams.set(key2, parameters[key2]); + } + url.searchParams.set("context", storeName); + if (key === void 0) { + return { + headers: apiHeaders, + url: url.toString() + }; + } + url.pathname += `/${key}`; + if (encodedMetadata) { + apiHeaders[METADATA_HEADER_EXTERNAL] = encodedMetadata; + } + if (method === "head" /* HEAD */) { + return { + headers: apiHeaders, + url: url.toString() + }; + } + const res = await this.fetch(url.toString(), { headers: apiHeaders, method }); + if (res.status !== 200) { + throw new Error(`Netlify Blobs has generated an internal error: ${res.status} response`); + } + const { url: signedURL } = await res.json(); + const userHeaders = encodedMetadata ? { [METADATA_HEADER_INTERNAL]: encodedMetadata } : void 0; + return { + headers: userHeaders, + url: signedURL + }; + } + async makeRequest({ + body, + headers: extraHeaders, + key, + metadata, + method, + parameters, + storeName + }) { + const { headers: baseHeaders = {}, url } = await this.getFinalRequest({ + key, + metadata, + method, + parameters, + storeName + }); + const headers = { + ...baseHeaders, + ...extraHeaders + }; + if (method === "put" /* PUT */) { + headers["cache-control"] = "max-age=0, stale-while-revalidate=60"; + } + const options = { + body, + headers, + method + }; + if (body instanceof ReadableStream) { + options.duplex = "half"; + } + return fetchAndRetry(this.fetch, url, options); + } +}; +var getClientOptions = (options, contextOverride) => { + const context = contextOverride ?? getEnvironmentContext(); + const siteID = context.siteID ?? options.siteID; + const token = context.token ?? options.token; + if (!siteID || !token) { + throw new MissingBlobsEnvironmentError(["siteID", "token"]); + } + const clientOptions = { + apiURL: context.apiURL ?? options.apiURL, + edgeURL: context.edgeURL ?? options.edgeURL, + fetch: options.fetch, + siteID, + token + }; + return clientOptions; +}; + +// src/store.ts +var import_node_buffer3 = require("buffer"); + +// src/util.ts +var BlobsInternalError = class extends Error { + constructor(statusCode) { + super(`Netlify Blobs has generated an internal error: ${statusCode} response`); + this.name = "BlobsInternalError"; + } +}; +var collectIterator = async (iterator) => { + const result = []; + for await (const item of iterator) { + result.push(item); + } + return result; +}; +var isNodeError = (error) => error instanceof Error; + +// src/store.ts +var Store = class _Store { + constructor(options) { + this.client = options.client; + if ("deployID" in options) { + _Store.validateDeployID(options.deployID); + this.name = `deploy:${options.deployID}`; + } else { + _Store.validateStoreName(options.name); + this.name = options.name; + } + } + async delete(key) { + const res = await this.client.makeRequest({ key, method: "delete" /* DELETE */, storeName: this.name }); + if (![200, 204, 404].includes(res.status)) { + throw new BlobsInternalError(res.status); + } + } + async get(key, options) { + const { type } = options ?? {}; + const res = await this.client.makeRequest({ key, method: "get" /* GET */, storeName: this.name }); + if (res.status === 404) { + return null; + } + if (res.status !== 200) { + throw new BlobsInternalError(res.status); + } + if (type === void 0 || type === "text") { + return res.text(); + } + if (type === "arrayBuffer") { + return res.arrayBuffer(); + } + if (type === "blob") { + return res.blob(); + } + if (type === "json") { + return res.json(); + } + if (type === "stream") { + return res.body; + } + throw new BlobsInternalError(res.status); + } + async getMetadata(key) { + const res = await this.client.makeRequest({ key, method: "head" /* HEAD */, storeName: this.name }); + if (res.status === 404) { + return null; + } + if (res.status !== 200 && res.status !== 304) { + throw new BlobsInternalError(res.status); + } + const etag = res?.headers.get("etag") ?? void 0; + const metadata = getMetadataFromResponse(res); + const result = { + etag, + metadata + }; + return result; + } + async getWithMetadata(key, options) { + const { etag: requestETag, type } = options ?? {}; + const headers = requestETag ? { "if-none-match": requestETag } : void 0; + const res = await this.client.makeRequest({ headers, key, method: "get" /* GET */, storeName: this.name }); + if (res.status === 404) { + return null; + } + if (res.status !== 200 && res.status !== 304) { + throw new BlobsInternalError(res.status); + } + const responseETag = res?.headers.get("etag") ?? void 0; + const metadata = getMetadataFromResponse(res); + const result = { + etag: responseETag, + metadata + }; + if (res.status === 304 && requestETag) { + return { data: null, ...result }; + } + if (type === void 0 || type === "text") { + return { data: await res.text(), ...result }; + } + if (type === "arrayBuffer") { + return { data: await res.arrayBuffer(), ...result }; + } + if (type === "blob") { + return { data: await res.blob(), ...result }; + } + if (type === "json") { + return { data: await res.json(), ...result }; + } + if (type === "stream") { + return { data: res.body, ...result }; + } + throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`); + } + list(options = {}) { + const iterator = this.getListIterator(options); + if (options.paginate) { + return iterator; + } + return collectIterator(iterator).then( + (items) => items.reduce( + (acc, item) => ({ + blobs: [...acc.blobs, ...item.blobs], + directories: [...acc.directories, ...item.directories] + }), + { blobs: [], directories: [] } + ) + ); + } + async set(key, data, { metadata } = {}) { + _Store.validateKey(key); + const res = await this.client.makeRequest({ + body: data, + key, + metadata, + method: "put" /* PUT */, + storeName: this.name + }); + if (res.status !== 200) { + throw new BlobsInternalError(res.status); + } + } + async setJSON(key, data, { metadata } = {}) { + _Store.validateKey(key); + const payload = JSON.stringify(data); + const headers = { + "content-type": "application/json" + }; + const res = await this.client.makeRequest({ + body: payload, + headers, + key, + metadata, + method: "put" /* PUT */, + storeName: this.name + }); + if (res.status !== 200) { + throw new BlobsInternalError(res.status); + } + } + static formatListResultBlob(result) { + if (!result.key) { + return null; + } + return { + etag: result.etag, + key: result.key + }; + } + static validateKey(key) { + if (key.startsWith("/") || key.startsWith("%2F")) { + throw new Error("Blob key must not start with forward slash (/)."); + } + if (import_node_buffer3.Buffer.byteLength(key, "utf8") > 600) { + throw new Error( + "Blob key must be a sequence of Unicode characters whose UTF-8 encoding is at most 600 bytes long." + ); + } + } + static validateDeployID(deployID) { + if (!/^\w{1,24}$/.test(deployID)) { + throw new Error(`'${deployID}' is not a valid Netlify deploy ID.`); + } + } + static validateStoreName(name) { + if (name.startsWith("deploy:") || name.startsWith("deploy%3A1")) { + throw new Error("Store name must not start with the `deploy:` reserved keyword."); + } + if (name.includes("/") || name.includes("%2F")) { + throw new Error("Store name must not contain forward slashes (/)."); + } + if (import_node_buffer3.Buffer.byteLength(name, "utf8") > 64) { + throw new Error( + "Store name must be a sequence of Unicode characters whose UTF-8 encoding is at most 64 bytes long." + ); + } + } + getListIterator(options) { + const { client, name: storeName } = this; + const parameters = {}; + if (options?.prefix) { + parameters.prefix = options.prefix; + } + if (options?.directories) { + parameters.directories = "true"; + } + return { + [Symbol.asyncIterator]() { + let currentCursor = null; + let done = false; + return { + async next() { + if (done) { + return { done: true, value: void 0 }; + } + const nextParameters = { ...parameters }; + if (currentCursor !== null) { + nextParameters.cursor = currentCursor; + } + const res = await client.makeRequest({ + method: "get" /* GET */, + parameters: nextParameters, + storeName + }); + const page = await res.json(); + if (page.next_cursor) { + currentCursor = page.next_cursor; + } else { + done = true; + } + const blobs = (page.blobs ?? []).map(_Store.formatListResultBlob).filter(Boolean); + return { + done: false, + value: { + blobs, + directories: page.directories ?? [] + } + }; + } + }; + } + }; + } +}; + +// src/store_factory.ts +var getDeployStore = (options = {}) => { + const context = getEnvironmentContext(); + const deployID = options.deployID ?? context.deployID; + if (!deployID) { + throw new MissingBlobsEnvironmentError(["deployID"]); + } + const clientOptions = getClientOptions(options, context); + const client = new Client(clientOptions); + return new Store({ client, deployID }); +}; +var getStore = (input) => { + if (typeof input === "string") { + const clientOptions = getClientOptions({}); + const client = new Client(clientOptions); + return new Store({ client, name: input }); + } + if (typeof input?.name === "string") { + const { name } = input; + const clientOptions = getClientOptions(input); + if (!name) { + throw new MissingBlobsEnvironmentError(["name"]); + } + const client = new Client(clientOptions); + return new Store({ client, name }); + } + if (typeof input?.deployID === "string") { + const clientOptions = getClientOptions(input); + const { deployID } = input; + if (!deployID) { + throw new MissingBlobsEnvironmentError(["deployID"]); + } + const client = new Client(clientOptions); + return new Store({ client, deployID }); + } + throw new Error( + "The `getStore` method requires the name of the store as a string or as the `name` property of an options object" + ); +}; + +// src/server.ts +var import_node_crypto = require("crypto"); +var import_node_fs = require("fs"); +var import_node_http = __toESM(require("http"), 1); +var import_node_os = require("os"); +var import_node_path = require("path"); +var import_node_process2 = require("process"); +var API_URL_PATH = /\/api\/v1\/sites\/(?[^/]+)\/blobs\/?(?[^?]*)/; +var DEFAULT_STORE = "production"; +var BlobsServer = class _BlobsServer { + constructor({ debug, directory, logger, onRequest, port, token }) { + this.address = ""; + this.debug = debug === true; + this.directory = directory; + this.logger = logger ?? console.log; + this.onRequest = onRequest ?? (() => { + }); + this.port = port || 0; + this.token = token; + this.tokenHash = (0, import_node_crypto.createHmac)("sha256", Math.random.toString()).update(token ?? Math.random.toString()).digest("hex"); + } + logDebug(...message) { + if (!this.debug) { + return; + } + this.logger("[Netlify Blobs server]", ...message); + } + async delete(req, res) { + const apiMatch = this.parseAPIRequest(req); + if (apiMatch) { + return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); + } + const url = new URL(req.url ?? "", this.address); + const { dataPath, key, metadataPath } = this.getLocalPaths(url); + if (!dataPath || !key) { + return this.sendResponse(req, res, 400); + } + try { + await import_node_fs.promises.rm(metadataPath, { force: true, recursive: true }); + } catch { + } + try { + await import_node_fs.promises.rm(dataPath, { force: true, recursive: true }); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + return this.sendResponse(req, res, 500); + } + } + return this.sendResponse(req, res, 204); + } + async get(req, res) { + const apiMatch = this.parseAPIRequest(req); + const url = apiMatch?.url ?? new URL(req.url ?? "", this.address); + if (apiMatch?.key) { + return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); + } + const { dataPath, key, metadataPath, rootPath } = this.getLocalPaths(url); + if (!dataPath || !metadataPath) { + return this.sendResponse(req, res, 400); + } + if (!key) { + return this.list({ dataPath, metadataPath, rootPath, req, res, url }); + } + this.onRequest({ type: "get" /* GET */ }); + const headers = {}; + try { + const rawData = await import_node_fs.promises.readFile(metadataPath, "utf8"); + const metadata = JSON.parse(rawData); + const encodedMetadata = encodeMetadata(metadata); + if (encodedMetadata) { + headers[METADATA_HEADER_INTERNAL] = encodedMetadata; + } + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + this.logDebug("Could not read metadata file:", error); + } + } + for (const name in headers) { + res.setHeader(name, headers[name]); + } + const stream = (0, import_node_fs.createReadStream)(dataPath); + stream.on("error", (error) => { + if (error.code === "EISDIR" || error.code === "ENOENT") { + return this.sendResponse(req, res, 404); + } + return this.sendResponse(req, res, 500); + }); + stream.pipe(res); + } + async head(req, res) { + const url = this.parseAPIRequest(req)?.url ?? new URL(req.url ?? "", this.address); + const { dataPath, key, metadataPath } = this.getLocalPaths(url); + if (!dataPath || !metadataPath || !key) { + return this.sendResponse(req, res, 400); + } + try { + const rawData = await import_node_fs.promises.readFile(metadataPath, "utf8"); + const metadata = JSON.parse(rawData); + const encodedMetadata = encodeMetadata(metadata); + if (encodedMetadata) { + res.setHeader(METADATA_HEADER_INTERNAL, encodedMetadata); + } + } catch (error) { + if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ISDIR")) { + return this.sendResponse(req, res, 404); + } + this.logDebug("Could not read metadata file:", error); + return this.sendResponse(req, res, 500); + } + res.end(); + } + async list(options) { + this.onRequest({ type: "list" /* LIST */ }); + const { dataPath, rootPath, req, res, url } = options; + const directories = url.searchParams.get("directories") === "true"; + const prefix = url.searchParams.get("prefix") ?? ""; + const result = { + blobs: [], + directories: [] + }; + try { + await _BlobsServer.walk({ directories, path: dataPath, prefix, rootPath, result }); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + this.logDebug("Could not perform list:", error); + return this.sendResponse(req, res, 500); + } + } + res.setHeader("content-type", "application/json"); + return this.sendResponse(req, res, 200, JSON.stringify(result)); + } + async put(req, res) { + const apiMatch = this.parseAPIRequest(req); + if (apiMatch) { + return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); + } + const url = new URL(req.url ?? "", this.address); + const { dataPath, key, metadataPath } = this.getLocalPaths(url); + if (!dataPath || !key || !metadataPath) { + return this.sendResponse(req, res, 400); + } + const metadataHeader = req.headers[METADATA_HEADER_INTERNAL]; + const metadata = decodeMetadata(Array.isArray(metadataHeader) ? metadataHeader[0] : metadataHeader ?? null); + try { + const tempDirectory = await import_node_fs.promises.mkdtemp((0, import_node_path.join)((0, import_node_os.tmpdir)(), "netlify-blobs")); + const relativeDataPath = (0, import_node_path.relative)(this.directory, dataPath); + const tempDataPath = (0, import_node_path.join)(tempDirectory, relativeDataPath); + await import_node_fs.promises.mkdir((0, import_node_path.dirname)(tempDataPath), { recursive: true }); + await new Promise((resolve2, reject) => { + req.pipe((0, import_node_fs.createWriteStream)(tempDataPath)); + req.on("end", resolve2); + req.on("error", reject); + }); + await import_node_fs.promises.mkdir((0, import_node_path.dirname)(dataPath), { recursive: true }); + await import_node_fs.promises.rename(tempDataPath, dataPath); + await import_node_fs.promises.rm(tempDirectory, { force: true, recursive: true }); + await import_node_fs.promises.mkdir((0, import_node_path.dirname)(metadataPath), { recursive: true }); + await import_node_fs.promises.writeFile(metadataPath, JSON.stringify(metadata)); + } catch (error) { + this.logDebug("Error when writing data:", error); + return this.sendResponse(req, res, 500); + } + return this.sendResponse(req, res, 200); + } + /** + * Parses the URL and returns the filesystem paths where entries and metadata + * should be stored. + */ + getLocalPaths(url) { + if (!url) { + return {}; + } + const [, siteID, rawStoreName, ...key] = url.pathname.split("/"); + if (!siteID || !rawStoreName) { + return {}; + } + const storeName = import_node_process2.platform === "win32" ? encodeURIComponent(rawStoreName) : rawStoreName; + const rootPath = (0, import_node_path.resolve)(this.directory, "entries", siteID, storeName); + const dataPath = (0, import_node_path.resolve)(rootPath, ...key); + const metadataPath = (0, import_node_path.resolve)(this.directory, "metadata", siteID, storeName, ...key); + return { dataPath, key: key.join("/"), metadataPath, rootPath }; + } + handleRequest(req, res) { + if (!req.url || !this.validateAccess(req)) { + return this.sendResponse(req, res, 403); + } + switch (req.method?.toLowerCase()) { + case "delete" /* DELETE */: { + this.onRequest({ type: "delete" /* DELETE */ }); + return this.delete(req, res); + } + case "get" /* GET */: { + return this.get(req, res); + } + case "put" /* PUT */: { + this.onRequest({ type: "set" /* SET */ }); + return this.put(req, res); + } + case "head" /* HEAD */: { + this.onRequest({ type: "getMetadata" /* GET_METADATA */ }); + return this.head(req, res); + } + default: + return this.sendResponse(req, res, 405); + } + } + /** + * Tries to parse a URL as being an API request and returns the different + * components, such as the store name, site ID, key, and signed URL. + */ + parseAPIRequest(req) { + if (!req.url) { + return null; + } + const apiURLMatch = req.url.match(API_URL_PATH); + if (!apiURLMatch) { + return null; + } + const fullURL = new URL(req.url, this.address); + const storeName = fullURL.searchParams.get("context") ?? DEFAULT_STORE; + const key = apiURLMatch.groups?.key; + const siteID = apiURLMatch.groups?.site_id; + const urlPath = [siteID, storeName, key].filter(Boolean); + const url = new URL(`/${urlPath.join("/")}?signature=${this.tokenHash}`, this.address); + return { + key, + siteID, + storeName, + url + }; + } + sendResponse(req, res, status, body) { + this.logDebug(`${req.method} ${req.url} ${status}`); + res.writeHead(status); + res.end(body); + } + async start() { + await import_node_fs.promises.mkdir(this.directory, { recursive: true }); + const server = import_node_http.default.createServer((req, res) => this.handleRequest(req, res)); + this.server = server; + return new Promise((resolve2, reject) => { + server.listen(this.port, () => { + const address = server.address(); + if (!address || typeof address === "string") { + return reject(new Error("Server cannot be started on a pipe or Unix socket")); + } + this.address = `http://localhost:${address.port}`; + resolve2(address); + }); + }); + } + async stop() { + if (!this.server) { + return; + } + await new Promise((resolve2, reject) => { + this.server?.close((error) => { + if (error) { + return reject(error); + } + resolve2(null); + }); + }); + } + validateAccess(req) { + if (!this.token) { + return true; + } + const { authorization = "" } = req.headers; + const parts = authorization.split(" "); + if (parts.length === 2 || parts[0].toLowerCase() === "bearer" && parts[1] === this.token) { + return true; + } + if (!req.url) { + return false; + } + const url = new URL(req.url, this.address); + const signature = url.searchParams.get("signature"); + if (signature === this.tokenHash) { + return true; + } + return false; + } + /** + * Traverses a path and collects both blobs and directories into a `result` + * object, taking into account the `directories` and `prefix` parameters. + */ + static async walk(options) { + const { directories, path, prefix, result, rootPath } = options; + const entries = await import_node_fs.promises.readdir(path); + for (const entry of entries) { + const entryPath = (0, import_node_path.join)(path, entry); + const stat = await import_node_fs.promises.stat(entryPath); + let key = (0, import_node_path.relative)(rootPath, entryPath); + if (import_node_path.sep !== "/") { + key = key.split(import_node_path.sep).join("/"); + } + const mask = key.slice(0, prefix.length); + const isMatch = prefix.startsWith(mask); + if (!isMatch) { + continue; + } + if (!stat.isDirectory()) { + const etag = Math.random().toString().slice(2); + result.blobs?.push({ + etag, + key, + last_modified: stat.mtime.toISOString(), + size: stat.size + }); + continue; + } + if (directories && key.startsWith(prefix)) { + result.directories?.push(key); + continue; + } + await _BlobsServer.walk({ directories, path: entryPath, prefix, rootPath, result }); + } + } +}; +// Annotate the CommonJS export names for ESM import in node: +0 && (module.exports = { + BlobsServer, + getDeployStore, + getStore +}); diff --git a/node_modules/@netlify/blobs/dist/main.d.cts b/node_modules/@netlify/blobs/dist/main.d.cts new file mode 100644 index 0000000..9a088bf --- /dev/null +++ b/node_modules/@netlify/blobs/dist/main.d.cts @@ -0,0 +1,276 @@ +import http from 'node:http'; + +declare global { + var netlifyBlobsContext: unknown; +} + +type Metadata = Record; + +type BlobInput = string | ArrayBuffer | Blob; +type Fetcher = typeof globalThis.fetch; +declare enum HTTPMethod { + DELETE = "delete", + GET = "get", + HEAD = "head", + PUT = "put" +} + +interface MakeStoreRequestOptions { + body?: BlobInput | null; + headers?: Record; + key?: string; + metadata?: Metadata; + method: HTTPMethod; + parameters?: Record; + storeName: string; +} +interface ClientOptions { + apiURL?: string; + edgeURL?: string; + fetch?: Fetcher; + siteID: string; + token: string; +} +declare class Client { + private apiURL?; + private edgeURL?; + private fetch; + private siteID; + private token; + constructor({ apiURL, edgeURL, fetch, siteID, token }: ClientOptions); + private getFinalRequest; + makeRequest({ body, headers: extraHeaders, key, metadata, method, parameters, storeName, }: MakeStoreRequestOptions): Promise; +} + +interface BaseStoreOptions { + client: Client; +} +interface DeployStoreOptions extends BaseStoreOptions { + deployID: string; +} +interface NamedStoreOptions extends BaseStoreOptions { + name: string; +} +type StoreOptions = DeployStoreOptions | NamedStoreOptions; +interface GetWithMetadataOptions { + etag?: string; +} +interface GetWithMetadataResult { + etag?: string; + metadata: Metadata; +} +interface ListResult { + blobs: ListResultBlob[]; + directories: string[]; +} +interface ListResultBlob { + etag: string; + key: string; +} +interface ListOptions { + directories?: boolean; + paginate?: boolean; + prefix?: string; +} +interface SetOptions { + /** + * Arbitrary metadata object to associate with an entry. Must be seralizable + * to JSON. + */ + metadata?: Metadata; +} +type BlobResponseType = 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'text'; +declare class Store { + private client; + private name; + constructor(options: StoreOptions); + delete(key: string): Promise; + get(key: string): Promise; + get(key: string, { type }: { + type: 'arrayBuffer'; + }): Promise; + get(key: string, { type }: { + type: 'blob'; + }): Promise; + get(key: string, { type }: { + type: 'json'; + }): Promise; + get(key: string, { type }: { + type: 'stream'; + }): Promise; + get(key: string, { type }: { + type: 'text'; + }): Promise; + getMetadata(key: string): Promise<{ + etag: string | undefined; + metadata: Metadata; + } | null>; + getWithMetadata(key: string, options?: GetWithMetadataOptions): Promise<({ + data: string; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'arrayBuffer'; + } & GetWithMetadataOptions): Promise<{ + data: ArrayBuffer; + } & GetWithMetadataResult>; + getWithMetadata(key: string, options: { + type: 'blob'; + } & GetWithMetadataOptions): Promise<({ + data: Blob; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'json'; + } & GetWithMetadataOptions): Promise<({ + data: any; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'stream'; + } & GetWithMetadataOptions): Promise<({ + data: ReadableStream; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'text'; + } & GetWithMetadataOptions): Promise<({ + data: string; + } & GetWithMetadataResult) | null>; + list(options: ListOptions & { + paginate: true; + }): AsyncIterable; + list(options?: ListOptions & { + paginate?: false; + }): Promise; + set(key: string, data: BlobInput, { metadata }?: SetOptions): Promise; + setJSON(key: string, data: unknown, { metadata }?: SetOptions): Promise; + private static formatListResultBlob; + private static validateKey; + private static validateDeployID; + private static validateStoreName; + private getListIterator; +} + +interface GetDeployStoreOptions extends Partial { + deployID?: string; +} +/** + * Gets a reference to a deploy-scoped store. + */ +declare const getDeployStore: (options?: GetDeployStoreOptions) => Store; +interface GetStoreOptions extends Partial { + deployID?: string; + name?: string; +} +/** + * Gets a reference to a store. + * + * @param input Either a string containing the store name or an options object + */ +declare const getStore: { + (name: string): Store; + (options: GetStoreOptions): Store; +}; + +type Logger = (...message: unknown[]) => void; + +declare enum Operation { + DELETE = "delete", + GET = "get", + GET_METADATA = "getMetadata", + LIST = "list", + SET = "set" +} +interface BlobsServerOptions { + /** + * Whether debug-level information should be logged, such as internal errors + * or information about incoming requests. + */ + debug?: boolean; + /** + * Base directory to read and write files from. + */ + directory: string; + /** + * Function to log messages. Defaults to `console.log`. + */ + logger?: Logger; + /** + * Callback function to be called on every request. + */ + onRequest?: (parameters: { + type: Operation; + }) => void; + /** + * Port to run the server on. Defaults to a random port. + */ + port?: number; + /** + * Static authentication token that should be present in all requests. If not + * supplied, no authentication check is performed. + */ + token?: string; +} +declare class BlobsServer { + private address; + private debug; + private directory; + private logger; + private onRequest; + private port; + private server?; + private token?; + private tokenHash; + constructor({ debug, directory, logger, onRequest, port, token }: BlobsServerOptions); + logDebug(...message: unknown[]): void; + delete(req: http.IncomingMessage, res: http.ServerResponse): Promise; + get(req: http.IncomingMessage, res: http.ServerResponse): Promise; + head(req: http.IncomingMessage, res: http.ServerResponse): Promise; + list(options: { + dataPath: string; + metadataPath: string; + rootPath: string; + req: http.IncomingMessage; + res: http.ServerResponse; + url: URL; + }): Promise; + put(req: http.IncomingMessage, res: http.ServerResponse): Promise; + /** + * Parses the URL and returns the filesystem paths where entries and metadata + * should be stored. + */ + getLocalPaths(url?: URL): { + dataPath?: undefined; + key?: undefined; + metadataPath?: undefined; + rootPath?: undefined; + } | { + dataPath: string; + key: string; + metadataPath: string; + rootPath: string; + }; + handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void | Promise; + /** + * Tries to parse a URL as being an API request and returns the different + * components, such as the store name, site ID, key, and signed URL. + */ + parseAPIRequest(req: http.IncomingMessage): { + key: string | undefined; + siteID: string; + storeName: string; + url: URL; + } | null; + sendResponse(req: http.IncomingMessage, res: http.ServerResponse, status: number, body?: string): void; + start(): Promise<{ + address: string; + family: string; + port: number; + }>; + stop(): Promise; + validateAccess(req: http.IncomingMessage): boolean; + /** + * Traverses a path and collects both blobs and directories into a `result` + * object, taking into account the `directories` and `prefix` parameters. + */ + private static walk; +} + +export { BlobResponseType, BlobsServer, GetWithMetadataOptions, GetWithMetadataResult, ListOptions, ListResultBlob, SetOptions, Store, StoreOptions, getDeployStore, getStore }; diff --git a/node_modules/@netlify/blobs/dist/main.d.ts b/node_modules/@netlify/blobs/dist/main.d.ts new file mode 100644 index 0000000..9a088bf --- /dev/null +++ b/node_modules/@netlify/blobs/dist/main.d.ts @@ -0,0 +1,276 @@ +import http from 'node:http'; + +declare global { + var netlifyBlobsContext: unknown; +} + +type Metadata = Record; + +type BlobInput = string | ArrayBuffer | Blob; +type Fetcher = typeof globalThis.fetch; +declare enum HTTPMethod { + DELETE = "delete", + GET = "get", + HEAD = "head", + PUT = "put" +} + +interface MakeStoreRequestOptions { + body?: BlobInput | null; + headers?: Record; + key?: string; + metadata?: Metadata; + method: HTTPMethod; + parameters?: Record; + storeName: string; +} +interface ClientOptions { + apiURL?: string; + edgeURL?: string; + fetch?: Fetcher; + siteID: string; + token: string; +} +declare class Client { + private apiURL?; + private edgeURL?; + private fetch; + private siteID; + private token; + constructor({ apiURL, edgeURL, fetch, siteID, token }: ClientOptions); + private getFinalRequest; + makeRequest({ body, headers: extraHeaders, key, metadata, method, parameters, storeName, }: MakeStoreRequestOptions): Promise; +} + +interface BaseStoreOptions { + client: Client; +} +interface DeployStoreOptions extends BaseStoreOptions { + deployID: string; +} +interface NamedStoreOptions extends BaseStoreOptions { + name: string; +} +type StoreOptions = DeployStoreOptions | NamedStoreOptions; +interface GetWithMetadataOptions { + etag?: string; +} +interface GetWithMetadataResult { + etag?: string; + metadata: Metadata; +} +interface ListResult { + blobs: ListResultBlob[]; + directories: string[]; +} +interface ListResultBlob { + etag: string; + key: string; +} +interface ListOptions { + directories?: boolean; + paginate?: boolean; + prefix?: string; +} +interface SetOptions { + /** + * Arbitrary metadata object to associate with an entry. Must be seralizable + * to JSON. + */ + metadata?: Metadata; +} +type BlobResponseType = 'arrayBuffer' | 'blob' | 'json' | 'stream' | 'text'; +declare class Store { + private client; + private name; + constructor(options: StoreOptions); + delete(key: string): Promise; + get(key: string): Promise; + get(key: string, { type }: { + type: 'arrayBuffer'; + }): Promise; + get(key: string, { type }: { + type: 'blob'; + }): Promise; + get(key: string, { type }: { + type: 'json'; + }): Promise; + get(key: string, { type }: { + type: 'stream'; + }): Promise; + get(key: string, { type }: { + type: 'text'; + }): Promise; + getMetadata(key: string): Promise<{ + etag: string | undefined; + metadata: Metadata; + } | null>; + getWithMetadata(key: string, options?: GetWithMetadataOptions): Promise<({ + data: string; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'arrayBuffer'; + } & GetWithMetadataOptions): Promise<{ + data: ArrayBuffer; + } & GetWithMetadataResult>; + getWithMetadata(key: string, options: { + type: 'blob'; + } & GetWithMetadataOptions): Promise<({ + data: Blob; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'json'; + } & GetWithMetadataOptions): Promise<({ + data: any; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'stream'; + } & GetWithMetadataOptions): Promise<({ + data: ReadableStream; + } & GetWithMetadataResult) | null>; + getWithMetadata(key: string, options: { + type: 'text'; + } & GetWithMetadataOptions): Promise<({ + data: string; + } & GetWithMetadataResult) | null>; + list(options: ListOptions & { + paginate: true; + }): AsyncIterable; + list(options?: ListOptions & { + paginate?: false; + }): Promise; + set(key: string, data: BlobInput, { metadata }?: SetOptions): Promise; + setJSON(key: string, data: unknown, { metadata }?: SetOptions): Promise; + private static formatListResultBlob; + private static validateKey; + private static validateDeployID; + private static validateStoreName; + private getListIterator; +} + +interface GetDeployStoreOptions extends Partial { + deployID?: string; +} +/** + * Gets a reference to a deploy-scoped store. + */ +declare const getDeployStore: (options?: GetDeployStoreOptions) => Store; +interface GetStoreOptions extends Partial { + deployID?: string; + name?: string; +} +/** + * Gets a reference to a store. + * + * @param input Either a string containing the store name or an options object + */ +declare const getStore: { + (name: string): Store; + (options: GetStoreOptions): Store; +}; + +type Logger = (...message: unknown[]) => void; + +declare enum Operation { + DELETE = "delete", + GET = "get", + GET_METADATA = "getMetadata", + LIST = "list", + SET = "set" +} +interface BlobsServerOptions { + /** + * Whether debug-level information should be logged, such as internal errors + * or information about incoming requests. + */ + debug?: boolean; + /** + * Base directory to read and write files from. + */ + directory: string; + /** + * Function to log messages. Defaults to `console.log`. + */ + logger?: Logger; + /** + * Callback function to be called on every request. + */ + onRequest?: (parameters: { + type: Operation; + }) => void; + /** + * Port to run the server on. Defaults to a random port. + */ + port?: number; + /** + * Static authentication token that should be present in all requests. If not + * supplied, no authentication check is performed. + */ + token?: string; +} +declare class BlobsServer { + private address; + private debug; + private directory; + private logger; + private onRequest; + private port; + private server?; + private token?; + private tokenHash; + constructor({ debug, directory, logger, onRequest, port, token }: BlobsServerOptions); + logDebug(...message: unknown[]): void; + delete(req: http.IncomingMessage, res: http.ServerResponse): Promise; + get(req: http.IncomingMessage, res: http.ServerResponse): Promise; + head(req: http.IncomingMessage, res: http.ServerResponse): Promise; + list(options: { + dataPath: string; + metadataPath: string; + rootPath: string; + req: http.IncomingMessage; + res: http.ServerResponse; + url: URL; + }): Promise; + put(req: http.IncomingMessage, res: http.ServerResponse): Promise; + /** + * Parses the URL and returns the filesystem paths where entries and metadata + * should be stored. + */ + getLocalPaths(url?: URL): { + dataPath?: undefined; + key?: undefined; + metadataPath?: undefined; + rootPath?: undefined; + } | { + dataPath: string; + key: string; + metadataPath: string; + rootPath: string; + }; + handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void | Promise; + /** + * Tries to parse a URL as being an API request and returns the different + * components, such as the store name, site ID, key, and signed URL. + */ + parseAPIRequest(req: http.IncomingMessage): { + key: string | undefined; + siteID: string; + storeName: string; + url: URL; + } | null; + sendResponse(req: http.IncomingMessage, res: http.ServerResponse, status: number, body?: string): void; + start(): Promise<{ + address: string; + family: string; + port: number; + }>; + stop(): Promise; + validateAccess(req: http.IncomingMessage): boolean; + /** + * Traverses a path and collects both blobs and directories into a `result` + * object, taking into account the `directories` and `prefix` parameters. + */ + private static walk; +} + +export { BlobResponseType, BlobsServer, GetWithMetadataOptions, GetWithMetadataResult, ListOptions, ListResultBlob, SetOptions, Store, StoreOptions, getDeployStore, getStore }; diff --git a/node_modules/@netlify/blobs/dist/main.js b/node_modules/@netlify/blobs/dist/main.js new file mode 100644 index 0000000..5a01172 --- /dev/null +++ b/node_modules/@netlify/blobs/dist/main.js @@ -0,0 +1,827 @@ +// src/environment.ts +import { Buffer } from "buffer"; +import { env } from "process"; +var getEnvironmentContext = () => { + const context = globalThis.netlifyBlobsContext || env.NETLIFY_BLOBS_CONTEXT; + if (typeof context !== "string" || !context) { + return {}; + } + const data = Buffer.from(context, "base64").toString(); + try { + return JSON.parse(data); + } catch { + } + return {}; +}; +var MissingBlobsEnvironmentError = class extends Error { + constructor(requiredProperties) { + super( + `The environment has not been configured to use Netlify Blobs. To use it manually, supply the following properties when creating a store: ${requiredProperties.join( + ", " + )}` + ); + this.name = "MissingBlobsEnvironmentError"; + } +}; + +// src/metadata.ts +import { Buffer as Buffer2 } from "buffer"; +var BASE64_PREFIX = "b64;"; +var METADATA_HEADER_INTERNAL = "x-amz-meta-user"; +var METADATA_HEADER_EXTERNAL = "netlify-blobs-metadata"; +var METADATA_MAX_SIZE = 2 * 1024; +var encodeMetadata = (metadata) => { + if (!metadata) { + return null; + } + const encodedObject = Buffer2.from(JSON.stringify(metadata)).toString("base64"); + const payload = `b64;${encodedObject}`; + if (METADATA_HEADER_EXTERNAL.length + payload.length > METADATA_MAX_SIZE) { + throw new Error("Metadata object exceeds the maximum size"); + } + return payload; +}; +var decodeMetadata = (header) => { + if (!header || !header.startsWith(BASE64_PREFIX)) { + return {}; + } + const encodedData = header.slice(BASE64_PREFIX.length); + const decodedData = Buffer2.from(encodedData, "base64").toString(); + const metadata = JSON.parse(decodedData); + return metadata; +}; +var getMetadataFromResponse = (response) => { + if (!response.headers) { + return {}; + } + const value = response.headers.get(METADATA_HEADER_EXTERNAL) || response.headers.get(METADATA_HEADER_INTERNAL); + try { + return decodeMetadata(value); + } catch { + throw new Error( + "An internal error occurred while trying to retrieve the metadata for an entry. Please try updating to the latest version of the Netlify Blobs client." + ); + } +}; + +// src/retry.ts +var DEFAULT_RETRY_DELAY = 5e3; +var MIN_RETRY_DELAY = 1e3; +var MAX_RETRY = 5; +var RATE_LIMIT_HEADER = "X-RateLimit-Reset"; +var fetchAndRetry = async (fetch, url, options, attemptsLeft = MAX_RETRY) => { + try { + const res = await fetch(url, options); + if (attemptsLeft > 0 && (res.status === 429 || res.status >= 500)) { + const delay = getDelay(res.headers.get(RATE_LIMIT_HEADER)); + await sleep(delay); + return fetchAndRetry(fetch, url, options, attemptsLeft - 1); + } + return res; + } catch (error) { + if (attemptsLeft === 0) { + throw error; + } + const delay = getDelay(); + await sleep(delay); + return fetchAndRetry(fetch, url, options, attemptsLeft - 1); + } +}; +var getDelay = (rateLimitReset) => { + if (!rateLimitReset) { + return DEFAULT_RETRY_DELAY; + } + return Math.max(Number(rateLimitReset) * 1e3 - Date.now(), MIN_RETRY_DELAY); +}; +var sleep = (ms) => new Promise((resolve2) => { + setTimeout(resolve2, ms); +}); + +// src/client.ts +var Client = class { + constructor({ apiURL, edgeURL, fetch, siteID, token }) { + this.apiURL = apiURL; + this.edgeURL = edgeURL; + this.fetch = fetch ?? globalThis.fetch; + this.siteID = siteID; + this.token = token; + if (!this.fetch) { + throw new Error( + "Netlify Blobs could not find a `fetch` client in the global scope. You can either update your runtime to a version that includes `fetch` (like Node.js 18.0.0 or above), or you can supply your own implementation using the `fetch` property." + ); + } + } + async getFinalRequest({ key, metadata, method, parameters = {}, storeName }) { + const encodedMetadata = encodeMetadata(metadata); + if (this.edgeURL) { + const headers = { + authorization: `Bearer ${this.token}` + }; + if (encodedMetadata) { + headers[METADATA_HEADER_INTERNAL] = encodedMetadata; + } + const path = key ? `/${this.siteID}/${storeName}/${key}` : `/${this.siteID}/${storeName}`; + const url2 = new URL(path, this.edgeURL); + for (const key2 in parameters) { + url2.searchParams.set(key2, parameters[key2]); + } + return { + headers, + url: url2.toString() + }; + } + const apiHeaders = { authorization: `Bearer ${this.token}` }; + const url = new URL(`/api/v1/sites/${this.siteID}/blobs`, this.apiURL ?? "https://api.netlify.com"); + for (const key2 in parameters) { + url.searchParams.set(key2, parameters[key2]); + } + url.searchParams.set("context", storeName); + if (key === void 0) { + return { + headers: apiHeaders, + url: url.toString() + }; + } + url.pathname += `/${key}`; + if (encodedMetadata) { + apiHeaders[METADATA_HEADER_EXTERNAL] = encodedMetadata; + } + if (method === "head" /* HEAD */) { + return { + headers: apiHeaders, + url: url.toString() + }; + } + const res = await this.fetch(url.toString(), { headers: apiHeaders, method }); + if (res.status !== 200) { + throw new Error(`Netlify Blobs has generated an internal error: ${res.status} response`); + } + const { url: signedURL } = await res.json(); + const userHeaders = encodedMetadata ? { [METADATA_HEADER_INTERNAL]: encodedMetadata } : void 0; + return { + headers: userHeaders, + url: signedURL + }; + } + async makeRequest({ + body, + headers: extraHeaders, + key, + metadata, + method, + parameters, + storeName + }) { + const { headers: baseHeaders = {}, url } = await this.getFinalRequest({ + key, + metadata, + method, + parameters, + storeName + }); + const headers = { + ...baseHeaders, + ...extraHeaders + }; + if (method === "put" /* PUT */) { + headers["cache-control"] = "max-age=0, stale-while-revalidate=60"; + } + const options = { + body, + headers, + method + }; + if (body instanceof ReadableStream) { + options.duplex = "half"; + } + return fetchAndRetry(this.fetch, url, options); + } +}; +var getClientOptions = (options, contextOverride) => { + const context = contextOverride ?? getEnvironmentContext(); + const siteID = context.siteID ?? options.siteID; + const token = context.token ?? options.token; + if (!siteID || !token) { + throw new MissingBlobsEnvironmentError(["siteID", "token"]); + } + const clientOptions = { + apiURL: context.apiURL ?? options.apiURL, + edgeURL: context.edgeURL ?? options.edgeURL, + fetch: options.fetch, + siteID, + token + }; + return clientOptions; +}; + +// src/store.ts +import { Buffer as Buffer3 } from "buffer"; + +// src/util.ts +var BlobsInternalError = class extends Error { + constructor(statusCode) { + super(`Netlify Blobs has generated an internal error: ${statusCode} response`); + this.name = "BlobsInternalError"; + } +}; +var collectIterator = async (iterator) => { + const result = []; + for await (const item of iterator) { + result.push(item); + } + return result; +}; +var isNodeError = (error) => error instanceof Error; + +// src/store.ts +var Store = class _Store { + constructor(options) { + this.client = options.client; + if ("deployID" in options) { + _Store.validateDeployID(options.deployID); + this.name = `deploy:${options.deployID}`; + } else { + _Store.validateStoreName(options.name); + this.name = options.name; + } + } + async delete(key) { + const res = await this.client.makeRequest({ key, method: "delete" /* DELETE */, storeName: this.name }); + if (![200, 204, 404].includes(res.status)) { + throw new BlobsInternalError(res.status); + } + } + async get(key, options) { + const { type } = options ?? {}; + const res = await this.client.makeRequest({ key, method: "get" /* GET */, storeName: this.name }); + if (res.status === 404) { + return null; + } + if (res.status !== 200) { + throw new BlobsInternalError(res.status); + } + if (type === void 0 || type === "text") { + return res.text(); + } + if (type === "arrayBuffer") { + return res.arrayBuffer(); + } + if (type === "blob") { + return res.blob(); + } + if (type === "json") { + return res.json(); + } + if (type === "stream") { + return res.body; + } + throw new BlobsInternalError(res.status); + } + async getMetadata(key) { + const res = await this.client.makeRequest({ key, method: "head" /* HEAD */, storeName: this.name }); + if (res.status === 404) { + return null; + } + if (res.status !== 200 && res.status !== 304) { + throw new BlobsInternalError(res.status); + } + const etag = res?.headers.get("etag") ?? void 0; + const metadata = getMetadataFromResponse(res); + const result = { + etag, + metadata + }; + return result; + } + async getWithMetadata(key, options) { + const { etag: requestETag, type } = options ?? {}; + const headers = requestETag ? { "if-none-match": requestETag } : void 0; + const res = await this.client.makeRequest({ headers, key, method: "get" /* GET */, storeName: this.name }); + if (res.status === 404) { + return null; + } + if (res.status !== 200 && res.status !== 304) { + throw new BlobsInternalError(res.status); + } + const responseETag = res?.headers.get("etag") ?? void 0; + const metadata = getMetadataFromResponse(res); + const result = { + etag: responseETag, + metadata + }; + if (res.status === 304 && requestETag) { + return { data: null, ...result }; + } + if (type === void 0 || type === "text") { + return { data: await res.text(), ...result }; + } + if (type === "arrayBuffer") { + return { data: await res.arrayBuffer(), ...result }; + } + if (type === "blob") { + return { data: await res.blob(), ...result }; + } + if (type === "json") { + return { data: await res.json(), ...result }; + } + if (type === "stream") { + return { data: res.body, ...result }; + } + throw new Error(`Invalid 'type' property: ${type}. Expected: arrayBuffer, blob, json, stream, or text.`); + } + list(options = {}) { + const iterator = this.getListIterator(options); + if (options.paginate) { + return iterator; + } + return collectIterator(iterator).then( + (items) => items.reduce( + (acc, item) => ({ + blobs: [...acc.blobs, ...item.blobs], + directories: [...acc.directories, ...item.directories] + }), + { blobs: [], directories: [] } + ) + ); + } + async set(key, data, { metadata } = {}) { + _Store.validateKey(key); + const res = await this.client.makeRequest({ + body: data, + key, + metadata, + method: "put" /* PUT */, + storeName: this.name + }); + if (res.status !== 200) { + throw new BlobsInternalError(res.status); + } + } + async setJSON(key, data, { metadata } = {}) { + _Store.validateKey(key); + const payload = JSON.stringify(data); + const headers = { + "content-type": "application/json" + }; + const res = await this.client.makeRequest({ + body: payload, + headers, + key, + metadata, + method: "put" /* PUT */, + storeName: this.name + }); + if (res.status !== 200) { + throw new BlobsInternalError(res.status); + } + } + static formatListResultBlob(result) { + if (!result.key) { + return null; + } + return { + etag: result.etag, + key: result.key + }; + } + static validateKey(key) { + if (key.startsWith("/") || key.startsWith("%2F")) { + throw new Error("Blob key must not start with forward slash (/)."); + } + if (Buffer3.byteLength(key, "utf8") > 600) { + throw new Error( + "Blob key must be a sequence of Unicode characters whose UTF-8 encoding is at most 600 bytes long." + ); + } + } + static validateDeployID(deployID) { + if (!/^\w{1,24}$/.test(deployID)) { + throw new Error(`'${deployID}' is not a valid Netlify deploy ID.`); + } + } + static validateStoreName(name) { + if (name.startsWith("deploy:") || name.startsWith("deploy%3A1")) { + throw new Error("Store name must not start with the `deploy:` reserved keyword."); + } + if (name.includes("/") || name.includes("%2F")) { + throw new Error("Store name must not contain forward slashes (/)."); + } + if (Buffer3.byteLength(name, "utf8") > 64) { + throw new Error( + "Store name must be a sequence of Unicode characters whose UTF-8 encoding is at most 64 bytes long." + ); + } + } + getListIterator(options) { + const { client, name: storeName } = this; + const parameters = {}; + if (options?.prefix) { + parameters.prefix = options.prefix; + } + if (options?.directories) { + parameters.directories = "true"; + } + return { + [Symbol.asyncIterator]() { + let currentCursor = null; + let done = false; + return { + async next() { + if (done) { + return { done: true, value: void 0 }; + } + const nextParameters = { ...parameters }; + if (currentCursor !== null) { + nextParameters.cursor = currentCursor; + } + const res = await client.makeRequest({ + method: "get" /* GET */, + parameters: nextParameters, + storeName + }); + const page = await res.json(); + if (page.next_cursor) { + currentCursor = page.next_cursor; + } else { + done = true; + } + const blobs = (page.blobs ?? []).map(_Store.formatListResultBlob).filter(Boolean); + return { + done: false, + value: { + blobs, + directories: page.directories ?? [] + } + }; + } + }; + } + }; + } +}; + +// src/store_factory.ts +var getDeployStore = (options = {}) => { + const context = getEnvironmentContext(); + const deployID = options.deployID ?? context.deployID; + if (!deployID) { + throw new MissingBlobsEnvironmentError(["deployID"]); + } + const clientOptions = getClientOptions(options, context); + const client = new Client(clientOptions); + return new Store({ client, deployID }); +}; +var getStore = (input) => { + if (typeof input === "string") { + const clientOptions = getClientOptions({}); + const client = new Client(clientOptions); + return new Store({ client, name: input }); + } + if (typeof input?.name === "string") { + const { name } = input; + const clientOptions = getClientOptions(input); + if (!name) { + throw new MissingBlobsEnvironmentError(["name"]); + } + const client = new Client(clientOptions); + return new Store({ client, name }); + } + if (typeof input?.deployID === "string") { + const clientOptions = getClientOptions(input); + const { deployID } = input; + if (!deployID) { + throw new MissingBlobsEnvironmentError(["deployID"]); + } + const client = new Client(clientOptions); + return new Store({ client, deployID }); + } + throw new Error( + "The `getStore` method requires the name of the store as a string or as the `name` property of an options object" + ); +}; + +// src/server.ts +import { createHmac } from "crypto"; +import { createReadStream, createWriteStream, promises as fs } from "fs"; +import http from "http"; +import { tmpdir } from "os"; +import { dirname, join, relative, resolve, sep } from "path"; +import { platform } from "process"; +var API_URL_PATH = /\/api\/v1\/sites\/(?[^/]+)\/blobs\/?(?[^?]*)/; +var DEFAULT_STORE = "production"; +var BlobsServer = class _BlobsServer { + constructor({ debug, directory, logger, onRequest, port, token }) { + this.address = ""; + this.debug = debug === true; + this.directory = directory; + this.logger = logger ?? console.log; + this.onRequest = onRequest ?? (() => { + }); + this.port = port || 0; + this.token = token; + this.tokenHash = createHmac("sha256", Math.random.toString()).update(token ?? Math.random.toString()).digest("hex"); + } + logDebug(...message) { + if (!this.debug) { + return; + } + this.logger("[Netlify Blobs server]", ...message); + } + async delete(req, res) { + const apiMatch = this.parseAPIRequest(req); + if (apiMatch) { + return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); + } + const url = new URL(req.url ?? "", this.address); + const { dataPath, key, metadataPath } = this.getLocalPaths(url); + if (!dataPath || !key) { + return this.sendResponse(req, res, 400); + } + try { + await fs.rm(metadataPath, { force: true, recursive: true }); + } catch { + } + try { + await fs.rm(dataPath, { force: true, recursive: true }); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + return this.sendResponse(req, res, 500); + } + } + return this.sendResponse(req, res, 204); + } + async get(req, res) { + const apiMatch = this.parseAPIRequest(req); + const url = apiMatch?.url ?? new URL(req.url ?? "", this.address); + if (apiMatch?.key) { + return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); + } + const { dataPath, key, metadataPath, rootPath } = this.getLocalPaths(url); + if (!dataPath || !metadataPath) { + return this.sendResponse(req, res, 400); + } + if (!key) { + return this.list({ dataPath, metadataPath, rootPath, req, res, url }); + } + this.onRequest({ type: "get" /* GET */ }); + const headers = {}; + try { + const rawData = await fs.readFile(metadataPath, "utf8"); + const metadata = JSON.parse(rawData); + const encodedMetadata = encodeMetadata(metadata); + if (encodedMetadata) { + headers[METADATA_HEADER_INTERNAL] = encodedMetadata; + } + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + this.logDebug("Could not read metadata file:", error); + } + } + for (const name in headers) { + res.setHeader(name, headers[name]); + } + const stream = createReadStream(dataPath); + stream.on("error", (error) => { + if (error.code === "EISDIR" || error.code === "ENOENT") { + return this.sendResponse(req, res, 404); + } + return this.sendResponse(req, res, 500); + }); + stream.pipe(res); + } + async head(req, res) { + const url = this.parseAPIRequest(req)?.url ?? new URL(req.url ?? "", this.address); + const { dataPath, key, metadataPath } = this.getLocalPaths(url); + if (!dataPath || !metadataPath || !key) { + return this.sendResponse(req, res, 400); + } + try { + const rawData = await fs.readFile(metadataPath, "utf8"); + const metadata = JSON.parse(rawData); + const encodedMetadata = encodeMetadata(metadata); + if (encodedMetadata) { + res.setHeader(METADATA_HEADER_INTERNAL, encodedMetadata); + } + } catch (error) { + if (isNodeError(error) && (error.code === "ENOENT" || error.code === "ISDIR")) { + return this.sendResponse(req, res, 404); + } + this.logDebug("Could not read metadata file:", error); + return this.sendResponse(req, res, 500); + } + res.end(); + } + async list(options) { + this.onRequest({ type: "list" /* LIST */ }); + const { dataPath, rootPath, req, res, url } = options; + const directories = url.searchParams.get("directories") === "true"; + const prefix = url.searchParams.get("prefix") ?? ""; + const result = { + blobs: [], + directories: [] + }; + try { + await _BlobsServer.walk({ directories, path: dataPath, prefix, rootPath, result }); + } catch (error) { + if (!isNodeError(error) || error.code !== "ENOENT") { + this.logDebug("Could not perform list:", error); + return this.sendResponse(req, res, 500); + } + } + res.setHeader("content-type", "application/json"); + return this.sendResponse(req, res, 200, JSON.stringify(result)); + } + async put(req, res) { + const apiMatch = this.parseAPIRequest(req); + if (apiMatch) { + return this.sendResponse(req, res, 200, JSON.stringify({ url: apiMatch.url.toString() })); + } + const url = new URL(req.url ?? "", this.address); + const { dataPath, key, metadataPath } = this.getLocalPaths(url); + if (!dataPath || !key || !metadataPath) { + return this.sendResponse(req, res, 400); + } + const metadataHeader = req.headers[METADATA_HEADER_INTERNAL]; + const metadata = decodeMetadata(Array.isArray(metadataHeader) ? metadataHeader[0] : metadataHeader ?? null); + try { + const tempDirectory = await fs.mkdtemp(join(tmpdir(), "netlify-blobs")); + const relativeDataPath = relative(this.directory, dataPath); + const tempDataPath = join(tempDirectory, relativeDataPath); + await fs.mkdir(dirname(tempDataPath), { recursive: true }); + await new Promise((resolve2, reject) => { + req.pipe(createWriteStream(tempDataPath)); + req.on("end", resolve2); + req.on("error", reject); + }); + await fs.mkdir(dirname(dataPath), { recursive: true }); + await fs.rename(tempDataPath, dataPath); + await fs.rm(tempDirectory, { force: true, recursive: true }); + await fs.mkdir(dirname(metadataPath), { recursive: true }); + await fs.writeFile(metadataPath, JSON.stringify(metadata)); + } catch (error) { + this.logDebug("Error when writing data:", error); + return this.sendResponse(req, res, 500); + } + return this.sendResponse(req, res, 200); + } + /** + * Parses the URL and returns the filesystem paths where entries and metadata + * should be stored. + */ + getLocalPaths(url) { + if (!url) { + return {}; + } + const [, siteID, rawStoreName, ...key] = url.pathname.split("/"); + if (!siteID || !rawStoreName) { + return {}; + } + const storeName = platform === "win32" ? encodeURIComponent(rawStoreName) : rawStoreName; + const rootPath = resolve(this.directory, "entries", siteID, storeName); + const dataPath = resolve(rootPath, ...key); + const metadataPath = resolve(this.directory, "metadata", siteID, storeName, ...key); + return { dataPath, key: key.join("/"), metadataPath, rootPath }; + } + handleRequest(req, res) { + if (!req.url || !this.validateAccess(req)) { + return this.sendResponse(req, res, 403); + } + switch (req.method?.toLowerCase()) { + case "delete" /* DELETE */: { + this.onRequest({ type: "delete" /* DELETE */ }); + return this.delete(req, res); + } + case "get" /* GET */: { + return this.get(req, res); + } + case "put" /* PUT */: { + this.onRequest({ type: "set" /* SET */ }); + return this.put(req, res); + } + case "head" /* HEAD */: { + this.onRequest({ type: "getMetadata" /* GET_METADATA */ }); + return this.head(req, res); + } + default: + return this.sendResponse(req, res, 405); + } + } + /** + * Tries to parse a URL as being an API request and returns the different + * components, such as the store name, site ID, key, and signed URL. + */ + parseAPIRequest(req) { + if (!req.url) { + return null; + } + const apiURLMatch = req.url.match(API_URL_PATH); + if (!apiURLMatch) { + return null; + } + const fullURL = new URL(req.url, this.address); + const storeName = fullURL.searchParams.get("context") ?? DEFAULT_STORE; + const key = apiURLMatch.groups?.key; + const siteID = apiURLMatch.groups?.site_id; + const urlPath = [siteID, storeName, key].filter(Boolean); + const url = new URL(`/${urlPath.join("/")}?signature=${this.tokenHash}`, this.address); + return { + key, + siteID, + storeName, + url + }; + } + sendResponse(req, res, status, body) { + this.logDebug(`${req.method} ${req.url} ${status}`); + res.writeHead(status); + res.end(body); + } + async start() { + await fs.mkdir(this.directory, { recursive: true }); + const server = http.createServer((req, res) => this.handleRequest(req, res)); + this.server = server; + return new Promise((resolve2, reject) => { + server.listen(this.port, () => { + const address = server.address(); + if (!address || typeof address === "string") { + return reject(new Error("Server cannot be started on a pipe or Unix socket")); + } + this.address = `http://localhost:${address.port}`; + resolve2(address); + }); + }); + } + async stop() { + if (!this.server) { + return; + } + await new Promise((resolve2, reject) => { + this.server?.close((error) => { + if (error) { + return reject(error); + } + resolve2(null); + }); + }); + } + validateAccess(req) { + if (!this.token) { + return true; + } + const { authorization = "" } = req.headers; + const parts = authorization.split(" "); + if (parts.length === 2 || parts[0].toLowerCase() === "bearer" && parts[1] === this.token) { + return true; + } + if (!req.url) { + return false; + } + const url = new URL(req.url, this.address); + const signature = url.searchParams.get("signature"); + if (signature === this.tokenHash) { + return true; + } + return false; + } + /** + * Traverses a path and collects both blobs and directories into a `result` + * object, taking into account the `directories` and `prefix` parameters. + */ + static async walk(options) { + const { directories, path, prefix, result, rootPath } = options; + const entries = await fs.readdir(path); + for (const entry of entries) { + const entryPath = join(path, entry); + const stat = await fs.stat(entryPath); + let key = relative(rootPath, entryPath); + if (sep !== "/") { + key = key.split(sep).join("/"); + } + const mask = key.slice(0, prefix.length); + const isMatch = prefix.startsWith(mask); + if (!isMatch) { + continue; + } + if (!stat.isDirectory()) { + const etag = Math.random().toString().slice(2); + result.blobs?.push({ + etag, + key, + last_modified: stat.mtime.toISOString(), + size: stat.size + }); + continue; + } + if (directories && key.startsWith(prefix)) { + result.directories?.push(key); + continue; + } + await _BlobsServer.walk({ directories, path: entryPath, prefix, rootPath, result }); + } + } +}; +export { + BlobsServer, + getDeployStore, + getStore +}; diff --git a/node_modules/@netlify/blobs/package.json b/node_modules/@netlify/blobs/package.json new file mode 100644 index 0000000..5a5ad5c --- /dev/null +++ b/node_modules/@netlify/blobs/package.json @@ -0,0 +1,83 @@ +{ + "name": "@netlify/blobs", + "version": "6.3.1", + "description": "A JavaScript client for the Netlify Blob Store", + "type": "module", + "engines": { + "node": "^14.16.0 || >=16.0.0" + }, + "main": "./dist/main.cjs", + "module": "./dist/main.js", + "types": "./dist/main.d.ts", + "exports": { + ".": { + "require": { + "types": "./dist/main.d.cts", + "default": "./dist/main.cjs" + }, + "import": { + "types": "./dist/main.d.ts", + "default": "./dist/main.js" + }, + "default": { + "types": "./dist/main.d.ts", + "default": "./dist/main.js" + } + }, + "./package.json": "./package.json" + }, + "files": [ + "dist/**/*" + ], + "scripts": { + "build": "run-s build:*", + "build:check": "tsc", + "build:transpile": "node build.mjs", + "dev": "node build.mjs --watch", + "prepare": "husky install node_modules/@netlify/eslint-config-node/.husky/", + "prepublishOnly": "npm ci && npm test", + "prepack": "npm run build", + "test": "run-s build format test:dev", + "format": "run-s build format:check-fix:*", + "format:ci": "run-s build format:check:*", + "format:check-fix:lint": "run-e format:check:lint format:fix:lint", + "format:check:lint": "cross-env-shell eslint $npm_package_config_eslint", + "format:fix:lint": "cross-env-shell eslint --fix $npm_package_config_eslint", + "format:check-fix:prettier": "run-e format:check:prettier format:fix:prettier", + "format:check:prettier": "cross-env-shell prettier --check $npm_package_config_prettier", + "format:fix:prettier": "cross-env-shell prettier --write $npm_package_config_prettier", + "test:dev": "run-s build test:dev:*", + "test:ci": "run-s build test:ci:*", + "test:dev:vitest": "vitest run", + "test:dev:vitest:watch": "vitest watch", + "test:ci:vitest": "vitest run" + }, + "config": { + "eslint": "--ignore-path .gitignore --cache --format=codeframe --max-warnings=0 \"{src,scripts,.github}/**/*.{js,ts,md,html}\" \"*.{js,ts,md,html}\"", + "prettier": "--ignore-path .gitignore --loglevel=warn \"{src,scripts,.github}/**/*.{js,ts,md,yml,json,html}\" \"*.{js,ts,yml,json,html}\" \".*.{js,ts,yml,json,html}\" \"!**/package-lock.json\" \"!package-lock.json\"" + }, + "keywords": [], + "license": "MIT", + "repository": "netlify/blobs", + "bugs": { + "url": "https://github.com/netlify/blobs/issues" + }, + "author": "Netlify Inc.", + "directories": { + "test": "test" + }, + "devDependencies": { + "@commitlint/cli": "^17.0.0", + "@commitlint/config-conventional": "^17.0.0", + "@netlify/eslint-config-node": "^7.0.1", + "c8": "^7.11.0", + "esbuild": "^0.19.0", + "husky": "^8.0.0", + "node-fetch": "^3.3.1", + "semver": "^7.5.3", + "tmp-promise": "^3.0.3", + "tsup": "^7.2.0", + "typescript": "^5.0.0", + "vitest": "^0.34.0" + } +} diff --git a/node_modules/@netlify/edge-functions/README.md b/node_modules/@netlify/edge-functions/README.md new file mode 100644 index 0000000..f0850ad --- /dev/null +++ b/node_modules/@netlify/edge-functions/README.md @@ -0,0 +1,3 @@ +# Netlify Edge Functions + +TypeScript types for [Netlify Edge Functions](https://docs.netlify.com/edge-functions/api/). diff --git a/node_modules/@netlify/edge-functions/node/README.md b/node_modules/@netlify/edge-functions/node/README.md new file mode 100644 index 0000000..f0850ad --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/README.md @@ -0,0 +1,3 @@ +# Netlify Edge Functions + +TypeScript types for [Netlify Edge Functions](https://docs.netlify.com/edge-functions/api/). diff --git a/node_modules/@netlify/edge-functions/node/dist/bootstrap/config.d.ts b/node_modules/@netlify/edge-functions/node/dist/bootstrap/config.d.ts new file mode 100644 index 0000000..ef05b38 --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/dist/bootstrap/config.d.ts @@ -0,0 +1,16 @@ +type Cache = "off" | "manual"; +type Path = `/${string}`; +type OnError = "fail" | "bypass" | Path; +type HTTPMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "OPTIONS"; +export interface Config { + cache?: Cache; + excludedPath?: Path | Path[]; + onError?: OnError; + path?: Path | Path[]; + method?: HTTPMethod | HTTPMethod[]; +} +export interface IntegrationsConfig extends Config { + name?: string; + generator?: string; +} +export {}; diff --git a/node_modules/@netlify/edge-functions/node/dist/bootstrap/context.d.ts b/node_modules/@netlify/edge-functions/node/dist/bootstrap/context.d.ts new file mode 100644 index 0000000..8900575 --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/dist/bootstrap/context.d.ts @@ -0,0 +1,61 @@ +import type { Cookies } from "./cookie.ts"; +export interface Geo { + city?: string; + country?: { + code?: string; + name?: string; + }; + subdivision?: { + code?: string; + name?: string; + }; + timezone?: string; + latitude?: number; + longitude?: number; +} +export interface Account { + id?: string; +} +export interface Site { + id?: string; + name?: string; + url?: string; +} +export interface Deploy { + id?: string; +} +export interface Context { + cookies: Cookies; + geo: Geo; + ip: string; + /** + * @deprecated Use [`Response.json`](https://fetch.spec.whatwg.org/#ref-for-dom-response-json①) instead. + */ + json(input: unknown, init?: ResponseInit): Response; + /** + * @deprecated Use `console.log` instead. + */ + log(...data: unknown[]): void; + next(options?: NextOptions): Promise; + /** + * @param request `Request` to be passed down the request chain. Defaults to the original `request` object passed into the Edge Function. + */ + next(request: Request, options?: NextOptions): Promise; + requestId: string; + /** + * @deprecated Use a `URL` object instead: https://ntl.fyi/edge-rewrite + */ + rewrite(url: string | URL): Promise; + site: Site; + account: Account; + server: ServerMetadata; + deploy: Deploy; + params: Record; +} +export interface NextOptions { + sendConditionalRequest?: boolean; +} +interface ServerMetadata { + region: string; +} +export {}; diff --git a/node_modules/@netlify/edge-functions/node/dist/bootstrap/cookie.d.ts b/node_modules/@netlify/edge-functions/node/dist/bootstrap/cookie.d.ts new file mode 100644 index 0000000..0e6ff9b --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/dist/bootstrap/cookie.d.ts @@ -0,0 +1,62 @@ +export interface Cookie { + /** Name of the cookie. */ + name: string; + /** Value of the cookie. */ + value: string; + /** The cookie's `Expires` attribute, either as an explicit date or UTC milliseconds. + * @example Explicit date: + * + * ```ts + * import { Cookie } from "https://deno.land/std@$STD_VERSION/http/cookie.ts"; + * const cookie: Cookie = { + * name: 'name', + * value: 'value', + * // expires on Fri Dec 30 2022 + * expires: new Date('2022-12-31') + * } + * ``` + * + * @example UTC milliseconds + * + * ```ts + * import { Cookie } from "https://deno.land/std@$STD_VERSION/http/cookie.ts"; + * const cookie: Cookie = { + * name: 'name', + * value: 'value', + * // expires 10 seconds from now + * expires: Date.now() + 10000 + * } + * ``` + */ + expires?: Date | number; + /** The cookie's `Max-Age` attribute, in seconds. Must be a non-negative integer. A cookie with a `maxAge` of `0` expires immediately. */ + maxAge?: number; + /** The cookie's `Domain` attribute. Specifies those hosts to which the cookie will be sent. */ + domain?: string; + /** The cookie's `Path` attribute. A cookie with a path will only be included in the `Cookie` request header if the requested URL matches that path. */ + path?: string; + /** The cookie's `Secure` attribute. If `true`, the cookie will only be included in the `Cookie` request header if the connection uses SSL and HTTPS. */ + secure?: boolean; + /** The cookie's `HTTPOnly` attribute. If `true`, the cookie cannot be accessed via JavaScript. */ + httpOnly?: boolean; + /** + * Allows servers to assert that a cookie ought not to + * be sent along with cross-site requests. + */ + sameSite?: "Strict" | "Lax" | "None"; + /** Additional key value pairs with the form "key=value" */ + unparsed?: string[]; +} +export interface DeleteCookieOptions { + domain?: string; + name: string; + path?: string; +} +export interface Cookies { + delete(name: string): void; + delete(options: DeleteCookieOptions): void; + get(name: string): string; + get(cookie: Pick): string; + set(name: string, value: string): void; + set(input: Cookie): void; +} diff --git a/node_modules/@netlify/edge-functions/node/dist/bootstrap/edge_function.d.ts b/node_modules/@netlify/edge-functions/node/dist/bootstrap/edge_function.d.ts new file mode 100644 index 0000000..e97d012 --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/dist/bootstrap/edge_function.d.ts @@ -0,0 +1,5 @@ +import type { Context } from "./context.ts"; +interface EdgeFunction { + (request: Request, context: Context): Response | Promise | URL | Promise | void | Promise; +} +export type { EdgeFunction }; diff --git a/node_modules/@netlify/edge-functions/node/dist/bootstrap/globals.d.ts b/node_modules/@netlify/edge-functions/node/dist/bootstrap/globals.d.ts new file mode 100644 index 0000000..e68f2fd --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/dist/bootstrap/globals.d.ts @@ -0,0 +1,26 @@ +declare global { + var Netlify: { + env: typeof env; + }; +} +declare const env: { + delete: (key: string) => void; + get: (key: string) => string | undefined; + has: (key: string) => boolean; + set: (key: string, value: string) => void; + toObject: () => { + [index: string]: string; + }; +}; +export declare const Netlify: { + env: { + delete: (key: string) => void; + get: (key: string) => string | undefined; + has: (key: string) => boolean; + set: (key: string, value: string) => void; + toObject: () => { + [index: string]: string; + }; + }; +}; +export {}; diff --git a/node_modules/@netlify/edge-functions/node/dist/index.d.ts b/node_modules/@netlify/edge-functions/node/dist/index.d.ts new file mode 100644 index 0000000..4aa6442 --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/dist/index.d.ts @@ -0,0 +1,4 @@ +import "./bootstrap/globals.ts"; +export type { Config, IntegrationsConfig } from "./bootstrap/config.ts"; +export type { Context } from "./bootstrap/context.ts"; +export type { EdgeFunction } from "./bootstrap/edge_function.ts"; diff --git a/node_modules/@netlify/edge-functions/node/dist/index.js b/node_modules/@netlify/edge-functions/node/dist/index.js new file mode 100644 index 0000000..f0a766d --- /dev/null +++ b/node_modules/@netlify/edge-functions/node/dist/index.js @@ -0,0 +1 @@ +export { } diff --git a/node_modules/@netlify/edge-functions/package.json b/node_modules/@netlify/edge-functions/package.json new file mode 100644 index 0000000..1d4827e --- /dev/null +++ b/node_modules/@netlify/edge-functions/package.json @@ -0,0 +1,14 @@ +{ + "name": "@netlify/edge-functions", + "version": "2.2.0", + "description": "TypeScript types for Netlify Edge Functions", + "main": "index.js", + "types": "node/dist/index.d.ts", + "devDependencies": { + "typescript": "^5.1.6" + }, + "files": [ + "node/dist/*.js", + "node/dist/**/*.d.ts" + ] +} \ No newline at end of file diff --git a/node_modules/website b/node_modules/website new file mode 120000 index 0000000..d55c2e1 --- /dev/null +++ b/node_modules/website @@ -0,0 +1 @@ +../projects/website \ No newline at end of file diff --git a/package-lock.json b/package-lock.json deleted file mode 100644 index e3560c9..0000000 --- a/package-lock.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "netlify-blobs", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "netlify-blobs", - "version": "1.0.0", - "license": "ISC", - "dependencies": { - "@netlify/blobs": "^6.3.1", - "@netlify/edge-functions": "^2.2.0" - } - }, - "node_modules/@netlify/blobs": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/@netlify/blobs/-/blobs-6.3.1.tgz", - "integrity": "sha512-JjLz3WW7Wp6NVwQtDxPpWio4L3u9pnnDXnQ7Q16zgAFE9IA1rSjZVSsyOQrtkiBQIxaJ1Zr5eky8vrXJ5mdRWg==", - "engines": { - "node": "^14.16.0 || >=16.0.0" - } - }, - "node_modules/@netlify/edge-functions": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/@netlify/edge-functions/-/edge-functions-2.2.0.tgz", - "integrity": "sha512-8UeKA2nUDB0oWE+Z0gLpA7wpLq8nM+NrZEQMfSdzfMJNvmYVabil/mS07rb0EBrUxM9PCKidKenaiCRnPTBSKw==" - } - } -} diff --git a/package.json b/package.json index f86e26a..7fbe9ec 100644 --- a/package.json +++ b/package.json @@ -1,16 +1,13 @@ { "name": "netlify-blobs", - "version": "1.0.0", - "description": "", - "main": "index.js", - "scripts": { - "test": "echo \"Error: no test specified\" && exit 1" + "private": true, + "workspaces": { + "packages": [ + "projects/*" + ] }, - "keywords": [], - "author": "", - "license": "ISC", - "dependencies": { - "@netlify/blobs": "^6.3.1", - "@netlify/edge-functions": "^2.2.0" - } + "description": "", + "scripts": {}, + "author": "brunos3d", + "license": "UNLICENSED" } diff --git a/.gitignore b/projects/website/.gitignore similarity index 100% rename from .gitignore rename to projects/website/.gitignore diff --git a/netlify.toml b/projects/website/netlify.toml similarity index 100% rename from netlify.toml rename to projects/website/netlify.toml diff --git a/projects/website/netlify/edge-functions/hello.ts b/projects/website/netlify/edge-functions/hello.ts new file mode 100644 index 0000000..f222e82 --- /dev/null +++ b/projects/website/netlify/edge-functions/hello.ts @@ -0,0 +1,25 @@ +import type { Context, Config } from '@netlify/edge-functions'; +import { getStore, type Store } from '@netlify/blobs'; + +import { postUploadSnapshot } from '../../routes/postUploadSnapshot.ts'; +import { getSnapshotList } from '../../routes/getSnapshotList.ts'; + +export default async (request: Request, context: Context) => { + try { + switch (request.method) { + case 'POST': + return postUploadSnapshot(request); + case 'GET': + return getSnapshotList(); + default: + return new Response('Method not allowed', { status: 405 }); + } + } catch (e) { + console.error(e); + return new Response('Internal Error', { status: 500 }); + } +}; + +export const config: Config = { + path: '/*', +}; diff --git a/projects/website/package.json b/projects/website/package.json new file mode 100644 index 0000000..218684a --- /dev/null +++ b/projects/website/package.json @@ -0,0 +1,16 @@ +{ + "name": "website", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "UNLICENSED", + "dependencies": { + "@netlify/blobs": "^6.3.1", + "@netlify/edge-functions": "^2.2.0" + } +} diff --git a/projects/website/routes/getSnapshotList.ts b/projects/website/routes/getSnapshotList.ts new file mode 100644 index 0000000..99a0ddd --- /dev/null +++ b/projects/website/routes/getSnapshotList.ts @@ -0,0 +1,10 @@ +import { getStore } from '@netlify/blobs'; + +export async function getSnapshotList() { + const zeSnapshotStore = getStore('ze_snapshots'); + const list = await zeSnapshotStore.list(); + const response = { + keys: list?.blobs?.map((blob) => ({ name: blob })), + }; + return new Response(JSON.stringify(response), { status: 200 }); +} diff --git a/projects/website/routes/postUploadSnapshot.ts b/projects/website/routes/postUploadSnapshot.ts new file mode 100644 index 0000000..bd09166 --- /dev/null +++ b/projects/website/routes/postUploadSnapshot.ts @@ -0,0 +1,23 @@ +import { getStore } from '@netlify/blobs'; + +interface SnapshotTemp { + id: string; + assets: Record; + message: string; +} + +export async function postUploadSnapshot(request: Request) { + const newSnapshot = (await request.json()) as SnapshotTemp; + + console.log('snapshot', newSnapshot); + + const zeSnapshotsStore = getStore('ze_snapshots'); + + console.log('before setJSON'); + + await zeSnapshotsStore.setJSON(newSnapshot.id, newSnapshot); + + console.log('after setJSON'); + + return new Response('Blob successfully stored', { status: 200 }); +} diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 0000000..2c4c6d7 --- /dev/null +++ b/yarn.lock @@ -0,0 +1,13 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@netlify/blobs@^6.3.1": + version "6.3.1" + resolved "https://registry.yarnpkg.com/@netlify/blobs/-/blobs-6.3.1.tgz#9ed1fd788ef3f23d749487830fc557504ca447c1" + integrity sha512-JjLz3WW7Wp6NVwQtDxPpWio4L3u9pnnDXnQ7Q16zgAFE9IA1rSjZVSsyOQrtkiBQIxaJ1Zr5eky8vrXJ5mdRWg== + +"@netlify/edge-functions@^2.2.0": + version "2.2.0" + resolved "https://registry.yarnpkg.com/@netlify/edge-functions/-/edge-functions-2.2.0.tgz#5f7f5c7602a7f98888a4b4421576ca609dad2083" + integrity sha512-8UeKA2nUDB0oWE+Z0gLpA7wpLq8nM+NrZEQMfSdzfMJNvmYVabil/mS07rb0EBrUxM9PCKidKenaiCRnPTBSKw==