diff --git a/.vscode/ltex.hiddenFalsePositives.en-US.txt b/.vscode/ltex.hiddenFalsePositives.en-US.txt new file mode 100644 index 0000000..2becfa2 --- /dev/null +++ b/.vscode/ltex.hiddenFalsePositives.en-US.txt @@ -0,0 +1 @@ +{"rule":"COMP_THAN","sentence":"^\\Q(First click will be slower as it will be aggregated and cached for the next fast requests)\\E$"} diff --git a/README.md b/README.md index 9d423a7..ff671c1 100644 --- a/README.md +++ b/README.md @@ -2,9 +2,35 @@ Load most Walrus blobs in less than 100 milliseconds! +Join the discussion on [Discord](https://discord.com/invite/Erb6SwsVbH) + +# Direct Links +You can load any blob with a direct link: + https://cdn.suiftly.io/blob/{blobID} + + +Example: + https://cdn.suiftly.io/blob/fK7v0bft1JqVbxQaM_KJAYkejbY9FgU9doqZwg7smw8 + +First click is slower to perform one-time aggregation and caching for subsequent fast requests. + +Suiftly returns proper MIME Content-Type header (e.g. image/png). + +# NPM Packages +See [@suiftly/core](https://www.npmjs.com/package/@suiftly/core) + Works for any Web2/Web3 browser and NodeJS apps. -Join the discussion on [Discord](https://discord.com/invite/Erb6SwsVbH) +As simple as `fetchBlob(blobID)` + +Features: + - Try CDN first, failover to Walrus + - Blob integrity check. + - Returns standard JS Blob object with proper MIME type. + +Installation: + ```npm install @suiftly/core``` + # Thank you! - [Mysten Labs](https://mystenlabs.com) and the [Sui foundation](https://sui.io) for their innovations and financial support. diff --git a/dapps/suiftly.walrus.site/package.json b/dapps/suiftly.walrus.site/package.json index ef1f580..a2141b2 100644 --- a/dapps/suiftly.walrus.site/package.json +++ b/dapps/suiftly.walrus.site/package.json @@ -5,8 +5,7 @@ "scripts": { "dev": "pnpm frontend:dev", "start": "pnpm dev", - "test": "pnpm backend:test", - "build": "pnpm backend:build && pnpm frontend:build", + "build": "pnpm frontend:build", "lint": "pnpm frontend:lint", "format": "pnpm frontend:format", diff --git a/dapps/suiftly.walrus.site/packages/backend/.gitignore b/dapps/suiftly.walrus.site/packages/backend/.gitignore deleted file mode 100644 index 8a8d2bc..0000000 --- a/dapps/suiftly.walrus.site/packages/backend/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -build -Move.lock -Suibase.toml diff --git a/dapps/suiftly.walrus.site/packages/backend/README.md b/dapps/suiftly.walrus.site/packages/backend/README.md deleted file mode 100644 index d1b6219..0000000 --- a/dapps/suiftly.walrus.site/packages/backend/README.md +++ /dev/null @@ -1,3 +0,0 @@ -# Sui dApp Starter: Backend - -Please find the root project [README](../../README.md). diff --git a/dapps/suiftly.walrus.site/packages/backend/move/greeting/Move.toml b/dapps/suiftly.walrus.site/packages/backend/move/greeting/Move.toml deleted file mode 100644 index e29cdf2..0000000 --- a/dapps/suiftly.walrus.site/packages/backend/move/greeting/Move.toml +++ /dev/null @@ -1,37 +0,0 @@ -[package] -name = "greeting" -edition = "2024.beta" # edition = "legacy" to use legacy (pre-2024) Move -# license = "" # e.g., "MIT", "GPL", "Apache 2.0" -# authors = ["..."] # e.g., ["Joe Smith (joesmith@noemail.com)", "John Snow (johnsnow@noemail.com)"] - -[dependencies] -Sui = { git = "https://github.com/MystenLabs/sui.git", subdir = "crates/sui-framework/packages/sui-framework", rev = "framework/devnet" } - -# For remote import, use the `{ git = "...", subdir = "...", rev = "..." }`. -# Revision can be a branch, a tag, and a commit hash. -# MyRemotePackage = { git = "https://some.remote/host.git", subdir = "remote/path", rev = "main" } - -# For local dependencies use `local = path`. Path is relative to the package root -# Local = { local = "../path/to" } - -# To resolve a version conflict and force a specific version for dependency -# override use `override = true` -# Override = { local = "../conflicting/version", override = true } - -[addresses] -greeting = "0x0" - -# Named addresses will be accessible in Move as `@name`. They're also exported: -# for example, `std = "0x1"` is exported by the Standard Library. -# alice = "0xA11CE" - -[dev-dependencies] -# The dev-dependencies section allows overriding dependencies for `--test` and -# `--dev` modes. You can introduce test-only dependencies here. -# Local = { local = "../path/to/dev-build" } - -[dev-addresses] -# The dev-addresses section allows overwriting named addresses for the `--test` -# and `--dev` modes. -# alice = "0xB0B" - diff --git a/dapps/suiftly.walrus.site/packages/backend/move/greeting/sources/greetings.move b/dapps/suiftly.walrus.site/packages/backend/move/greeting/sources/greetings.move deleted file mode 100644 index b43cde9..0000000 --- a/dapps/suiftly.walrus.site/packages/backend/move/greeting/sources/greetings.move +++ /dev/null @@ -1,218 +0,0 @@ -// Copyright (c) Konstantin Komelin and other contributors -// SPDX-License-Identifier: MIT - -/// Module: greeting -module greeting::greeting { - - // === Imports === - - // use std::debug; - use sui::event::emit; - use sui::random::{Random, new_generator}; - use std::string::{utf8, String}; - // The creator bundle: `package` and `display` often go together. - use sui::package; - use sui::display; - - // === Constants === - - const NoEmojiIndex: u8 = 0; - const MinEmojiIndex: u8 = 1; - const MaxEmojiIndex: u8 = 64; - - // === Errors === - - const EEmptyName:u64 = 0; - - // === Structs === - - public struct Greeting has key { - id: UID, - name: String, - emoji: u8 - } - - /// One-Time-Witness for the module. - public struct GREETING has drop {} - - // === Events === - - /// Emitted when the greeting is created. - public struct EventGreetingCreated has copy, drop { - greeting_id: ID - } - - /// Emitted when the Greeting is set. - public struct EventGreetingSet has copy, drop { - greeting_id: ID - } - - /// Emitted when the Greeting is reset. - public struct EventGreetingReset has copy, drop { - greeting_id: ID - } - - // === Initializer === - - /// In the module initializer one claims the `Publisher` object - /// to then create a `Display`. The `Display` is initialized with - /// a set of fields (but can be modified later) and published via - /// the `update_version` call. - /// - /// Keys and values are set in the initializer but could also be - /// set after publishing if a `Publisher` object was created. - /// - /// Implements One Time Witness pattern. - fun init(otw: GREETING, ctx: &mut TxContext) { - let keys = vector[ - utf8(b"name"), - // utf8(b"link"), - utf8(b"image_url"), - utf8(b"description"), - utf8(b"project_url"), - utf8(b"creator"), - utf8(b"license"), - ]; - - let values = vector[ - // For `name`, we can use the `Greetings.name` property. - utf8(b"Greetings to {name}"), - // For `link`, one can build a URL using an `id` property. - // utf8(b"https://demo.sui-dapp-starter.dev/{id}"), - // For `image_url`, use an IPFS template + image url or a Walrus url like https://suidappstarter.walrus.site/emoji/{emoji}.svg. - utf8(b"https://demo.sui-dapp-starter.dev/emoji/{emoji}.svg"), - // Description is static for all `Greeting` objects. - utf8(b"Demonstrates Sui Object Display feature"), - // Project URL is usually static. - utf8(b"https://demo.sui-dapp-starter.dev"), - // Creator field can be any. - utf8(b"Sui dApp Starter"), - // SVG emojis from https://github.com/twitter/twemoji are used, so it's necessary to provide the license info. - utf8(b"Graphics borrowed from https://github.com/twitter/twemoji and licensed under CC-BY 4.0: https://creativecommons.org/licenses/by/4.0/"), - ]; - - // Claim the `Publisher` for the package. - let publisher = package::claim(otw, ctx); - - // Get a new `Display` object for the `Greeting` type. - let mut display = display::new_with_fields( - &publisher, keys, values, ctx - ); - - // Commit first version of `Display` to apply changes. - display::update_version(&mut display); - - transfer::public_transfer(publisher, ctx.sender()); - transfer::public_transfer(display, ctx.sender()); - } - - /// Create and share a Greeting object. - public fun create(ctx: &mut TxContext) { - // Create the Greeting object. - let greeting = new(ctx); - - emit(EventGreetingCreated { - greeting_id: greeting.id.to_inner(), - }); - - // Share the Greeting object with everyone. - transfer::transfer(greeting, ctx.sender()); - } - - // === Public-Mutative Functions === - - /// Resets the greeting. - public fun reset_greeting(g: &mut Greeting) { - g.name = b"".to_string(); - g.emoji = NoEmojiIndex; - - let greeting_id = g.id.to_inner(); - - emit(EventGreetingReset { - greeting_id - }); - } - - // === Public-View Functions === - - /// Returns the name of currently greeted person. - public fun name(g: &Greeting): String { - g.name - } - - /// Returns the emoji of current greeting. - public fun emoji(g: &Greeting): u8 { - g.emoji - } - - // === Private Functions === - - /// Sets the name of currently greeted person and chooses a random emoji for them. - /// - /// The function is defined as private entry to prevent calls from other Move functions. (If calls from other - /// functions are allowed, the calling function might abort the transaction depending on the winner.) - /// Gas based attacks are not possible since the gas cost of this function is independent of the winner. - entry fun set_greeting(g: &mut Greeting, name: String, r: &Random, ctx: &mut TxContext) { - assert!(name != b"".to_string(), EEmptyName); - - let mut generator = r.new_generator(ctx); - let emoji = generator.generate_u8_in_range(MinEmojiIndex, MaxEmojiIndex); - - // debug::print(g); - g.name = name; - g.emoji = emoji; - // debug::print(g); - - let greeting_id = g.id.to_inner(); - - emit(EventGreetingSet { - greeting_id - }); - } - - /// Create a new empty Greetings object. - fun new(ctx: &mut TxContext): Greeting { - Greeting { - id: object::new(ctx), - name: b"".to_string(), - emoji: NoEmojiIndex - } - } - - // === Test Functions === - - #[test_only] - // The `init` is not run in tests, and normally a test_only function is - // provided so that the module can be initialized in tests. Having it public - // is important for tests located in other modules. - public fun init_for_testing(ctx: &mut TxContext) { - init(GREETING {}, ctx); - } - - #[test_only] - /// Create a new Greeting for tests. - public fun new_for_testing(name: String, ctx: &mut TxContext): Greeting { - let mut greeting = new(ctx); - greeting.name = name; - - greeting - } - - #[test_only] - /// Returns the MaxEmojiIndex constant value. - public fun no_emoji_index(): u8 { - NoEmojiIndex - } - - #[test_only] - /// Returns the MaxEmojiIndex constant value. - public fun min_emoji_index(): u8 { - MinEmojiIndex - } - - #[test_only] - /// Returns the MaxEmojiIndex constant value. - public fun max_emoji_index(): u8 { - MaxEmojiIndex - } -} diff --git a/dapps/suiftly.walrus.site/packages/backend/move/greeting/tests/greeting_tests.move b/dapps/suiftly.walrus.site/packages/backend/move/greeting/tests/greeting_tests.move deleted file mode 100644 index b32366c..0000000 --- a/dapps/suiftly.walrus.site/packages/backend/move/greeting/tests/greeting_tests.move +++ /dev/null @@ -1,96 +0,0 @@ -// Copyright (c) Konstantin Komelin and other contributors -// SPDX-License-Identifier: MIT - -#[test_only] -module greeting::greeting_tests { - use greeting::greeting; - use sui::test_utils; - use sui::random::{Self, Random}; - use sui::test_scenario as ts; - - #[test] - /// Tests successful run of the set_greeting() and reset_greeting() functions. - fun test_greeting() { - let user1 = @0x0; - let bob = b"Bob".to_string(); - let alice = b"Alice".to_string(); - let empty = b"".to_string(); - let mut ts = ts::begin(user1); - - // Run the module initializer. - // The curly braces are used to explicitly scope the transaction. - { - greeting::init_for_testing(ts.ctx()); - }; - - // @todo: Test Object Display. - - // Setup randomness. - random::create_for_testing(ts.ctx()); - ts.next_tx(user1); - let mut random_state: Random = ts.take_shared(); - random_state.update_randomness_state_for_testing( - 0, - x"1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F", - ts.ctx(), - ); - - ts.next_tx(user1); - let mut g = greeting::new_for_testing(bob, ts.ctx()); - assert!(greeting::name(&g) == bob, 0); - assert!(greeting::emoji(&g) == greeting::no_emoji_index(), 1); - - ts.next_tx(user1); - greeting::set_greeting(&mut g, alice, &random_state, ts.ctx()); - assert!(greeting::name(&g) == alice, 2); - assert!(greeting::emoji(&g) >= greeting::min_emoji_index() && greeting::emoji(&g) <= greeting::max_emoji_index(), 3); - - ts.next_tx(user1); - greeting::reset_greeting(&mut g); - assert!(greeting::name(&g) == empty, 4); - assert!(greeting::emoji(&g) == greeting::no_emoji_index(), 5); - - test_utils::destroy(g); - ts::return_shared(random_state); - ts.end(); - } - - #[test] - #[expected_failure(abort_code = greeting::EEmptyName)] - /// Tests failed run of the set_greeting() in case of the empty name. - fun test_set_greeting_fail() { - let user1 = @0x0; - let bob = b"Bob".to_string(); - let empty = b"".to_string(); - let mut ts = ts::begin(user1); - - // Run the module initializer. - // The curly braces are used to explicitly scope the transaction. - { - greeting::init_for_testing(ts.ctx()); - }; - - // Setup randomness. - random::create_for_testing(ts.ctx()); - ts.next_tx(user1); - let mut random_state: Random = ts.take_shared(); - random_state.update_randomness_state_for_testing( - 0, - x"1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F1F", - ts.ctx(), - ); - - ts.next_tx(user1); - let mut g = greeting::new_for_testing(bob, ts.ctx()); - assert!(greeting::name(&g) == bob, 0); - assert!(greeting::emoji(&g) == greeting::no_emoji_index(), 1); - - ts.next_tx(user1); - // Should fail. - greeting::set_greeting(&mut g, empty, &random_state, ts.ctx()); - - test_utils::destroy(g); - ts::return_shared(random_state); - ts.end(); - } -} diff --git a/dapps/suiftly.walrus.site/packages/backend/package.json b/dapps/suiftly.walrus.site/packages/backend/package.json deleted file mode 100644 index 797efc9..0000000 --- a/dapps/suiftly.walrus.site/packages/backend/package.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "name": "backend", - "private": true, - "version": "0.0.0", - "scripts": { - "build": "lsui move build -d -p ./move/greeting", - "test": "lsui move test -d -p ./move/greeting", - "copy-package-id": "node ./scripts/copy-package-id", - "localnet:start": "localnet start", - "localnet:stop": "localnet stop", - "localnet:status": "localnet status", - "localnet:faucet": "localnet faucet", - "localnet:regen": "localnet regen", - "localnet:update": "localnet update", - "localnet:address": "lsui client active-address", - "localnet:deploy": "localnet publish --path ${PWD}/move/greeting && pnpm copy-package-id -n localnet", - "localnet:deploy:no-dependency-check": "localnet publish --path ${PWD}/move/greeting --skip-dependency-verification && pnpm copy-package-id -n localnet", - "localnet:explorer:start": "sui-explorer-local start", - "localnet:explorer:stop": "sui-explorer-local stop", - "localnet:explorer:restart": "sui-explorer-local restart", - "devnet:start": "devnet start", - "devnet:stop": "devnet stop", - "devnet:status": "devnet status", - "devnet:update": "devnet update", - "devnet:links": "devnet links", - "devnet:address": "dsui client active-address", - "devnet:deploy": "devnet publish --path ${PWD}/move/greeting && pnpm copy-package-id -n devnet", - "devnet:deploy:no-dependency-check": "devnet publish --path ${PWD}/move/greeting --skip-dependency-verification && pnpm copy-package-id -n devnet", - "testnet:start": "testnet start", - "testnet:stop": "testnet stop", - "testnet:status": "testnet status", - "testnet:update": "testnet update", - "testnet:links": "testnet links", - "testnet:address": "tsui client active-address", - "testnet:deploy": "testnet publish --path ${PWD}/move/greeting && pnpm copy-package-id -n testnet", - "testnet:deploy:no-dependency-check": "testnet publish --path ${PWD}/move/greeting --skip-dependency-verification && pnpm copy-package-id -n testnet", - "mainnet:start": "mainnet start", - "mainnet:stop": "mainnet stop", - "mainnet:status": "mainnet status", - "mainnet:update": "mainnet update", - "mainnet:links": "mainnet links", - "mainnet:address": "msui client active-address", - "mainnet:deploy": "mainnet publish --path ${PWD}/move/greeting && pnpm copy-package-id -n mainnet", - "mainnet:deploy:no-dependency-check": "mainnet publish --path ${PWD}/move/greeting --skip-dependency-verification && pnpm copy-package-id -n mainnet" - }, - "devDependencies": { - "env-file-rw": "^1.0.0", - "sui-explorer-local": "^2.1.1" - } -} diff --git a/dapps/suiftly.walrus.site/packages/backend/scripts/copy-package-id.js b/dapps/suiftly.walrus.site/packages/backend/scripts/copy-package-id.js deleted file mode 100755 index 58f73df..0000000 --- a/dapps/suiftly.walrus.site/packages/backend/scripts/copy-package-id.js +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env node - -/** - * The script copies the deployed package ID from the corresponding Suibase network file to .env.local of the frontend package, - * which is then read by the app. - * - * The default network is localnet. To change it, pass "-n [NETWORK_TYPE]" through console. - */ - -const { promises } = require("node:fs"); -const { homedir } = require("node:os"); -const path = require("node:path"); -const EnvFileWriter = require("env-file-rw").default; - -const DEPLOYED_MODULE_NAME = "greeting"; - -const main = async () => { - const network = getNetworkFromArgs(); - const sourceFile = sourceFilePath(network, DEPLOYED_MODULE_NAME); - const targetFile = targetFilePath(); - - // Read package ID from SuiBase packageId file. - const packageId = await readPackageId(sourceFile); - - // Create .env.local file if it doesn't exist. - await createFileIfNecessary(targetFile); - - // Add VITE_[network]_CONTRACT_PACKAGE_ID variable to .env.local or update its value if it exists. - await setEnvVar( - targetFile, - `VITE_${network.toUpperCase()}_CONTRACT_PACKAGE_ID`, - packageId - ); -}; - -const sourceFilePath = (network, deployedModuleName) => { - return path.join( - homedir(), - `/suibase/workdirs/${network}/published-data/${deployedModuleName}/most-recent/package-id.json` - ); -}; - -const targetFilePath = () => { - return path.join(process.cwd(), "../frontend/.env.local"); -}; - -const getNetworkFromArgs = () => { - const arg = process.argv.slice(2); - - switch (arg[0]) { - case "-n": - return arg[1]; - - default: - return "localnet"; - } -}; - -/** - * Read package ID from SuiBase packageId file. - * - * @param {string} sourceFile - * @returns - */ -const readPackageId = async (sourceFile) => { - const data = await promises.readFile(sourceFile, "utf8"); - return JSON.parse(data)[0]; -}; - -/** - * Create a file if it doesn't exist. - * - * @param {string} filePath - * @returns - */ -const createFileIfNecessary = async (filePath) => { - try { - await promises.writeFile(filePath, "", { flag: "wx" }); - } catch {} -}; - -/** - * Set the environment variable in the .env.local file. - * - * @param {string} envFilePath - * @param {string} name - * @param {string} value - * @returns - */ -const setEnvVar = async (envFilePath, name, value) => { - const envFileWriter = new EnvFileWriter(envFilePath, false); - await envFileWriter.parse(); - envFileWriter.set(name, value); - await envFileWriter.save(); -}; - -// Main entry point. -main().catch((e) => { - console.error(e); -}); diff --git a/dapps/suiftly.walrus.site/packages/frontend/src/components/App.tsx b/dapps/suiftly.walrus.site/packages/frontend/src/components/App.tsx index f7a9153..08ad8d7 100644 --- a/dapps/suiftly.walrus.site/packages/frontend/src/components/App.tsx +++ b/dapps/suiftly.walrus.site/packages/frontend/src/components/App.tsx @@ -1,14 +1,118 @@ -import { FC } from 'react' +import { FC, useEffect, useRef } from 'react' import GreetingForm from '~~/components/GreetingForm' import Layout from '~~/components/layout/Layout' import NetworkSupportChecker from './NetworkSupportChecker' +// A fetchBlob() async function that take a single "blobID" string argument. +// It should return a promise that resolves to the blob data (a standard JS +// Blob interface). +// +// The blob data is first retreived with a CDN URL like this: +// https://cdn.suiftly.io/blobs/{blobID} +// +// The function should extract the Content-Type header and use it +// to build the JS Blob object. +// +// The function should throw an error if the response status is not 200. +// +// Example usage: +// const blob = await fetchBlob('some-blob-id') + + const App: FC = () => { + // TODO Just proof-of-concept... need to design this way better!!!! + const fetchInitiated1 = useRef(false) + const fetchInitiated2 = useRef(false) + + useEffect(() => { + const blobID = 'fK7v0bft1JqVbxQaM_KJAYkejbY9FgU9doqZwg7smw8' + const imageContainer1 = document.getElementById('image-container-walrus') + const imageContainer2 = document.getElementById('image-container-suiftly') + + if (imageContainer1 && !fetchInitiated1.current) { + fetchInitiated1.current = true + fetchBlob(blobID, { source: 'walrus' }) + .then((blob) => { + const url = URL.createObjectURL(blob) + const img = document.createElement('img') + img.src = url + img.alt = 'Fetched Blob' + + // Clear the loading message and append the image + if (imageContainer1) { + imageContainer1.innerHTML = '' + imageContainer1.appendChild(img) + } + }) + .catch((error) => { + console.error('Error fetching walrus blob:', error) + if (imageContainer1) { + imageContainer1.innerHTML = '

Error loading walrus image

' + } + }) + } + + if (imageContainer2 && !fetchInitiated2.current) { + fetchInitiated2.current = true + fetchBlob(blobID, { source: 'suiftly' }) + .then((blob) => { + const url = URL.createObjectURL(blob) + const img = document.createElement('img') + img.src = url + img.alt = 'Fetched Blob' + + // Clear the loading message and append the image + if (imageContainer2) { + imageContainer2.innerHTML = '' + imageContainer2.appendChild(img) + } + }) + .catch((error) => { + console.error('Error fetching suiftly blob:', error) + if (imageContainer2) { + imageContainer2.innerHTML = '

Error loading suiftly image

' + } + }) + } + + // Cleanup URL object when component unmounts + return () => { + if (imageContainer1) { + const img = imageContainer1.querySelector('img') + if (img) { + URL.revokeObjectURL(img.src) + } + } + if (imageContainer2) { + const img = imageContainer2.querySelector('img') + if (img) { + URL.revokeObjectURL(img.src) + } + } + } + }) + return (
+ + {/* fetchBlob(blobID, { type: "suiftly"}) +
+

Loading image...

+
*/} + + {/* fetchBlob(blobID, { type: "walrus"}) */} +
+

Loading image...

+
) diff --git a/packages/core/README.md b/packages/core/README.md index 6f94c91..82f0756 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -3,32 +3,94 @@ # Suiftly - CDN Optimizations for Sui Walrus -Important: Project in alpha development. Not working yet!!! +Fast and robust loading of any Walrus blobs. + +Simple use: `fetchBlob(blobID)` + +Works for any Web2/Web3 and NodeJS apps. + +Useful for loading (and validating) even when not hosted on walrus.site. + +Alternatively, you can download any blob directly (without validation) with a link: https://cdn.suiftly.io/blob/{blobID} More info: [https://suiftly.io](https://suiftly.io/) +> MIME Content-Type header is properly generated by Suiftly + +>Important: Blob encoding has not been finalized and published by Mysten Labs, therefore fetchBlob() skips validation for now. + # Functions -Retrieve a Walrus blob for a given blob ID. +## fetchBlob + +```typescript +function fetchBlob( + blobID: string, + options?: { + mimeType?: string, + allowSuiftly?: boolean, + allowWalrus?: boolean + } +): Promise +``` + +Fetches a Walrus blob -Attempts first to download and verify integrity from the CDN. +### Parameters -On failure, try next with a direct download from the Walrus network. This fallback is possible only for webapp published at .walrus.site. +- `blobID` - Walrus blob ID +- `options` - Optional parameters + - `options.allowSuiftly` - Whether to allow fetching from Suiftly CDN as the primary source. Default is true. + - `options.allowWalrus` - Whether to allow fallback to Walrus if the primary fetch fails. Default is true. + - `options.mimeType` - Force the MIME type (e.g. image/png) in the returned Blob. By default, will be properly set when retrieving from Suiftly. Will be an empty string when retrieving from Walrus. -Returns a standard JS Blob with its mime-type properly defined. +### Returns -The blob can be further converted to a URL object. Example with an 'image/png': -```js +A promise that resolves to a standard Blob object. Returned data is verified to match `blobID`. + +### Throws + +Will throw an error if the fetch or blob integrity check fails. + +### Example 1 + + ```ts import { fetchBlob } from '@suiftly/core'; -async function loadImage() { - const blob = await fetchBlob({blobId:"AqizY7o7lAAB_DdqQ_4x1Ldg-yOWZRWrWterJZoKHZc"}); +async function getAnyBlob(blobId:string) { + try { + const blob = await fetchBlob(blobId); + console.log(blob); + } catch (error) { + console.error('Error fetching blob:', error); + } +} + +getAnyBlob("fK7v0bft1JqVbxQaM_KJAYkejbY9FgU9doqZwg7smw8"); +``` + +### Example 2 +```ts +import { fetchBlob } from '@suiftly/core'; - // Create a URL object from the blob - const imageUrl = URL.createObjectURL(blob); - document.getElementById("imageDisplay").src = imageUrl; +async function setImage() { + try { + // Fetch the blob. + // Force the type (not really needed, done here just to demo). + const blob = await fetchBlob('your-blob-id', { mimeType: 'image/png' }); + // Display as an HTML image element + const url = URL.createObjectURL(blob); + const imageContainer = document.getElementById('image-container'); + if (imageContainer) { + const img = document.createElement('img'); + img.src = url; + imageContainer.appendChild(img); + } + } catch (error) { + console.error('Error fetching blob:', error); + } } -loadImage(); -``` \ No newline at end of file +setImage(); + ``` diff --git a/packages/core/package.json b/packages/core/package.json index 4c4ca07..cd0a10b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@suiftly/core", - "version": "0.0.3", + "version": "0.0.7", "homepage": "https://suiftly.io", "description": "Common low level functions for Suiftly.io", "exports": { diff --git a/packages/core/src/__tests__/fetch-test.ts b/packages/core/src/__tests__/fetch-test.ts deleted file mode 100644 index 8e24864..0000000 --- a/packages/core/src/__tests__/fetch-test.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { fetch_suiftly } from '../fetch'; - -describe('dummy_test()', () => { - describe('when call with no parameter', () => { - it('returns `true`', () => { - expect(fetch_suiftly()).toBe(true); - }); - }); -}); diff --git a/packages/core/src/__tests__/fetchBlob-test.ts b/packages/core/src/__tests__/fetchBlob-test.ts new file mode 100644 index 0000000..1d90dcd --- /dev/null +++ b/packages/core/src/__tests__/fetchBlob-test.ts @@ -0,0 +1,34 @@ +import { fetchBlob } from '../fetchBlob'; + +describe('fetchBlob', () => { + // Test with a Blob known to be on the network. + const blobID = 'fK7v0bft1JqVbxQaM_KJAYkejbY9FgU9doqZwg7smw8'; + const inexistentBlobID = 'K7v0bft1JqVbxQaM_KJAYkejbY9FgU9doqZwg7smw8f'; + const expectedMimeType = 'image/png; charset=binary'; + + it('should fetch a blob with the correct MIME type', async () => { + expect.hasAssertions(); + const blob = await fetchBlob(blobID); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe(expectedMimeType); + expect(blob.size).toBe(180486); + }); + + it('allow to override the MIME type', async () => { + expect.hasAssertions(); + const overrideMimeType = 'image/jpeg'; + const blob = await fetchBlob(blobID, { mimeType: overrideMimeType }); + expect(blob).toBeInstanceOf(Blob); + expect(blob.type).toBe(overrideMimeType); + }); + + it('should throw an error for an inexistentBlobID', async () => { + expect.hasAssertions(); + await expect(fetchBlob(inexistentBlobID)).rejects.toThrow(); + }); + + it('should throw an error when blobID param is an empty string', async () => { + expect.hasAssertions(); + await expect(fetchBlob('')).rejects.toThrow('Blob ID is required'); + }); +}); diff --git a/packages/core/src/fetch.ts b/packages/core/src/fetch.ts deleted file mode 100644 index f1fd645..0000000 --- a/packages/core/src/fetch.ts +++ /dev/null @@ -1,6 +0,0 @@ -// Purpose: This file is the entry point for the core package. - -export function fetch_suiftly(): boolean { - /// This module is obviously work-in-progress - return true; -} diff --git a/packages/core/src/fetchBlob.ts b/packages/core/src/fetchBlob.ts new file mode 100644 index 0000000..6c471f0 --- /dev/null +++ b/packages/core/src/fetchBlob.ts @@ -0,0 +1,115 @@ +/** + * Fetches a Walrus blob + * + * Default behavior is to try first the Suiftly CDN and then fallback directly to Walrus. + * + * @param blobID - The ID of the blob to fetch. + * @param options - Optional parameters for fetching the blob. + * @param options.mimeType - Force the MIME type of the blob (e.g. image/png). By default, will be properly set if retreiving from Suiftly. Will be empty string when retreiving from Walrus. + * @param options.allowSuiftly - Whether to allow fetching from Suiftly CDN as the primary source. Default is true. + * @param options.allowWalrus - Whether to allow fallback to Walrus if the primary fetch fails. Default is true. + * @returns A promise that resolves to a Blob object. Data integrity verified to match blobID. + * @throws Will throw an error if the fetch fails or blob ID validation fails. + * + * @example + * ```typescript + * import { fetchBlob } from '@suiftly/core'; + * + * async function getBlob() { + * try { + * const blob = await fetchBlob('your-blob-id'); + * console.log(blob); + * } catch (error) { + * console.error('Error fetching blob:', error); + * } + * } + * + * getBlob(); + * ``` + * + * * @example + * ```typescript + * import { fetchBlob } from '@suiftly/core'; + * + * async function setImage() { + * try { + * const blob = await fetchBlob('your-blob-id', { mimeType: 'image/png' }); + * const url = URL.createObjectURL(blob); + * const imageContainer = document.getElementById('image-container'); + * if (imageContainer) { + * const img = document.createElement('img'); + * img.src = url; + * imageContainer.appendChild(img); + * } + * } catch (error) { + * console.error('Error fetching blob:', error); + * } + * } + * + * setImage(); + * ``` + */ + +interface FetchBlobOptions { + allowSuiftly?: boolean; + allowWalrus?: boolean; + mimeType?: string; // Force MIME type in the returned Blob. +} + +export async function fetchBlob(blobID: string, options: FetchBlobOptions = {}): Promise { + const createBlob = async (response: Response, mimeType: string): Promise => { + try { + const blob = await response.blob(); + return new Blob([blob], { type: mimeType }); + } catch (error) { + if (error instanceof Error) { + throw new Error(`Failed to create blob: ${error.message}`); + } else { + throw new Error('Failed to create blob: Unknown error'); + } + } + }; + + try { + const allowSuiftly = options.allowSuiftly !== false; + const allowWalrus = options.allowWalrus !== false; + // Validate parameters + if (!blobID) { + throw new Error('Blob ID is required'); + } + + if (!allowSuiftly && !allowWalrus) { + throw new Error('No source specified. Neither suiftly or walrus are allowed'); + } + + // TODO Data integity check once Mysten Labs publish the encoding. + if (allowSuiftly) { + const cdnResponse = await fetch(`https://cdn.suiftly.io/blob/${blobID}`); + if (cdnResponse.ok) { + const contentType = + options.mimeType || cdnResponse.headers.get('Content-Type') || 'application/octet-stream'; + return await createBlob(cdnResponse, contentType); + } else if (!allowWalrus) { + throw new Error(`Failed to fetch suiftly (no fallback): ${cdnResponse.statusText}`); + } + } + + if (allowWalrus) { + const walrusResponse = await fetch(`https://blobid.walrus/${blobID}`); + if (walrusResponse.ok) { + // The walrusResponse do not have a type. + const contentType = options.mimeType || 'application/octet-stream'; + return await createBlob(walrusResponse, contentType); + } + throw new Error(`Failed to fetch walrus: ${walrusResponse.statusText}`); + } + + throw new Error(`No valid source specified`); + } catch (error) { + if (error instanceof Error) { + return await Promise.reject(new Error(`${error.message}`)); + } else { + return await Promise.reject(new Error('Unknown error')); + } + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 71ef5d0..7bb9fa0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1 +1 @@ -export * from './fetch'; +export * from './fetchBlob';