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
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
// Browser implementation of the parts of emdash's PluginContext that
// atproto-integrations-emdash uses: storage, settings, kv, http, wasm, log,
// site, url and plugin. Storage lives in IndexedDB; every compare-and-set runs
// inside one readwrite transaction, so it is atomic across tabs too.
const WASM_LIMITS = { moduleBytes: 4 << 20, inputBytes: 1 << 20, outputBytes: 1 << 20, memoryPages: 1024, wallTimeMs: 5000 };
const ENTRY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]{0,255}$/;
const done = (request) =>
new Promise((resolve, reject) => {
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
function openDatabase(name) {
const request = indexedDB.open(name, 1);
request.onupgradeneeded = () => request.result.createObjectStore("rows");
return done(request);
}
// Values round-trip through JSON, as they do in emdash's database.
const json = (value) => JSON.parse(JSON.stringify(value ?? null));
export function matches(data, where = {}) {
return Object.entries(where).every(([field, cond]) => {
const value = data?.[field] ?? null;
if (cond === null || typeof cond !== "object") return value === cond;
if ("in" in cond) return cond.in.includes(value);
if ("startsWith" in cond) return typeof value === "string" && value.startsWith(cond.startsWith);
return (
value !== null &&
(cond.gt === undefined || value > cond.gt) &&
(cond.gte === undefined || value >= cond.gte) &&
(cond.lt === undefined || value < cond.lt) &&
(cond.lte === undefined || value <= cond.lte)
);
});
}
export function compareBy(orderBy = {}) {
const fields = Object.entries(orderBy);
return (a, b) => {
for (const [field, direction] of fields) {
const x = a.value?.[field];
const y = b.value?.[field];
if (x === y) continue;
const order = x === undefined || x === null ? -1 : y === undefined || y === null ? 1 : x < y ? -1 : 1;
return direction === "desc" ? -order : order;
}
return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
};
}
// fn may only await IndexedDB requests; awaiting anything else lets the transaction commit early.
async function transaction(database, mode, fn) {
const tx = (await database).transaction("rows", mode);
const finished = new Promise((resolve, reject) => {
tx.oncomplete = resolve;
tx.onerror = tx.onabort = () => reject(tx.error);
});
const [result] = await Promise.all([fn(tx.objectStore("rows")), finished]);
return result;
}
function collection(database, namespace) {
const key = (id) => [namespace, id];
const row = (id, data) => ({ id, value: json(data), revision: crypto.randomUUID() });
const read = (fn) => transaction(database, "readonly", fn);
const write = (fn) => transaction(database, "readwrite", fn);
// Keys are [namespace, id]; an array sorts after every string, so this spans one namespace.
const rows = () => read((store) => done(store.getAll(IDBKeyRange.bound([namespace], [namespace, []]))));
const self = {
get: async (id) => (await read((store) => done(store.get(key(id)))))?.value ?? null,
getVersioned: async (id) => {
const found = await read((store) => done(store.get(key(id))));
return found ? { value: found.value, revision: found.revision } : null;
},
exists: async (id) => (await read((store) => done(store.count(key(id))))) > 0,
put: (id, data) => write((store) => done(store.put(row(id, data), key(id)))).then(() => undefined),
delete: (id) =>
write(async (store) => {
const found = await done(store.count(key(id)));
await done(store.delete(key(id)));
return found > 0;
}),
compareAndSet: (id, expectedRevision, data) =>
write(async (store) => {
const found = await done(store.get(key(id)));
if ((found?.revision ?? null) !== expectedRevision) return { applied: false };
const next = row(id, data);
await done(store.put(next, key(id)));
return { applied: true, revision: next.revision };
}),
compareAndDelete: (id, expectedRevision) =>
write(async (store) => {
const found = await done(store.get(key(id)));
if (!found || found.revision !== expectedRevision) return { applied: false };
await done(store.delete(key(id)));
return { applied: true };
}),
getMany: (ids) =>
read(async (store) => {
const found = await Promise.all(ids.map((id) => done(store.get(key(id)))));
return new Map(found.filter(Boolean).map((item) => [item.id, item.value]));
}),
putMany: (items) =>
write((store) => Promise.all(items.map(({ id, data }) => done(store.put(row(id, data), key(id)))))).then(() => undefined),
deleteMany: (ids) =>
write(async (store) => {
const counts = await Promise.all(ids.map((id) => done(store.count(key(id)))));
await Promise.all(ids.map((id) => done(store.delete(key(id)))));
return counts.reduce((sum, count) => sum + count, 0);
}),
// ponytail: scans the whole namespace and pages by offset; fine for plugin-sized tables, add IDB indexes if they grow.
query: async ({ where, orderBy, limit = 50, cursor } = {}) => {
const all = (await rows()).filter((item) => matches(item.value, where)).sort(compareBy(orderBy));
const start = cursor ? Number(cursor) : 0;
const end = start + Math.min(Math.max(limit, 1), 100);
const hasMore = end < all.length;
return { items: all.slice(start, end).map(({ id, value }) => ({ id, data: value })), hasMore, ...(hasMore && { cursor: String(end) }) };
},
count: async (where) => (await rows()).filter((item) => matches(item.value, where)).length,
// Update-only: absent row, non-object document, failed guard or bad counter all return applied: false.
updateIf: (id, { where, set = {}, delta = {} }) => {
const changes = json(set);
const steps = Object.entries(delta).map(([field, spec]) => {
if (field in changes) throw new TypeError("Storage field appears in both set and delta");
const amount = "inc" in spec ? spec.inc : -spec.dec;
if (!Number.isSafeInteger(amount)) throw new TypeError("Storage delta must be a safe integer");
return [field, amount];
});
return write(async (store) => {
const data = (await done(store.get(key(id))))?.value;
if (typeof data !== "object" || data === null || Array.isArray(data) || !matches(data, where)) return { applied: false };
const next = { ...data, ...changes };
for (const [field, amount] of steps) {
next[field] = data[field] + amount;
if (!Number.isSafeInteger(data[field]) || !Number.isSafeInteger(next[field])) return { applied: false };
}
const updated = row(id, next);
await done(store.put(updated, key(id)));
return { applied: true, data: updated.value };
});
},
// SettingsAccess / KVAccess shape
set: (id, value) => self.put(id, value),
list: async (prefix = "") =>
(await rows()).filter((item) => item.id.startsWith(prefix)).map(({ id, value }) => ({ key: id, value })),
};
return self;
}
// Non-extractable AES-GCM key kept in IndexedDB: page script can use it but never read its bytes.
function secretKey(database) {
let key;
return () =>
(key ??= crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, false, ["encrypt", "decrypt"]).then((fresh) =>
transaction(database, "readwrite", async (store) => {
const found = await done(store.get(["meta", "secretKey"]));
if (found) return found;
await done(store.put(fresh, ["meta", "secretKey"]));
return fresh;
}),
));
}
// Settings whose schema type is "secret" are stored encrypted, as emdash does, bound to plugin id and setting name.
export function settingsAccess(settings, schema, pluginId, key) {
const additionalData = (name) => new TextEncoder().encode(`${pluginId}:settings:${name}`);
const seal = async (name, value) => {
if (schema[name]?.type !== "secret" || value === null || value === undefined) return value;
const iv = crypto.getRandomValues(new Uint8Array(12));
const data = await crypto.subtle.encrypt({ name: "AES-GCM", iv, additionalData: additionalData(name) }, await key(), new TextEncoder().encode(JSON.stringify(value)));
return { $secret: { iv: iv.toBase64(), data: new Uint8Array(data).toBase64() } };
};
const open = async (name, value) => {
if (!value?.$secret) return value;
const { iv, data } = value.$secret;
const plain = await crypto.subtle.decrypt({ name: "AES-GCM", iv: Uint8Array.fromBase64(iv), additionalData: additionalData(name) }, await key(), Uint8Array.fromBase64(data));
return JSON.parse(new TextDecoder().decode(plain));
};
return {
get: async (name) => open(name, await settings.get(name)),
getVersioned: async (name) => {
const found = await settings.getVersioned(name);
return found && { ...found, value: await open(name, found.value) };
},
compareAndSet: async (name, revision, value) => settings.compareAndSet(name, revision, await seal(name, value)),
compareAndDelete: settings.compareAndDelete,
set: async (name, value) => settings.put(name, await seal(name, value)),
delete: settings.delete,
list: async (prefix) => Promise.all((await settings.list(prefix)).map(async ({ key, value }) => ({ key, value: await open(key, value) }))),
};
}
async function sha256Hex(bytes) {
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("");
}
const wasmError = (code, message) => Object.assign(new Error(`${code}: ${message}`), { name: "WasmRunError", code });
const uleb = (n) => {
const out = [];
do {
const low = n % 128;
n = Math.floor(n / 128);
out.push(n ? low | 128 : low);
} while (n);
return out;
};
/**
* The memory cap, enforced inside the call. V8 has no hook on a module's own memory.grow, and a
* defined memory's maximum is not visible through WebAssembly.Module, so the host rewrites the
* memory section's limits before compiling: maximum = `pages` (or the module's own, if lower;
* rustc declares none). memory.grow past it then returns -1 inside the call, as the Rust host's
* wasmtime StoreLimits does; a module that cannot handle that traps (WASM_TRAP). A minimum over
* `pages` is refused (WASM_TOO_LARGE), as are a second memory and memory64 (WASM_ABI_INVALID, as
* the Rust host's engine config). Other bytes are copied as they are.
* @param {Uint8Array} bytes a wasm binary
* @param {number} pages
*/
export function capMemory(bytes, pages) {
let at = 8; // magic + version
const leb = () => {
let value = 0;
let scale = 1;
let byte;
do {
byte = bytes[at++] ?? 0;
value += (byte & 127) * scale;
scale *= 128;
} while (byte & 128);
return value;
};
while (at < bytes.length) {
const start = at;
const id = bytes[at++];
const size = leb();
const end = at + size;
if (id === 5) {
const count = leb();
// One memory, 32-bit: the cap is per memory, so a second memory (or memory64) would escape it.
if (count > 1) throw wasmError("WASM_ABI_INVALID", "a module may declare at most one memory");
const body = [...uleb(count)];
for (let i = 0; i < count; i++) {
const flags = bytes[at++];
if (flags & 4) throw wasmError("WASM_ABI_INVALID", "memory64 is not supported");
const min = leb();
const max = flags & 1 ? leb() : pages;
if (min > pages) throw wasmError("WASM_TOO_LARGE", `memory exceeds ${pages} pages`);
body.push(flags | 1, ...uleb(min), ...uleb(Math.min(max, pages)));
}
const section = [5, ...uleb(body.length), ...body];
const out = new Uint8Array(start + section.length + bytes.length - end);
out.set(bytes.subarray(0, start));
out.set(section, start);
out.set(bytes.subarray(end), start + section.length);
return out;
}
at = end;
}
return bytes;
}
// Validates and compiles here (cached by digest), then runs the call in a fresh Worker
// (wasm-worker.js) that is terminated after timeoutMs, so a module that never returns
// costs one worker, not the page. Matches emdash's WASM_LIMITS.wallTimeMs by default.
// ponytail: one worker per call (a few ms each); pool them if calls get hot.
// The default spawn starts a same-origin blob that imports wasm-worker.js: a page may load atlibs from a
// CDN, and a Worker's script must be the page's own origin (a module worker imports from anywhere with CORS).
const shims = new Map();
const spawnModuleWorker = (url) => {
if (!shims.has(url.href)) shims.set(url.href, URL.createObjectURL(new Blob([`import ${JSON.stringify(url.href)};`], { type: "text/javascript" })));
return new Worker(shims.get(url.href), { type: "module" });
};
export function wasmAccess(timeoutMs, spawn = spawnModuleWorker) {
const cache = new Map();
return {
// ioBytes: the input and output cap for this call (the run's budget.ioBytes), at most 16 MiB.
async run({ module, entry, input, sha256, ioBytes }) {
const io = Math.min(ioBytes > 0 ? ioBytes : WASM_LIMITS.inputBytes, 16 << 20);
const limits = { ...WASM_LIMITS, inputBytes: io, outputBytes: io };
if (!(module instanceof Uint8Array)) throw wasmError("WASM_ABI_INVALID", "module must be a Uint8Array");
if (module.byteLength > WASM_LIMITS.moduleBytes) throw wasmError("WASM_TOO_LARGE", `module exceeds ${WASM_LIMITS.moduleBytes} bytes`);
if (typeof entry !== "string" || !ENTRY_PATTERN.test(entry)) throw wasmError("WASM_ABI_INVALID", "entry must be an export name");
const inputJson = JSON.stringify(input ?? null);
if (new TextEncoder().encode(inputJson).byteLength > io) throw wasmError("WASM_TOO_LARGE", `input exceeds ${io} bytes`);
const digest = await sha256Hex(module);
if (sha256 !== undefined && String(sha256).toLowerCase() !== digest) throw wasmError("WASM_DIGEST_MISMATCH", "module digest does not match sha256");
let compiled = cache.get(digest);
if (!compiled) {
const capped = capMemory(module, WASM_LIMITS.memoryPages);
try {
compiled = await WebAssembly.compile(capped);
} catch (error) {
throw wasmError("WASM_ABI_INVALID", error.message);
}
cache.set(digest, compiled);
}
if (WebAssembly.Module.imports(compiled).length > 0) throw wasmError("WASM_IMPORTS_NOT_ALLOWED", "modules must not declare imports");
const worker = spawn(new URL("./wasm-worker.js", import.meta.url));
try {
const reply = await new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(wasmError("WASM_TIMEOUT", `${entry} ran longer than ${timeoutMs} ms`)), timeoutMs);
worker.onmessage = ({ data }) => (clearTimeout(timer), resolve(data));
worker.onerror = (event) => (clearTimeout(timer), reject(wasmError("WASM_UNAVAILABLE", event.message ?? "worker failed")));
worker.postMessage({ compiled, entry, inputJson, limits });
});
if (reply.error === undefined) return reply.output;
const [, code, message] = /^(WASM_[A-Z_]+): ([\s\S]*)$/.exec(reply.error) ?? [, "WASM_TRAP", reply.error];
throw wasmError(code, message);
} finally {
worker.terminate();
}
},
};
}
/**
* @param {{ id: string, version?: string, storage?: Record<string, unknown>, settingsSchema?: Record<string, { type: string }>, siteUrl?: string, siteName?: string, fetch?: typeof fetch, dbName?: string, wasmTimeoutMs?: number }} options
* `storage` and `settingsSchema` take the `storage` and `admin.settingsSchema` blocks of emdash-plugin.jsonc;
* storage indexes are not needed here.
* @returns {import("emdash/plugin").PluginContext}
*/
export function createPluginContext({ id, version = "0.0.0", storage = {}, settingsSchema = {}, siteUrl = globalThis.location?.origin ?? "", siteName = "", fetch: fetchImpl, dbName = `emdash-plugin:${id}`, wasmTimeoutMs = WASM_LIMITS.wallTimeMs }) {
const database = openDatabase(dbName);
const log = Object.fromEntries(
["debug", "info", "warn", "error"].map((level) => [level, (message, data) => console[level](`[${id}] ${message}`, ...(data === undefined ? [] : [data]))]),
);
return {
plugin: { id, version },
storage: Object.fromEntries(Object.keys(storage).map((name) => [name, collection(database, `storage:${name}`)])),
settings: settingsAccess(collection(database, "settings"), settingsSchema, id, secretKey(database)),
kv: collection(database, "kv"),
http: { fetch: (url, init) => (fetchImpl ?? globalThis.fetch)(url, init) },
wasm: wasmAccess(wasmTimeoutMs),
log,
site: { name: siteName, url: siteUrl, locale: "en", trailingSlash: "ignore" },
url: (path) => new URL(path, siteUrl).href,
};
}