~ knotless Search/
↑↓ move↵ open⇧↵ all results

johnandersen777.bsky.social/atlibs

0
0
main
255 lines · 8.2 KB Raw History

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.json lists.
  • A module may update or delete only records it created through this host, unless its module.json lists the collection in anyRecords.

Previous: How it works | Next: Sign in from a page