Run modules in a page
The browser library is javascript/src: plain ES modules, no dependencies, no build step. A page
imports it through an import map, runs modules in a Worker and renders what they return.
The example is examples/web/hello: it counts an account's records (signed out), and sets your
Statusphere status (signed in, next page).
Serve the page
examples/web/hello/atlibs links to javascript/src and modules/ to the cargo wasm builds, so the
directory serves on its own.
python3 -m http.server 8000 --bind 127.0.0.1 --directory examples/web/hello &
curl -s localhost:8000/ | grep '<hello-'
<hello-count></hello-count>
<hello-status></hello-status>
Open http://127.0.0.1:8000/. On Windows, clone with git config --global core.symlinks true (and
Developer Mode on), or the links check out as text files.
The import map
examples/web/hello/index.html
<!DOCTYPE html>
<meta charset="utf-8">
<title>hello</title>
<script type="importmap">
{
"imports": {
"@atproto/oauth-client-browser": "https://esm.sh/@atproto/oauth-client-browser@0.5.14",
"atlibs/": "./atlibs/"
}
}
</script>
<script type="module" src="./main.js"></script>
<h1>hello</h1>
<hello-count></hello-count>
<hello-status></hello-status>
Outside this repo, atlibs/ is https://cdn.jsdelivr.net/npm/@publicdomainrelay/atlibs@0.5.0/src/
(Use in a page).
Load a module
A local build: the wasm and its module.json.
import { localModule } from 'atlibs/run-module/index.js'
const modules = new URL('./examples/web/hello/modules/', location.href)
const hello = await localModule(new URL('hello.wasm', modules), new URL('hello.json', modules))
console.log(JSON.stringify(hello.calls))
["com.atproto.repo.listRecords"]
A published one: its record and blob, fetched from the publisher's PDS and checked against the CID.
import { createPluginContext } from 'atlibs/plugin-context/index.js'
import { loadModule } from 'atlibs/run-module/index.js'
const ctx = createPluginContext({ id: 'docs' })
const statusphere = await loadModule(ctx, 'bobvmbuilder.bsky.social/statusphere')
console.log(JSON.stringify(statusphere.calls))
["com.atproto.repo.listRecords","com.atproto.repo.createRecord","com.atproto.repo.deleteRecord"]
These run as page modules in headless Chrome under the import map above. On a server,
createNodeContext takes the place of createPluginContext (Node server).
Run an entry
runModule(ctx, module, entry, input, call) runs the step loop. ctx is the page's PluginContext
(storage, fetch, the wasm Worker); call(nsid, request) makes each XRPC call the module asks for. Here a
stub answers them:
import { createPluginContext } from 'atlibs/plugin-context/index.js'
import { localModule, runModule } from 'atlibs/run-module/index.js'
const ctx = createPluginContext({ id: 'docs' })
const modules = new URL('./examples/web/hello/modules/', location.href)
const statusphere = await localModule(new URL('statusphere.wasm', modules), new URL('statusphere.json', modules))
const call = async (nsid, { params, input }) => {
console.log(nsid)
return nsid === 'com.atproto.repo.listRecords' ? { records: [] } : { uri: `at://${input.repo}/${input.collection}/3k`, cid: 'bafy' }
}
const { result, error } = await runModule(ctx, statusphere, 'set_status', { did: 'did:plc:alice', status: '🦋' }, call)
console.log(JSON.stringify(error ?? result))
com.atproto.repo.listRecords
com.atproto.repo.createRecord
{"status":"🦋","unchanged":false,"uri":"at://did:plc:alice/xyz.statusphere.status/3k"}
It resolves to { entry, result, error?, events, calls }. Module and call errors come back as
error; runModule does not throw for them.
hostCall({ ctx }) makes the calls for real. With no session it makes public reads only; with an OAuth
session it acts as the user (Sign in from a page).
In the example, main.js creates the context and the modules and hands them to the cubit:
examples/web/hello/main.js
const cubit = new HelloCubit({
ctx: createPluginContext({ id: 'hello' }),
…
hello: local('hello'),
statusphere: local('statusphere'),
})
The cubit runs entries with them:
examples/web/hello/hello-cubit.js
// Signed out: hostCall with no session makes public reads only.
async count({ did, collection }) {
this.emit({ count: { ...IDLE, status: 'busy' } })
this.emit({ count: await this.#run(this.#hello, 'count', { did, collection }, hostCall({ ctx: this.#ctx })) })
}
examples/web/hello/hello-cubit.js
async #run(module, entry, input, call) {
try {
const { result, error } = await runModule(this.#ctx, await module, entry, input, call)
return error === undefined ? { status: 'done', result, error: null } : { status: 'error', result: null, error }
} catch (error) {
return { status: 'error', result: null, error: error.message }
}
}
Render the result
State lives in a Cubit: emit(patch) replaces it, subscribe(fn) renders it. A component keeps its
markup in an .html file of <template>s (templated(url)), sends events up and renders the state it
is handed.
examples/web/hello/hello-count.html
<template>
<h2>Count records</h2>
<form>
<input name="did" placeholder="did:plc:..." required>
<input name="collection" value="xyz.statusphere.status" required>
<button>Count</button>
</form>
<p class="out"></p>
</template>
examples/web/hello/hello-count.js
import { templated } from 'atlibs/component/index.js'
const Base = await templated(new URL('./hello-count.html', import.meta.url))
// Markup in .html, behaviour here: send events up, render the state it is given.
customElements.define('hello-count', class extends Base {
connectedCallback() {
super.connectedCallback()
this.querySelector('form').onsubmit = (e) => {
e.preventDefault()
this.dispatchEvent(new CustomEvent('count', { bubbles: true, detail: Object.fromEntries(new FormData(e.target)) }))
}
}
render({ count: { status, result, error } }) {
this.querySelector('button').disabled = status === 'busy'
this.querySelector('.out').textContent = error ?? (result ? `${result.records} records in ${result.collection}` : '')
}
})
Wiring, in main.js:
examples/web/hello/main.js
// Wiring only: component events go to the cubit, cubit state goes to the components.
const views = document.querySelectorAll('hello-count, hello-status')
document.addEventListener('count', (e) => cubit.count(e.detail))
document.addEventListener('login', (e) => cubit.login(e.detail))
document.addEventListener('status', (e) => cubit.setStatus(e.detail))
document.addEventListener('logout', () => cubit.logout())
cubit.subscribe((state) => views.forEach((view) => view.render(state)))
await cubit.init()
Test it
The cubit takes its dependencies, so a Node test runs it with the real modules and stubs only OAuth and the network.
examples/web/hello/hello-cubit.test.mjs
test("count runs signed out: a public read of the account's PDS", async () => {
const cubit = await cubitWith(undefined);
assert.equal(cubit.state.session, "signed-out");
await cubit.count({ did, collection: "xyz.statusphere.status" });
assert.deepEqual(cubit.state.count, { status: "done", result: { collection: "xyz.statusphere.status", records: 1 }, error: null });
});
hello.test.mjs loads index.html itself in headless Chrome (skipped without Chrome): the import map
and module graph, both components rendered signed out, and Count running the hello module in a Worker.
Only the PLC directory, the PDS and, without ATLIBS_DOCS_NETWORK=1, esm.sh are stubbed.
node --test examples/web/hello/*.test.mjs 2>&1 | grep -E '^ℹ fail'
ℹ fail 0
Rules
- One wasm event gets 5 s; a run's calls, events and time are budgeted (limits).
- A module may only make the calls its
module.jsonlists. - A module may update or delete only records it created through this host, unless its
module.jsonlists the collection inanyRecords.
Previous: How it works | Next: Sign in from a page