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

johnandersen777.bsky.social/atlibs

0
0
main
346 lines · 16.0 KB Raw History
// 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,
	};
}