Using Walrus with JavaScript
You can store and read Walrus blobs from JavaScript with plain HTTP calls: a publisher accepts uploads through PUT requests, and an aggregator serves downloads through GET requests. The built-in fetch API covers both.
How to store and read a blob
The following file holds the whole round trip: the endpoint constants, the store call, the response parser, and the read call.
// Copyright (c) Walrus Foundation
// SPDX-License-Identifier: Apache-2.0
// Store a blob on Walrus and read it back with plain HTTP calls.
//
// A publisher accepts uploads through PUT requests and an aggregator serves
// downloads through GET requests, so the built-in fetch API is all you need.
// The endpoints below are the public Testnet ones.
const PUBLISHER = "https://publisher.walrus-testnet.walrus.space";
const AGGREGATOR = "https://aggregator.walrus-testnet.walrus.space";
// Send the raw bytes as the body of a PUT request. `data` accepts a File,
// Blob, ArrayBuffer, Uint8Array, or string.
export async function storeBlob(data, epochs = 1) {
const response = await fetch(`${PUBLISHER}/v1/blobs?epochs=${epochs}`, {
method: "PUT",
body: data,
});
if (!response.ok) {
throw new Error(`Store failed with status ${response.status}`);
}
return response.json();
}
// A successful store returns one of two shapes. `newlyCreated` describes a
// blob Walrus stored for the first time; `alreadyCertified` describes one
// another user already stored and certified. They nest the blob ID
// differently, so handle both.
export function parseStoreResponse(info) {
if ("alreadyCertified" in info) {
return {
status: "Already certified",
blobId: info.alreadyCertified.blobId,
endEpoch: info.alreadyCertified.endEpoch,
};
}
if ("newlyCreated" in info) {
return {
status: "Newly created",
blobId: info.newlyCreated.blobObject.blobId,
endEpoch: info.newlyCreated.blobObject.storage.endEpoch,
suiObjectId: info.newlyCreated.blobObject.id,
};
}
throw new Error("Unexpected store response");
}
// Read the blob back by blob ID. Call response.text() or response.json()
// instead when you stored text or JSON.
export async function readBlob(blobId) {
const response = await fetch(`${AGGREGATOR}/v1/blobs/${blobId}`);
if (!response.ok) {
throw new Error(`Read failed with status ${response.status}`);
}
return response.arrayBuffer();
}
The store call sends the file bytes in a PUT request to the publisher's /v1/blobs endpoint. The read call sends a GET request to the aggregator's /v1/blobs/<blobId> endpoint.
Two constraints apply when you pick endpoints:
- Walrus has no public unauthenticated publisher on Mainnet. Use a Testnet publisher for experiments; on Mainnet, run your own publisher, use an upload relay, or use the TypeScript SDK.
- Most public aggregators and publishers limit requests to 10 MiB. To store larger blobs, run your own publisher or use the CLI.
Control the resulting blob through query parameters on the store call:
| Parameter | Effect |
|---|---|
epochs | The number of storage epochs, which sets how long Walrus stores the blob. Defaults to 1 if you omit it. |
deletable=true or permanent=true | Whether the owner can delete the blob before it expires. The publisher stores new blobs as deletable by default. |
send_object_to | A Sui address that receives the created Blob object. |
The blobId the parser returns identifies the data on Walrus, and endEpoch tells you when the storage period ends. See Understanding the response for the full format.
Reading by blob ID returns application/octet-stream, so set the media type yourself when you embed a blob in a page. To get stored headers such as content-type back, read by Sui object ID instead with /v1/blobs/by-object-id/<objectId>. See Reading Blobs.
A CDN-fronted aggregator might briefly serve a cached 404 from before the blob propagated. If your app just stored the blob, retry the read with backoff. See Reading Blobs Right After Upload.
How to build a browser upload form
The following example combines the upload and download calls into a web form. The form lets you set the publisher and aggregator URLs, pick a file and a number of epochs, and optionally enter a Sui address. The address receives the created Blob object through the send_object_to parameter. After a successful upload, the script parses both response shapes and renders each stored blob with a download link that points at the aggregator.
<!--
Copyright (c) Walrus Foundation
SPDX-License-Identifier: Apache-2.0
-->
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css"
integrity="sha384-QWTKZyjpPEjISv5WaRU9OFeRpok6YctnYmDr5pNlyT2bRjXh0JMhjY6hW+ALEwIH" rel="stylesheet"
crossorigin="anonymous">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Upload Blobs</title>
<style>
dt {
opacity: 80%;
}
object {
object-fit: cover;
}
</style>
</head>
<body>
<script>
const SUI_NETWORK = "testnet";
const SUI_VIEW_TX_URL = `https://suiscan.xyz/${SUI_NETWORK}/tx`;
const SUI_VIEW_OBJECT_URL = `https://suiscan.xyz/${SUI_NETWORK}/object`;
/**
* Handler for submit events from the HTML form.
*
* Stores the blob provided in the form, and disable the default HTML form submission.
*/
function onSubmit(event) {
// Ensure that the event does not propagate, so that the browser does not
// perform a POST with the file.
event.preventDefault();
// Disable the form while uploading.
enableForm(false);
// Store and display the blob, then re-enable the form.
storeBlob().then((storageInfo) => {
displayUpload(storageInfo.info, storageInfo.media_type);
alert(null);
enableForm(true);
}).catch((error) => {
console.error(error);
alert(
"An error occurred while uploading. Check the browser console and ensure that \
the aggregator and publisher URLs are correct."
);
enableForm(true);
});
// Return false to cancel form submission.
return false;
}
/**
* Stores the file from the HTML form on Walrus.
*/
function storeBlob() {
const inputFile = document.getElementById("file-input").files[0];
const numEpochs = document.getElementById("epochs-input").value;
const basePublisherUrl = document.getElementById("publisher-url-input").value;
// Submit a PUT request with the file's content as the body to the /v1/blobs endpoint.
let sendTo = document.getElementById("send-to-input").value.trim();
let sendToParam = sendTo ? `&send_object_to=${sendTo}`: "";
return fetch(`${basePublisherUrl}/v1/blobs?epochs=${numEpochs}${sendToParam}`, {
method: "PUT",
body: inputFile,
}).then((response) => {
if (response.status === 200) {
// Parse successful responses as JSON, and return it along with the
// mime type from the the file input element.
return response.json().then((info) => {
console.log(info);
return { info: info, media_type: inputFile.type };
});
} else {
throw new Error("Something went wrong when storing the blob!");
}
})
}
/**
* Display the result of uploading the file to Walrus.
*/
function displayUpload(storage_info, media_type) {
// Extract the displayed fields from either of the two successful responses:
// - newlyCreated for blobs that have been uploaded for the first time,
// or whose duration has been extended.
// - alreadyCertified for blobs that have already been uploaded and certified.
if ("alreadyCertified" in storage_info) {
info = {
status: "Already certified",
blobId: storage_info.alreadyCertified.blobId,
endEpoch: storage_info.alreadyCertified.endEpoch,
suiRefType: "Previous Sui Certified Event",
suiRef: storage_info.alreadyCertified.event.txDigest,
suiBaseUrl: SUI_VIEW_TX_URL,
};
} else if ("newlyCreated" in storage_info) {
info = {
status: "Newly created",
blobId: storage_info.newlyCreated.blobObject.blobId,
endEpoch: storage_info.newlyCreated.blobObject.storage.endEpoch,
suiRefType: "Associated Sui Object",
suiRef: storage_info.newlyCreated.blobObject.id,
suiBaseUrl: SUI_VIEW_OBJECT_URL,
};
} else {
throw Error("Unhandled successful response!");
}
// The URL used to download and view the blob.
const baseAggregatorUrl = document.getElementById("aggregator-url-input").value;
const blobUrl = `${baseAggregatorUrl}/v1/blobs/${info.blobId}`;
// The URL for viewing the event or object on chain
const suiUrl = `${info.suiBaseUrl}/${info.suiRef}`
const isImage = media_type.startsWith("image");
// Create the HTML entry in the page for the uploaded blob.
//
// For the associated icon, we use the `<object/>` HTML element, as it allows specifying
// the media type. The walrus aggregator returns blobs as `application/octect-stream`,
// so it's necessary to specify the content type to the browser in the `object` element.
document.getElementById("uploaded-blobs").insertAdjacentHTML(
"afterBegin",
`<article class="row border rounded-2 shadow-sm mb-3">
<object type="${isImage ? media_type : ''}" data="${isImage ? blobUrl : ''}" class="col-4 ps-0"></object>
<dl class="blob-info col-8 my-2">
<dt>Status</dt><dd>${info.status}</dd>
<dt>Blob ID</dt>
<dd class="text-truncate"><a href="${blobUrl}">${info.blobId}</a></dd>
<dt>${info.suiRefType}</dt>
<dd class="text-truncate">
<a href="${suiUrl}" target="_blank">${info.suiRef}</a>
</dd>
<dt>Stored until epoch</dt><dd>${info.endEpoch}</dd>
</dl>
</article>`
);
}
/**
* Helper function to disable the form while uploading.
*/
function enableForm(isEnabled) {
if (isEnabled) {
// Copy the urls, so that they are not reset.
const publisherUrl = document.getElementById("publisher-url-input").value;
const aggregatorUrl = document.getElementById("aggregator-url-input").value;
// Reset the form
document.getElementById("upload-form").reset()
// Revert the URLs to their previous values
document.getElementById("publisher-url-input").value = publisherUrl;
document.getElementById("aggregator-url-input").value = aggregatorUrl;
// Disable the progress indication
document.getElementById("submit-spinner").style.visibility = "collapse";
document.getElementById("submit-text").textContent = "Upload";
// Re-enable the form
document.querySelector("#upload-form fieldset").removeAttribute("disabled");
} else {
document.querySelector("#upload-form fieldset").setAttribute("disabled", "");
document.getElementById("submit-spinner").style.visibility = "visible";
document.getElementById("submit-text").textContent = "Uploading ...";
}
}
/**
* Helper to display an error message.
*/
function alert(message) {
const alertElement = document.getElementById("alert");
if (message !== null) {
alertElement.textContent = message;
}
alertElement.style.visibility = message !== null ? "visible" : "hidden";
}
</script>
<header class="container my-3">
<hgroup>
<h1>Walrus Blob Upload</h1>
<p class="lead">An example uploading and displaying files with Walrus.</p>
</hgroup>
</header>
<main class="container">
<div class="row align-items-start gx-5">
<section class="col-lg-5 mb-3">
<hgroup>
<h2>Blob Upload</h2>
<p>
Upload blobs to Walrus, and display them on this page. See the
<a href="https://docs.wal.app" target="_blank">
Walrus documentation
</a> for more information.
The file size is limited to 10 MiB on the default publisher. Use the <a
href="https://docs.wal.app/docs/walrus-client/storing-blobs" target="_blank">CLI
tool</a> to store bigger files.
</p>
</hgroup>
<form id="upload-form" onsubmit="return onSubmit(event)" class="mb-3">
<fieldset class="row g-3">
<div class="col-lg-6">
<label for="publisher-url-input" class="form-label">
Walrus publisher URL
</label>
<input id="publisher-url-input" type="url" class="form-control"
placeholder="https://publisher.walrus-testnet.walrus.space"
value="https://publisher.walrus-testnet.walrus.space" required />
</div>
<div class="col-lg-6">
<label for="aggregator-url-input" class="form-label">
Walrus aggregator URL
</label>
<input id="aggregator-url-input" type="url" class="form-control"
placeholder="https://aggregator.walrus-testnet.walrus.space"
value="https://aggregator.walrus-testnet.walrus.space" required />
</div>
<div class="col-12">
<label for="file-input" class="form-label">Blob to upload (<strong>Max
10 MiB size</strong> on the default publisher!)</label>
<input id="file-input" type="file" class="form-control" required />
</div>
<div class="col-12">
<label for="epochs-input" class="form-label">Epochs</label>
<input id="epochs-input" type="number" value="1" min="1" placeholder="Epochs" class="form-control"
required />
<div class="form-text">
The number of Walrus epochs for which to store the blob.
</div>
</div>
<div class="col-12">
<label for="send-to-input" class="form-label">Send to</label>
<input id="send-to-input" placeholder="0x9d8a07f9f5cb4d10ad3a8c949f17b9ae6c51fc76c2b7ef62dc5babec551e79e6" class="form-control"
required />
<div class="form-text">
The address to which the newly created `Blob` object should be sent (optional).
</div>
</div>
<button id="submit" class="btn btn-primary">
<span id="submit-spinner" class="spinner-border spinner-border-sm" aria-hidden="true"
style="visibility: collapse;"></span>
<span id="submit-text" role="status">Upload</span>
</button>
</fieldset>
</form>
<div id="alert" class="alert alert-danger" role="alert" style="visibility: hidden;">
</div>
</section>
<section class="col-lg-7">
<h2>Uploaded Blobs</h2>
<div id="uploaded-blobs">
<!-- Container for info about uploaded blobs -->
</div>
</section>
</div>
</main>
</body>
</html>
To try it, save the file locally and open it in a browser.
How to read Walrus system state
The following example reads Walrus system state on Sui instead of blob data. The script calls the sui_getObject JSON-RPC method on a public Sui full node and renders the per-epoch rewards and used storage capacity from the system state into a table.
<!--
Copyright (c) Walrus Foundation
SPDX-License-Identifier: Apache-2.0
-->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Walrus System Object Data</title>
<script>
async function fetchSuiObject() {
const endpoint = 'https://graphql.mainnet.sui.io/graphql';
// The Walrus system object on Sui Mainnet. Its single dynamic
// field holds the inner system state, so this query keeps working
// across Walrus contract upgrades.
const objectId = '0x2134d52768ea07e8c43570ef975eb3e4c27a39fa6396bef985b5abc58d03ddd2';
const query = `query ($objectId: SuiAddress!) {
object(address: $objectId) {
dynamicFields(first: 1) {
nodes {
value { ... on MoveValue { json } }
}
}
}
}`;
try {
const response = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query, variables: { objectId } })
});
const data = await response.json();
const data_results =
data.data.object.dynamicFields.nodes[0]
.value.json.future_accounting.ring_buffer;
data_results.sort((a, b) => a.epoch - b.epoch);
let totalRewards = 0;
let table = '<table border=\"1\" cellspacing=\"0\" cellpadding=\"5\">' +
'<thead><tr><th>Epoch</th><th>Rewards to Distribute (WAL)</th><th>Used Capacity (GiB)</th></tr></thead>' +
'<tbody>';
data_results.forEach(item => {
const rewardsWal = parseInt(item.rewards_to_distribute) / 1e9;
const usedCapacityGib = parseInt(item.used_capacity) / (1<<30);
totalRewards += rewardsWal;
table += `<tr>` +
`<td style="text-align:right;">${item.epoch}</td>` +
`<td style="text-align:right;">${rewardsWal.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>` +
`<td style="text-align:right;">${usedCapacityGib.toLocaleString(undefined, {maximumFractionDigits: 0})}</td>` +
`</tr>`;
});
table += `<tr>` +
`<td><strong>Total</strong></td>` +
`<td style="text-align:right;"><strong>${totalRewards.toLocaleString(undefined, {maximumFractionDigits: 0})}</strong></td>` +
`<td></td></tr>` +
'</tbody></table>';
document.getElementById('result').innerHTML = table;
} catch (error) {
document.getElementById('result').textContent = 'Error: ' + error.message;
}
}
window.onload = fetchSuiObject;
</script>
</head>
<body>
<h2>Walrus System Object Data</h2>
<pre id="result">Loading...</pre>
</body>
</html>
Default storage duration
If you omit epochs, the publisher fills in the default of 1 when it parses the query parameters of the store request. The value counts epochs ahead of the current one, and the resulting end epoch is exclusive, so the default stores the blob only until the end of the current epoch.