<!doctype html>
<html>
<title>`wasi:keyvalue`</title>
<head>
<meta content="text/html;charset=utf-8" http-equiv="Content-Type" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bulma@1.0.2/css/bulma.min.css">
<style>
* {
scrollbar-width: thin;
scrollbar-color: rgb(255 255 255 / 0.3) transparent;
}
.section:has(#template-output .field) {
margin-bottom: 3em;
}
#log:has(option[value="log"]:checked) :is([data-level="debug"]) {
display: none
}
#log:has(option[value="info"]:checked) :is([data-level="debug"], [data-level="log"]) {
display: none
}
#log:has(option[value="warn"]:checked) :is([data-level="debug"], [data-level="log"], [data-level="info"]) {
display: none
}
#log:has(option[value="error"]:checked) :is([data-level="debug"], [data-level="log"], [data-level="info"], [data-level="warn"]) {
display: none;
}
</style>
<script type="module">
let conn;
let buckets = new Map();
function toHex(v) {
if (!v) return '';
return v.reduce((s, x) => s + x.toString(16).padStart(2, '0'), '')
}
function uuidString(v) {
return v.reduce((s, x) => {
const hex = x.toString(16).padStart(2, '0');
switch (s.length) {
case 8:
case 13:
case 18:
case 23:
return s + "-" + hex;
default:
return s + hex;
}
}, '')
}
async function wasiKeyvalueStoreOpen(tx, rx, identifier) {
const text = new TextEncoder();
const idBuf = text.encode(identifier);
if (idBuf.length > 127) throw 'this example currently does not support identifiers longer than 127 bytes - open a PR!';
const payloadBytes = 1 + idBuf.length;
if (payloadBytes > 127) throw 'this example currently does not support requests payload longer than 127 bytes - open a PR!';
let buf = new Uint8Array([
0,
'wasi:keyvalue/store@0.2.0-draft2'.length,
...text.encode('wasi:keyvalue/store@0.2.0-draft2'),
'open'.length,
...text.encode('open'),
0,
payloadBytes,
idBuf.length,
...idBuf
]);
dbg.debug('writing `open("' + identifier + '")` invocation: ' + toHex(buf));
await tx.write(buf);
dbg.debug('awaiting write to complete');
await tx.ready;
tx.close();
dbg.debug('reading `open` response...');
buf = null;
return await rx.read().then(function handleChunk({done, value}) {
if (!buf) {
buf = value;
} else if (value) {
const b = new Uint8Array(buf.length + value.length);
b.set(buf);
b.set(value, buf.length);
buf = b;
}
if (done) {
if (!buf || buf.length < 4) throw 'unexpected EOF';
dbg.debug('received `open` response: ' + toHex(buf));
if (buf[0] != 0) throw 'unexpected path'
if (buf[1] > 127) throw 'only decoding responses of up to 127 bytes is currently supported - open a PR!'
switch (buf[2]) {
case 0:
return buf.subarray(4);
case 1:
switch (buf[3]) {
case 0:
throw "no such store";
case 1:
throw "access denied";
case 2:
const err = new TextDecoder().decode(buf.subarray(5));
throw err;
}
default:
throw "invalid result status byte"
}
}
return rx.read().then(handleChunk);
}, err => {
throw 'failed to read `open` response: ' + err;
})
}
async function wasiKeyvalueStoreBucketGet(tx, rx, bucket, key) {
const text = new TextEncoder();
if (bucket.length > 127) throw 'this example currently does not support buckets longer than 127 bytes - open a PR!';
const keyBuf = text.encode(key);
if (keyBuf.length > 127) throw 'this example currently does not support keys longer than 127 bytes - open a PR!';
const payloadBytes = 2 + bucket.length + keyBuf.length;
if (payloadBytes > 127) throw 'this example currently does not support request payloads longer than 127 bytes - open a PR!';
let buf = new Uint8Array([
0,
'wasi:keyvalue/store@0.2.0-draft2'.length,
...text.encode('wasi:keyvalue/store@0.2.0-draft2'),
'bucket.get'.length,
...text.encode('bucket.get'),
0,
payloadBytes,
bucket.length,
...bucket,
keyBuf.length,
...keyBuf
]);
dbg.debug('writing `bucket.get("' + bucket + '", "' + key + '")` invocation: ' + toHex(buf));
await tx.write(buf);
dbg.debug('awaiting write to complete');
await tx.ready;
tx.close();
dbg.debug('reading `bucket.get` response...');
buf = null;
return await rx.read().then(function handleChunk({done, value}) {
if (!buf) {
buf = value;
} else if (value) {
const b = new Uint8Array(buf.length + value.length);
b.set(buf);
b.set(value, buf.length);
buf = b;
}
if (done) {
if (!buf || buf.length < 4) throw 'unexpected EOF';
dbg.debug('received `bucket.get` response: ' + toHex(buf));
if (buf[0] != 0) throw 'unexpected path'
if (buf[1] > 127) throw 'only decoding responses of up to 127 bytes is currently supported - open a PR!'
switch (buf[2]) {
case 0:
switch (buf[3]) {
case 0:
return null
case 1:
if (buf.length < 5) throw 'unexpected EOF'
return buf.subarray(5)
default:
throw "invalid option status byte"
}
case 1:
switch (buf[3]) {
case 0:
throw "no such store";
case 1:
throw "access denied";
case 2:
const err = new TextDecoder().decode(buf.subarray(5));
throw err;
}
default:
throw "invalid result status byte"
}
}
return rx.read().then(handleChunk);
}, err => {
throw 'failed to read `bucket.get` response: ' + err;
})
}
async function wasiKeyvalueStoreBucketSet(tx, rx, bucket, key, value) {
const text = new TextEncoder();
if (bucket.length > 127) throw 'this example currently does not support buckets longer than 127 bytes - open a PR!';
if (value.length > 127) throw 'this example currently does not support values longer than 127 bytes - open a PR!';
const keyBuf = text.encode(key);
if (keyBuf.length > 127) throw 'this example currently does not support keys longer than 127 bytes - open a PR!';
const payloadBytes = 3 + bucket.length + keyBuf.length + value.length;
if (payloadBytes > 127) throw 'this example currently does not support request payloads longer than 127 bytes - open a PR!';
let buf = new Uint8Array([
0,
'wasi:keyvalue/store@0.2.0-draft2'.length,
...text.encode('wasi:keyvalue/store@0.2.0-draft2'),
'bucket.set'.length,
...text.encode('bucket.set'),
0,
payloadBytes,
bucket.length,
...bucket,
keyBuf.length,
...keyBuf,
value.length,
...value
]);
dbg.debug('writing `bucket.set("' + bucket + '", "' + key + '", "' + value + '") invocation: ' + toHex(buf));
await tx.write(buf);
dbg.debug('awaiting write to complete');
await tx.ready;
tx.close();
dbg.debug('reading `bucket.set` response...');
buf = null;
await rx.read().then(function handleChunk({done, value}) {
if (!buf) {
buf = value;
} else if (value) {
const b = new Uint8Array(buf.length + value.length);
b.set(buf);
b.set(value, buf.length);
buf = b;
}
if (done) {
if (!buf || buf.length < 3) throw 'unexpected EOF';
dbg.debug('received `bucket.set` response: ' + toHex(buf));
if (buf[0] != 0) throw 'unexpected path'
if (buf[1] > 127) throw 'only decoding responses of up to 127 bytes is currently supported - open a PR!'
switch (buf[2]) {
case 0:
if (buf.length != 3) throw "unparsed bytes left on stream";
return
case 1:
if (buf.length < 4) throw 'unexpected EOF';
switch (buf[3]) {
case 0:
throw "no such store";
case 1:
throw "access denied";
case 2:
const err = new TextDecoder().decode(buf.subarray(5));
throw err;
}
default:
throw "invalid result status byte"
}
}
rx.read().then(handleChunk);
}, err => {
throw 'failed to read `bucket.set` response: ' + err;
})
}
async function getBucket() {
if (!conn) throw 'WebTransport not connected';
const obj = getFormValues('#settings');
let identifier = '';
let proto = obj.proto;
if (proto === 'mem') {
identifier = '';
} else if (proto === 'nats') {
const addr = obj['nats-addr'];
if (!addr) throw 'NATS.io server address must be set';
identifier = 'wrpc+nats://' + addr;
const prefix = obj['nats-prefix'];
if (prefix) identifier = identifier + '/' + prefix;
const bucket = obj['nats-bucket'];
if (bucket) identifier = identifier + ';' + bucket;
} else if (proto === 'redis') {
const url = obj['redis-url'];
if (!url) throw 'Redis URL must be set';
identifier = url;
} else if (proto === 'quic') {
const addr = obj['quic-addr'];
if (!addr) throw 'QUIC address must be set';
identifier = 'wrpc+quic://' + addr;
const bucket = obj['quic-bucket'];
if (bucket) identifier = identifier + ';' + bucket;
} else if (proto === 'tcp') {
const addr = obj['tcp-addr'];
if (!addr) throw 'TCP address must be set';
identifier = 'wrpc+tcp://' + addr;
const bucket = obj['tcp-bucket'];
if (bucket) identifier = identifier + ';' + bucket;
} else if (proto === 'unix') {
const path = obj['unix-path'];
if (!path) throw 'Unix Domain Socket path must be set';
identifier = 'wrpc+unix://' + path;
const bucket = obj['unix-bucket']
if (bucket) identifier = identifier + ';' + bucket;
} else if (proto === 'web') {
const addr = obj['web-addr'];
if (!addr) throw 'WebTransport address must be set';
identifier = 'wrpc+web://' + addr;
const bucket = obj['web-bucket'];
if (bucket) identifier = identifier + ';' + bucket;
} else {
throw 'selected wRPC transport not supported yet';
}
if (identifier.length > 127) throw 'this example currently does not support identifiers longer than 127 bytes - open a PR!';
const bucket = buckets.get(identifier);
if (bucket) return bucket;
dbg.debug('creating `open` stream...');
const stream = await conn.createBidirectionalStream();
return await wasiKeyvalueStoreOpen(
stream.writable.getWriter(),
stream.readable.getReader(),
identifier,
).then(bucket => {
const bucketName = uuidString(bucket);
dbg.log(`opened bucket ${bucketName}`);
buckets.set(identifier, bucket);
return bucket;
}, err => {
throw 'failed to open bucket: ' + err;
});
}
async function handleGet() {
const getValue = document.querySelector('#get input[name="get-value"]');
if (getValue) getValue.value = null;
if (!conn) throw 'WebTransport not connected';
const bucket = await getBucket();
if (!bucket) throw 'failed to get bucket';
const obj = getFormValues('#get');
const key = obj['get-key'];
if (!key) throw 'key must be set';
if (key.length > 127) throw 'this example currently does not support keys longer than 127 bytes - open a PR!';
dbg.debug('creating `get` stream...');
const stream = await conn.createBidirectionalStream();
await wasiKeyvalueStoreBucketGet(
stream.writable.getWriter(),
stream.readable.getReader(),
bucket,
key,
).then(value => {
if (!value) {
dbg.info('key missing')
return;
}
const s = new TextDecoder().decode(value);
const bucketName = uuidString(bucket);
dbg.info(`got value from bucket ${bucketName}:`, JSON.stringify(s, null, 2));
if (getValue) getValue.value = s;
}, err => {
throw 'failed to get value: ' + err;
});
}
async function handleSet() {
if (!conn) throw 'WebTransport not connected';
const bucket = await getBucket();
if (!bucket) throw 'failed to get bucket';
const obj = getFormValues('#set');
const key = obj['set-key'];
if (!key) throw 'key must be set';
if (key.length > 127) throw 'this example currently does not support keys longer than 127 bytes - open a PR!';
const value = obj['set-value'];
let valueBuf;
if (value) {
valueBuf = new TextEncoder().encode(value);
} else {
valueBuf = new Uint8Array();
}
if (valueBuf.length > 127) throw 'this example currently does not support values longer than 127 bytes - open a PR!';
dbg.debug('creating `set` stream...');
const stream = await conn.createBidirectionalStream();
await wasiKeyvalueStoreBucketSet(
stream.writable.getWriter(),
stream.readable.getReader(),
bucket,
key,
valueBuf,
).then(() => {
const bucketName = uuidString(bucket);
dbg.info(`set value in bucket ${bucketName}`);
}, err => {
throw 'failed to set value: ' + err
});
}
const dbg = (() => {
const output = document.querySelector('#message-output');
const className = {
debug: 'has-text-grey',
log: 'has-text-normal',
info: 'has-text-info',
warn: 'has-text-warning',
error: 'has-text-danger',
};
const handleLog = (value, level = 'log') => {
level = ['debug', 'log', 'info', 'warn', 'error'].includes(level) ? level : 'info';
const prefix = new Date().toISOString();
const levelTag = `[${level}]`.padStart(7, ' ');
const span = document.createElement('span');
span.textContent = `${prefix} ${levelTag} ${value.join(' ')}\n`;
span.classList.add(className[level]);
span.dataset.level = level;
if (!output) {
console.error(span.textContent)
return
}
output.prepend(span)
}
return {
debug: (...value) => handleLog(value, 'debug'),
log: (...value) => handleLog(value, 'log'),
info: (...value) => handleLog(value, 'info'),
warn: (...value) => handleLog(value, 'warn'),
error: (...value) => handleLog(value, 'error'),
}
})()
function initUI() {
function updateTemplate() {
const option = protoDropdown?.value ?? 'mem'
const defaultTemplate = document.querySelector('.form-fields[data-option=default]');
const templateOutput = document.querySelector('#template-output');
const template = document.querySelector(`.form-fields[data-option=${option}]`) ?? defaultTemplate;
if (templateOutput && template) templateOutput.innerHTML = template.innerHTML;
}
const protoDropdown = document.querySelector('#proto');
protoDropdown?.addEventListener('change', updateTemplate);
updateTemplate();
const settingsForm = document.querySelector('#settings');
settingsForm?.addEventListener('submit', (e) => e.preventDefault());
const getForm = document.querySelector('#get');
getForm?.addEventListener('submit', (e) => {
e.preventDefault();
handleGet().catch(dbg.error);
});
const setForm = document.querySelector('#set');
setForm?.addEventListener('submit', (e) => {
e.preventDefault();
handleSet().catch(dbg.error);
});
};
function getFormValues(selector) {
const form = document.querySelector(selector);
if (!form) throw new Error('form not found');
const formData = new FormData(form)
const formEntries = Array.from(formData);
return Object.fromEntries(formEntries.map(([key, value]) => [key, value.toString()]));
}
initUI();
const {PORT, CERT_DIGEST} = await import('./consts.js');
for (; ;) {
dbg.log('connecting to wRPC over WebTransport on `' + PORT + '`...');
let c;
try {
c = new WebTransport('https://localhost:' + PORT, {
serverCertificateHashes: [
{
algorithm: 'sha-256',
value: CERT_DIGEST.buffer
}
]
});
} catch (err) {
dbg.error('failed to connect to WebTransport endpoint: ' + err);
await new Promise(r => setTimeout(r, 1000));
continue;
}
dbg.debug('waiting for WebTransport connection to be established...');
try {
await c.ready;
} catch (err) {
dbg.error('failed to establish WebTransport connection: ' + err);
await new Promise(r => setTimeout(r, 1000));
continue;
}
conn = c;
dbg.info('WebTransport connection established');
try {
const {closeCode, reason} = await conn.closed;
dbg.log(`WebTransport connection closed with code '${closeCode}': ${reason}`);
} catch (err) {
dbg.error('WebTransport connection failed: ' + err);
}
conn = null;
}
</script>
</head>
<body>
<section class="section columns is-mobile">
<div class="column">
<h1 class="title">
<a href="https://wa.dev/wasi:keyvalue@0.2.0-draft2">
<code>wasi:keyvalue</code>
</a>
</h1>
</div>
<div class="column is-narrow">
<div class="select">
<select id="proto" form="settings" name="proto">
<option value="mem">In-memory</option>
<option value="redis">Redis</option>
<option value="nats">wRPC/NATS.io</option>
<option value="quic">wRPC/QUIC</option>
<option value="tcp">wRPC/TCP</option>
<option value="unix">wRPC/Unix domain sockets</option>
<option value="web">wRPC/WebTransport</option>
</select>
</div>
</div>
</section>
<section class="section py-0">
<form id="settings">
<div id="template-output"></div>
<template class="form-fields" data-option="default"></template>
<template class="form-fields" data-option="redis">
<div class="field">
<label class="label">Redis server URL</label>
<div class="control">
<input class="input" type="text" name="redis-url" placeholder="redis://localhost:6379"
value="redis://localhost:6379" />
</div>
</div>
</template>
<template class="form-fields" data-option="nats">
<div class="field">
<label class="label">Bucket identifier</label>
<div class="control">
<input class="input" type="text" name="nats-bucket" />
</div>
</div>
<div class="field">
<label class="label">NATS.io server address</label>
<div class="control">
<input class="input" type="text" name="nats-addr" placeholder="localhost:4222"
value="localhost:4222" />
</div>
</div>
<div class="field">
<label class="label">NATS.io prefix</label>
<div class="control">
<input class="input" type="text" name="nats-prefix" />
</div>
</div>
</template>
<template class="form-fields" data-option="quic">
<div class="field">
<label class="label">Bucket identifier</label>
<div class="control">
<input class="input" type="text" name="quic-bucket" />
</div>
</div>
<div class="field">
<label class="label">QUIC socket address</label>
<div class="control">
<input class="input" type="text" name="quic-addr" placeholder="[::1]:4433" value="[::1]:4433" />
</div>
</div>
</template>
<template class="form-fields" data-option="tcp">
<div class="field">
<label class="label">Bucket identifier</label>
<div class="control">
<input class="input" type="text" name="tcp-bucket" />
</div>
</div>
<div class="field">
<label class="label">TCP socket address</label>
<div class="control">
<input class="input" type="text" name="tcp-addr" placeholder="[::1]:7761" value="[::1]:7761" />
</div>
</div>
</template>
<template class="form-fields" data-option="unix">
<div class="field">
<label class="label">Bucket identifier</label>
<div class="control">
<input class="input" type="text" name="unix-bucket" />
</div>
</div>
<div class="field">
<label class="label">Path to Unix socket</label>
<div class="control">
<input class="input" type="text" name="unix-path" placeholder="/tmp/wrpc/wasi/keyvalue.sock"
value="/tmp/wrpc/wasi/keyvalue.sock" />
</div>
</div>
</template>
<template class="form-fields" data-option="web">
<div class="field">
<label class="label">Bucket identifier</label>
<div class="control">
<input class="input" type="text" name="web-bucket" />
</div>
</div>
<div class="field">
<label class="label">WebTransport address</label>
<div class="control">
<input class="input" type="text" name="web-addr" placeholder="localhost:4433"
value="localhost:4433" />
</div>
</div>
</template>
</form>
</section>
<section class="container px-5 mb-6 grid is-gap-6">
<div class="cell">
<h2 class="title is-4 has-text-centered">Set</h2>
<form id="set">
<div class="field is-horizontal">
<div class="field-body">
<div class="field is-grouped">
<div class="control is-expanded">
<input class="input" name="set-key" placeholder="key" />
</div>
<div class="control is-expanded">
<input class="input" name="set-value" placeholder="value" />
</div>
<div class="control">
<button type="submit" class="button is-primary">Set</button>
</div>
</div>
</div>
</div>
</form>
</div>
<div class="cell">
<h2 class="title is-4 has-text-centered">Get</h2>
<form id="get">
<div class="field is-horizontal">
<div class="field-body">
<div class="field is-grouped">
<div class="control is-expanded">
<input class="input" name="get-key" placeholder="key" />
</div>
<div class="control is-expanded">
<input class="input" name="get-value" disabled readonly />
</div>
<div class="control">
<button type="submit" class="button is-info">Get</button>
</div>
</div>
</div>
</div>
</form>
</div>
</section>
<section id="log">
<div class="columns px-3 container is-mobile">
<div class="column">
<h3 class="title is-4">Output</h3>
</div>
<div class="column is-narrow">
<div class="field is-grouped">
<div class="field-label is-small">
<label class="label">Log Level</label>
</div>
<div class="field-body">
<div class="field">
<div class="select is-small">
<select>
<option value="debug">debug</option>
<option value="log">log</option>
<option value="info" selected>info</option>
<option value="warn">warn</option>
<option value="error">error</option>
</select>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="has-background">
<pre class="container has-background-inherit"><code>
<p id="message-output"></p>
</code></pre>
</div>
</section>
</body>
</html>