@lens-chain/storage-client
Version:
The easiest way to store data on Lens Grove.
694 lines (687 loc) • 20.3 kB
JavaScript
// src/builders.ts
function walletOnly(address, chainId) {
return { template: "wallet_address", walletAddress: address, chainId };
}
function lensAccountOnly(account, chainId) {
return { template: "lens_account", chainId, lensAccount: account };
}
function immutable(chainId) {
return { template: "immutable", chainId };
}
function genericAcl(chainId) {
return new GenericAclTemplateBuilder(chainId);
}
var GenericAclTemplateBuilder = class {
acl = { template: "generic_acl" };
constructor(chainId) {
this.acl.chainId = chainId;
}
reset() {
this.acl = { template: "generic_acl" };
}
withContractAddress(contractAddress) {
this.acl.contractAddress = contractAddress;
return this;
}
withFunctionSig(functionSig) {
this.acl.functionSig = functionSig;
return this;
}
withParams(params) {
this.acl.params = params;
return this;
}
build() {
if (!this.isValid(this.acl)) {
throw new Error("GenericAclTemplate is missing required fields");
}
return this.acl;
}
isValid(acl) {
return !!(acl.template === "generic_acl" && acl.contractAddress && acl.functionSig && acl.params && acl.chainId);
}
};
// src/environments.ts
var production = {
name: "production",
backend: "https://api.grove.storage",
defaultChainId: 232,
cachingTimeout: 5e3,
propagationTimeout: 1e4,
statusPollingInterval: 500
};
var staging = {
name: "staging",
backend: "https://api.staging.grove.storage",
defaultChainId: 37111,
cachingTimeout: 1e4,
propagationTimeout: 2e4,
statusPollingInterval: 500
};
var local = {
name: "local",
backend: "http://localhost:30371110",
defaultChainId: 37111,
cachingTimeout: 0,
// no caching
propagationTimeout: 3e4,
statusPollingInterval: 500
};
// src/errors.ts
var BaseError = class _BaseError extends Error {
static async fromResponse(response) {
try {
const { message } = await response.clone().json();
return new this(message);
} catch (_) {
return new this(await response.text());
}
}
static from(args) {
if (args instanceof Error) {
const message = _BaseError.formatMessage(args);
return new this(message, { cause: args });
}
return new this(String(args));
}
static formatMessage(cause) {
const messages = [];
let currentError = cause;
while (currentError instanceof Error) {
messages.push(currentError.message);
currentError = currentError.cause;
}
return messages.join(" due to ");
}
};
var AuthorizationError = class extends BaseError {
name = "AuthorizationError";
constructor(message) {
super(message);
}
};
var StorageClientError = class extends BaseError {
name = "StorageClientError";
constructor(message) {
super(message);
}
};
var InvariantError = class extends Error {
name = "InvariantError";
};
// src/AuthorizationService.ts
var AuthorizationService = class {
constructor(env) {
this.env = env;
}
async authorize(action, storageKey, signer) {
const challenge = await this.requestChallenge(action, storageKey);
const signature = await signer.signMessage({
message: challenge.message
});
const { challenge_cid } = await this.submitSignedChallenge({
...challenge,
signature
});
return {
challengeId: challenge_cid,
secret: challenge.secret_random
};
}
async requestChallenge(action, storageKey) {
const response = await fetch(`${this.env.backend}/challenge/new`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify({
storage_key: storageKey,
action
})
});
if (!response.ok) {
throw await AuthorizationError.fromResponse(response);
}
return response.json();
}
async submitSignedChallenge(challenge) {
const response = await fetch(`${this.env.backend}/challenge/sign`, {
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(challenge)
});
if (!response.ok) {
throw await AuthorizationError.fromResponse(response);
}
return response.json();
}
};
// src/types.ts
var RECOVERED_ADDRESS_PARAM_MARKER = "<recovered_address>";
var UploadResponse = class {
constructor(resource, client) {
this.resource = resource;
this.client = client;
}
/**
* Wait until the resource is fully propagated to the underlying storage infrastructure.
*
* Edit and delete operations are only allowed after the resource if fully propagated.
*
* @throws a {@link StorageClientError} if the operation fails or times out.
*/
async waitForPropagation() {
return this.client.waitUntilStatus(
this.resource.storageKey,
["done"],
this.client.env.propagationTimeout
);
}
};
var FileUploadResponse = class extends UploadResponse {
/**
* The `lens://…` URI of the file.
*/
uri;
/**
* The storage key of the file.
*/
storageKey;
/**
* The gateway URL of this file.
*/
gatewayUrl;
constructor(resource, client) {
super(resource, client);
this.uri = resource.uri;
this.storageKey = resource.storageKey;
this.gatewayUrl = resource.gatewayUrl;
}
};
// src/utils.ts
function invariant(condition, message) {
if (!condition) {
throw new InvariantError(message);
}
}
function never(message = "Unexpected call to never()") {
throw new InvariantError(message);
}
function delay(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function* multipartStream(entries, boundary) {
for (const { name, file } of entries) {
yield `--${boundary}\r
`;
yield `Content-Disposition: form-data; name="${name}"; filename="${file.name}"\r
`;
yield `Content-Type: ${file.type}\r
\r
`;
const reader = file.stream().getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
yield value;
}
yield "\r\n";
}
yield `--${boundary}--\r
`;
}
function createMultipartStream(entries) {
const boundary = `----WebKitFormBoundary${Math.random().toString(36).substring(2)}`;
return {
stream: new ReadableStream({
async start(controller) {
for await (const chunk of multipartStream(entries, boundary)) {
if (typeof chunk === "string") {
controller.enqueue(new TextEncoder().encode(chunk));
} else {
controller.enqueue(chunk);
}
}
controller.close();
}
}),
boundary
};
}
async function detectStreamSupport() {
let duplexAccessed = false;
const testStream = new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array([0]));
controller.close();
}
});
const request = new Request("data:text/plain;charset=utf-8,42", {
body: testStream,
method: "POST",
// @ts-ignore
get duplex() {
duplexAccessed = true;
return "half";
}
});
try {
const hasContentType = request.headers.has("Content-Type");
await fetch(request.clone());
const body = await request.text();
return duplexAccessed && !hasContentType && body === "\0";
} catch (_) {
return false;
}
}
function createFormData(entries) {
const formData = new FormData();
for (const { name, file } of entries) {
formData.append(name, file, file.name);
}
return formData;
}
function computeMultipartSize(entries, boundary) {
let size = 0;
const encoder = new TextEncoder();
for (const { name, file } of entries) {
size += encoder.encode(`--${boundary}\r
`).length;
size += encoder.encode(
`Content-Disposition: form-data; name="${name}"; filename="${file.name}"\r
`
).length;
size += encoder.encode(`Content-Type: ${file.type}\r
\r
`).length;
size += file.size;
size += encoder.encode("\r\n").length;
}
size += encoder.encode(`--${boundary}--\r
`).length;
return size;
}
async function createMultipartRequestInit(method, entries) {
if (await detectStreamSupport()) {
const { stream, boundary } = createMultipartStream(entries);
return {
method,
headers: {
"Content-Type": `multipart/form-data; boundary=${boundary}`,
"Content-Length": computeMultipartSize(entries, boundary).toString()
},
body: stream,
// @ts-ignore
duplex: "half"
// Required for streaming request body in some browsers
};
}
const formData = createFormData(entries);
return {
method,
body: formData
};
}
var LENS_SCHEME = "lens";
var LENS_URI_SUFFIX = `${LENS_SCHEME}://`;
function extractStorageKey(storageKeyOrUri) {
if (storageKeyOrUri.startsWith(LENS_URI_SUFFIX)) {
return storageKeyOrUri.slice(LENS_URI_SUFFIX.length);
}
return storageKeyOrUri;
}
function resourceFrom(storageKey, env) {
return {
storageKey,
gatewayUrl: `${env.backend}/${storageKey}`,
uri: `${LENS_SCHEME}://${storageKey}`
};
}
function statusResponseFrom(data) {
return {
status: data.status,
storageKey: data.storage_key,
progress: data.progress
};
}
function createAclTemplateContent(acl) {
switch (acl.template) {
case "generic_acl":
return {
template: acl.template,
contract_address: acl.contractAddress,
chain_id: acl.chainId,
network_type: "evm",
function_sig: acl.functionSig,
params: acl.params
};
case "lens_account":
return {
template: acl.template,
lens_account: acl.lensAccount,
chain_id: acl.chainId
};
case "wallet_address":
return {
template: acl.template,
wallet_address: acl.walletAddress,
chain_id: acl.chainId
};
case "immutable":
return {
template: acl.template,
chain_id: acl.chainId
};
default:
never(`Unknown ACL template: ${acl}`);
}
}
function createAclEntry(template) {
const name = "lens-acl.json";
const content = createAclTemplateContent(template);
return {
name,
file: new File([JSON.stringify(content)], name, {
type: "application/json"
})
};
}
function createDefaultIndexContent(files) {
return {
files: files.map((file) => file.storageKey)
};
}
function createIndexFile(content) {
return new File([JSON.stringify(content)], "index.json", {
type: "application/json"
});
}
var MultipartEntriesBuilder = class _MultipartEntriesBuilder {
constructor(allocations) {
this.allocations = allocations;
}
idx = 0;
entries = [];
static from(allocations) {
return new _MultipartEntriesBuilder(allocations);
}
withFile(file) {
this.entries.push({
name: this.allocations[this.idx++]?.storageKey ?? never("Unexpected file, no storage key available"),
file
});
return this;
}
withFiles(files) {
for (const file of files) {
this.withFile(file);
}
return this;
}
withAclTemplate(template) {
this.entries.push(createAclEntry(template));
return this;
}
withIndexFile(index) {
const file = index instanceof File ? index : createIndexFile(
index === true ? createDefaultIndexContent(this.allocations) : index.call(null, this.allocations.slice())
// shallow copy
);
invariant(
file.name === "index.json",
"Index file must be named 'index.json'"
);
return this.withFile(file);
}
build() {
return this.entries;
}
};
// src/StorageClient.ts
var StorageClient = class _StorageClient {
constructor(env) {
this.env = env;
this.authorization = new AuthorizationService(env);
}
authorization;
/**
* Creates a new instance of the `Storage` client.
*
* @param env - the environment configuration
* @returns The `Storage` client instance
*/
static create(env = production) {
return new _StorageClient(env);
}
async uploadFile(file, { acl } = { acl: immutable(this.env.defaultChainId) }) {
const resource = acl.template === "immutable" ? await this.uploadImmutableFile(file, acl) : await this.uploadMutableFile(file, acl);
await this.waitUntilStatus(
resource.storageKey,
["done", "available"],
this.env.cachingTimeout
);
return new FileUploadResponse(resource, this);
}
async uploadAsJson(json, options = { acl: immutable(this.env.defaultChainId) }) {
const file = new File([JSON.stringify(json)], options.name ?? "data.json", {
type: "application/json"
});
return this.uploadFile(file, options);
}
async uploadFolder(files, options = { acl: immutable(this.env.defaultChainId) }) {
const withIndexFile = "index" in options && !!options.index;
const [folderResource, ...fileResources] = await this.allocateStorage(
files.length + (withIndexFile ? 2 : 1)
);
const builder = MultipartEntriesBuilder.from(fileResources).withFiles(
Array.from(files)
);
if (options.index) {
builder.withIndexFile(options.index);
}
builder.withAclTemplate(options.acl);
const entries = builder.build();
const response = await this.upload(folderResource.storageKey, entries);
if (!response.ok) {
throw await StorageClientError.fromResponse(response);
}
await this.waitUntilStatus(
// biome-ignore lint/style/noNonNullAssertion: we know the folder has at least one file
withIndexFile ? folderResource.storageKey : fileResources[0].storageKey,
["done", "available"],
this.env.cachingTimeout
);
return {
folder: folderResource,
files: fileResources
};
}
/**
* Given an URI or storage key, resolves it to a URL.
*
* @param storageKeyOrUri - The `lens://…` URI or storage key
* @returns The URL to the resource
*/
resolve(storageKeyOrUri) {
const storageKey = extractStorageKey(storageKeyOrUri);
return `${this.env.backend}/${storageKey}`;
}
/**
* Deletes a resource from the storage.
*
* @throws a {@link AuthorizationError} if not authorized to delete the resource
* @param storageKeyOrUri - The `lens://…` URI or storage key
* @param signer - The signer to use for the deletion
* @returns The deletion result.
*/
async delete(storageKeyOrUri, signer) {
const storageKey = extractStorageKey(storageKeyOrUri);
const authorization = await this.authorization.authorize(
"delete",
storageKey,
signer
);
const response = await fetch(
`${this.env.backend}/${storageKey}?challenge_cid=${authorization.challengeId}&secret_random=${authorization.secret}`,
{
method: "DELETE"
}
);
return {
success: response.ok
};
}
/**
* Updates a JSON object in the storage.
*
* @throws a {@link StorageClientError} if editing the file fails
* @throws a {@link AuthorizationError} if not authorized to edit the file
* @param storageKeyOrUri - The `lens://…` URI or storage key
* @param json - The JSON object to upload
* @param signer - The signer to use for the edit
* @param options - Upload options including the ACL configuration
* @returns The {@link FileUploadResponse} to the uploaded JSON
*/
async updateJson(storageKeyOrUri, json, signer, options) {
const file = new File([JSON.stringify(json)], options.name ?? "data.json", {
type: "application/json"
});
return this.editFile(storageKeyOrUri, file, signer, options);
}
async editFile(storageKeyOrUri, newFile, signer, options = { acl: immutable(this.env.defaultChainId) }) {
const storageKey = extractStorageKey(storageKeyOrUri);
const authorization = await this.authorization.authorize(
"edit",
storageKey,
signer
);
const resource = resourceFrom(storageKey, this.env);
const builder = MultipartEntriesBuilder.from([resource]).withFile(newFile).withAclTemplate(options.acl);
const entries = builder.build();
const response = await this.update(storageKey, authorization, entries);
if (!response.ok) {
throw await StorageClientError.fromResponse(response);
}
return new FileUploadResponse(resource, this);
}
/**
* @internal
*/
async status(storageKeyOrUri) {
const storageKey = extractStorageKey(storageKeyOrUri);
const response = await fetch(`${this.env.backend}/status/${storageKey}`);
if (!response.ok) {
throw await StorageClientError.fromResponse(response);
}
try {
const data = await response.json();
return statusResponseFrom(data);
} catch (_) {
throw await StorageClientError.fromResponse(response);
}
}
/**
* @internal
*/
async waitUntilStatus(storageKeyOrUri, expectedStatuses, timeout) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeout) {
const { status } = await this.status(storageKeyOrUri);
switch (status) {
case "error_upload":
case "error_edit":
case "error_delete":
case "unauthorized":
throw StorageClientError.from(
`The resource ${storageKeyOrUri} has returned a '${status}' status.`
);
}
if (expectedStatuses.includes(status)) {
return;
}
await delay(this.env.statusPollingInterval);
}
throw StorageClientError.from(
`Timeout waiting for resource ${storageKeyOrUri} to reach status: ${expectedStatuses.join(" or ")}.`
);
}
async allocateStorage(amount) {
invariant(amount > 0, "Amount must be greater than 0");
const response = await fetch(
`${this.env.backend}/link/new?amount=${amount}`,
{
method: "POST"
}
);
if (!response.ok) {
throw await StorageClientError.fromResponse(response);
}
return this.parseResourceFrom(response);
}
async uploadMutableFile(file, acl) {
const [resource] = await this.allocateStorage(1);
const builder = MultipartEntriesBuilder.from([resource]).withFile(file).withAclTemplate(acl);
const entries = builder.build();
const response = await this.upload(resource.storageKey, entries);
if (!response.ok) {
throw await StorageClientError.fromResponse(response);
}
return resource;
}
async uploadImmutableFile(file, { chainId }) {
const response = await fetch(`${this.env.backend}?chain_id=${chainId}`, {
method: "POST",
headers: {
"Content-Type": file.type,
"Content-Length": file.size.toString()
},
body: file
});
if (!response.ok) {
throw await StorageClientError.fromResponse(response);
}
const [resource] = await this.parseResourceFrom(response);
return resource;
}
async upload(storageKey, entries) {
return this.multipartRequest(
"POST",
`${this.env.backend}/${storageKey}`,
entries
);
}
async update(storageKey, authorization, entries) {
const response = await this.multipartRequest(
"PUT",
`${this.env.backend}/${storageKey}?challenge_cid=${authorization.challengeId}&secret_random=${authorization.secret}`,
entries
);
await this.waitUntilStatus(
storageKey,
["done"],
this.env.propagationTimeout
);
return response;
}
async multipartRequest(method, url, entries) {
return fetch(url, await createMultipartRequestInit(method, entries));
}
parseResourceFrom = async (response) => {
const list = await response.json();
return list.map((data) => {
const storageKey = data.storage_key ?? never(`Missing 'storage_key' in response: ${JSON.stringify(data)}`);
return {
storageKey,
// TODO use data.gateway_url once fixed by the API
// gatewayUrl: data.gateway_url ?? never('Missing gateway URL'),
gatewayUrl: this.resolve(storageKey),
uri: data.uri ?? never(`Missing 'uri' in response: ${JSON.stringify(data)}`)
};
});
};
};
export { AuthorizationError, FileUploadResponse, InvariantError, LENS_SCHEME, MultipartEntriesBuilder, RECOVERED_ADDRESS_PARAM_MARKER, StorageClient, StorageClientError, createMultipartRequestInit, delay, extractStorageKey, genericAcl, immutable, invariant, lensAccountOnly, local, never, production, resourceFrom, staging, statusResponseFrom, walletOnly };
//# sourceMappingURL=index.js.map
//# sourceMappingURL=index.js.map