1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
# 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](oauth.md)).
## 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.
```bash run
python3 -m http.server 8000 --bind 127.0.0.1 --directory examples/web/hello &
```
```bash run
curl -s localhost:8000/ | grep '<hello-'
```
```output
<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`
```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](../start/page.md)).
## Load a module
A local build: the wasm and its module.json.
```js run browser
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))
```
```output
["com.atproto.repo.listRecords"]
```
A published one: its record and blob, fetched from the publisher's PDS and checked against the CID.
```js run=network browser
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))
```
```output
["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](../server/node.md)).
## 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:
```js run browser
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))
```
```output
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](oauth.md)).
In the example, `main.js` creates the context and the modules and hands them to the cubit:
`examples/web/hello/main.js`
```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`
```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`
```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`
```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`
```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`
```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`
```js
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.
```bash run
node --test examples/web/hello/*.test.mjs 2>&1 | grep -E '^ℹ fail'
```
```output
ℹ fail 0
```
## Rules
- One wasm event gets 5 s; a run's calls, events and time are budgeted ([limits](../reference/limits.md)).
- 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](../how-it-works.md) | Next: [Sign in from a page](oauth.md)