UNPKG

@lens-chain/storage-client

Version:

The easiest way to store data on Lens Grove.

670 lines (662 loc) 20 kB
'use strict'; // 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, propagationTimeout: 1e4, propagationPollingInterval: 500 }; var staging = { name: "staging", backend: "https://api.staging.grove.storage", defaultChainId: 37111, propagationTimeout: 2e4, propagationPollingInterval: 500 }; var local = { name: "local", backend: "http://localhost:30371110", defaultChainId: 37111, propagationTimeout: 3e4, propagationPollingInterval: 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 (error) { 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/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 (error) { 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 statusFrom(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/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() { const startedAt = Date.now(); while (Date.now() - startedAt < this.client.env.propagationTimeout) { try { const { status } = await this.client.status(this.resource.storageKey); switch (status) { case "done": return; case "error_upload": case "error_edit": case "error_delete": case "unauthorized": throw StorageClientError.from( `The resource ${this.resource.storageKey} has returned a '${status}' status.` ); default: await delay(this.client.env.propagationPollingInterval); break; } } catch (error) { console.log(error); throw StorageClientError.from(error); } } throw StorageClientError.from(`Timeout waiting for ${this.resource.uri} to be persisted.`); } }; 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/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); 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 needsIndex = "index" in options && !!options.index; const [folderResource, ...fileResources] = await this.allocateStorage( files.length + (needsIndex ? 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.create(folderResource.storageKey, entries); if (!response.ok) { throw await StorageClientError.fromResponse(response); } 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 statusFrom(data); } catch (error) { throw await StorageClientError.fromResponse(response); } } 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.create(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 create(storageKey, entries) { return this.multipartRequest("POST", `${this.env.backend}/${storageKey}`, entries); } async update(storageKey, authorization, entries) { return this.multipartRequest( "PUT", `${this.env.backend}/${storageKey}?challenge_cid=${authorization.challengeId}&secret_random=${authorization.secret}`, entries ); } 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)}`) }; }); }; }; exports.AuthorizationError = AuthorizationError; exports.FileUploadResponse = FileUploadResponse; exports.InvariantError = InvariantError; exports.LENS_SCHEME = LENS_SCHEME; exports.MultipartEntriesBuilder = MultipartEntriesBuilder; exports.RECOVERED_ADDRESS_PARAM_MARKER = RECOVERED_ADDRESS_PARAM_MARKER; exports.StorageClient = StorageClient; exports.StorageClientError = StorageClientError; exports.createMultipartRequestInit = createMultipartRequestInit; exports.delay = delay; exports.extractStorageKey = extractStorageKey; exports.genericAcl = genericAcl; exports.immutable = immutable; exports.invariant = invariant; exports.lensAccountOnly = lensAccountOnly; exports.local = local; exports.never = never; exports.production = production; exports.resourceFrom = resourceFrom; exports.staging = staging; exports.statusFrom = statusFrom; exports.walletOnly = walletOnly; //# sourceMappingURL=index.cjs.map //# sourceMappingURL=index.cjs.map