/** * Binary asset store for static API assets (e.g. token logos), keyed by the * loader layout `/`. Token icons are content-addressed by address * (`4217/icons/0x…`, no extension); the stored content-type carries the format. * Shared by the main API ({@link App.create}'s `assets`, read) and the admin app * (`Admin.create`'s `assets`, read + write). */ /** A static asset: its bytes plus content type. */ export type Asset = { /** Asset bytes. */ body: ArrayBuffer /** MIME type to serve the asset with. */ contentType: string } /** A binary asset store keyed by `/`. */ export type Assets = { /** Reads an asset by `key`, or `undefined` when absent. */ get(key: string): Promise /** Lists asset keys matching `prefix`, when the backend supports enumeration. */ list?: ((prefix: string) => Promise) | undefined /** Stores an asset at `key`, replacing any existing object. Omit for read-only stores. */ put?: ((key: string, asset: Asset) => Promise) | undefined } /** Builds the public URL for an asset key using the same path shape as the data API. */ export function url(options: url.Options) { const basePath = options.basePath?.replace(/^\/+|\/+$/g, '') const path = options.path.replace(/^\/+/, '') const pathname = [basePath, 'assets', String(options.chainId), path].filter(Boolean).join('/') return new URL(`/${pathname}`, options.origin).toString() } export declare namespace url { /** Inputs for building one public asset URL. */ type Options = { /** Optional API base path mounted before `/assets`. */ basePath?: string | undefined /** Chain that owns the asset. */ chainId: number /** API origin that serves the asset. */ origin: string /** Asset-store path below the chain id. */ path: string } } /** Backs {@link Assets} with a Cloudflare R2 bucket. */ export function cloudflareR2(bucket: cloudflareR2.Bucket): Assets { return { async get(key) { const object = await bucket.get(key) if (!object) return undefined return { body: await object.arrayBuffer(), contentType: object.httpMetadata?.contentType ?? 'application/octet-stream', } }, async list(prefix) { const keys: string[] = [] let cursor: string | undefined for (;;) { const page = await bucket.list({ ...(cursor === undefined ? {} : { cursor }), limit: 1_000, prefix, }) keys.push(...page.objects.map((object) => object.key)) if (!page.truncated) return keys if (!page.cursor) throw new Error('R2 returned a truncated asset list without a cursor.') cursor = page.cursor } }, put: (key, { body, contentType }) => bucket.put(key, body, { httpMetadata: { contentType } }).then(() => undefined), } } export declare namespace cloudflareR2 { /** Minimal Cloudflare R2 bucket shape used by this adapter. */ type Bucket = { /** Gets an object, or `null` when absent. */ get(key: string): Promise<{ /** Reads the object body as bytes. */ arrayBuffer(): Promise /** HTTP metadata stored with the object. */ httpMetadata?: { contentType?: string | undefined } | undefined } | null> /** Lists objects matching a key prefix. */ list(options: { /** Continues a previous truncated listing. */ cursor?: string | undefined /** Maximum objects returned in one page. */ limit: number /** Object-key prefix to match. */ prefix: string }): Promise<{ /** Cursor for the next page. */ cursor?: string | undefined /** Matching objects. */ objects: readonly { key: string }[] /** Whether more matching objects remain. */ truncated: boolean }> /** Writes an object. */ put( key: string, value: ArrayBuffer, options?: { httpMetadata?: { contentType?: string | undefined } | undefined }, ): Promise } }