···11-# flex
11+# tinylex
2233-flex is a tiny vanilla JavaScript library for validating [atproto lexicons](https://atproto.com/guides/lexicon). Given a set of lexicon schemata — the JSON documents that describe atproto record types — flex extracts static types and validates record data against them. It conforms to [Standard Schema](https://standardschema.dev/schema), so you can use it anywhere you'd use schema validators like Zod/Valibot/etc.
33+tinylex is a tiny vanilla JavaScript library for validating [atproto lexicons](https://atproto.com/guides/lexicon). Given a set of lexicon schemata — the JSON documents that describe atproto record types — tinylex extracts static types and validates record data against them. It conforms to [Standard Schema](https://standardschema.dev/schema), so you can use it anywhere you'd use schema validators like Zod/Valibot/etc.
4455## Installation
6677-flex is meant to be [vendored](https://htmx.org/essays/vendoring/); simply copy and paste `flex.js` and `flex-install` into your project!
77+tinylex is meant to be [vendored](https://htmx.org/essays/vendoring/); simply copy and paste `tinylex.js` and `tinylex-install` into your project!
8899## Usage
10101111A `LexiconCatalog` holds your lexicon documents and resolves cross-references between them. You can handwrite or copy/paste lexicons inline or [fetch them by NSID](#fetching-lexicons) — either way, add them to the catalog, then call `schema(nsid)` to get a Standard Schema validator:
12121313```js
1414-import { LexiconCatalog } from "./flex.js";
1414+import { LexiconCatalog } from "./tinylex.js";
15151616const lexicons = new LexiconCatalog().add({
1717 lexicon: 1,
···45454646## Fetching lexicons
47474848-`flex-install` resolves lexicons by NSID over DNS (per the [lexicon resolution spec](https://atproto.com/specs/lexicon#lexicon-resolution)) and writes them to disk:
4848+`tinylex-install` resolves lexicons by NSID over DNS (per the [lexicon resolution spec](https://atproto.com/specs/lexicon#lexicon-resolution)) and writes them to disk:
49495050```sh
5151-./flex-install app.bsky.feed.post app.bsky.feed.like
5252-./flex-install app.bsky.actor.profile --out ./schemata
5353-./flex-install app.bsky.feed.post --js
5151+./tinylex-install app.bsky.feed.post app.bsky.feed.like
5252+./tinylex-install app.bsky.actor.profile --out ./schemata
5353+./tinylex-install app.bsky.feed.post --js
5454```
55555656Output defaults to `./lexicons/<nsid>.json`. Pass `--js` to emit `.js` files wrapped in `export default /** @type {const} */ ({...})` so imports keep literal types (otherwise TypeScript widens the JSON and `LexiconCatalog.add()` can't infer narrow types for `result.value`).
···61616262The [official guide to installing lexicons](https://atproto.com/guides/installing-lexicons) uses the [`@atproto/lex`](https://npmx.dev/package/@atproto/lex) package, which has 79 transitive dependencies and a requires a build step to actually use the lexicons in your code. [`@atcute/lex-cli`](https://npmx.dev/package/@atcute/lex-cli) is lighter weight, but requires a config file _and_ a build step.
63636464-flex is ~650 lines of vanilla JavaScript with no dependencies. It lets you use the lexicon schema JSON as-is for validation and static typing. For convenience, it also includes a ~100 line install script that works with any JavaScript runtime to fetch lexicon documents based on their NSIDs.
6464+tinylex is ~650 lines of vanilla JavaScript with no dependencies. It lets you use the lexicon schema JSON as-is for validation and static typing. For convenience, it also includes a ~100 line install script that works with any JavaScript runtime to fetch lexicon documents based on their NSIDs.
-114
flex-install
···11-#!/bin/sh
22-":" //# ; if command -v bun >/dev/null 2>&1; then exec bun "$0" "$@"; elif command -v deno >/dev/null 2>&1; then exec deno run -A "$0" "$@"; else exec node "$0" "$@"; fi
33-44-// This Source Code Form is subject to the terms of the Mozilla Public
55-// License, v. 2.0. If a copy of the MPL was not distributed with this
66-// file, You can obtain one at https://mozilla.org/MPL/2.0/.
77-//
88-// Copyright (c) 2026 Jake Lazaroff https://tangled.org/jakelazaroff.com/flex
99-1010-import { writeFile, mkdir } from "node:fs/promises";
1111-import { dirname, join } from "node:path";
1212-1313-async function resolveTxt(host) {
1414- const r = await fetch(
1515- `https://cloudflare-dns.com/dns-query?name=${encodeURIComponent(host)}&type=TXT`,
1616- { headers: { accept: "application/dns-json" } }
1717- );
1818- if (!r.ok) throw new Error(`doh ${r.status}`);
1919- const { Answer = [] } = await r.json();
2020- return Answer.filter(a => a.type === 16).map(a => {
2121- const chunks = [];
2222- const re = /"((?:[^"\\]|\\.)*)"/g;
2323- let m;
2424- while ((m = re.exec(a.data))) chunks.push(m[1].replace(/\\(.)/g, "$1"));
2525- return chunks;
2626- });
2727-}
2828-2929-const argv = typeof Deno !== "undefined" ? Deno.args : process.argv.slice(2);
3030-const nsids = [];
3131-let outDir = "lexicons";
3232-let asJs = false;
3333-3434-for (let i = 0; i < argv.length; i++) {
3535- const a = argv[i];
3636- if (a === "--out" || a === "-o") outDir = argv[++i];
3737- else if (a.startsWith("--out=")) outDir = a.slice(6);
3838- else if (a === "--js") asJs = true;
3939- else nsids.push(a);
4040-}
4141-if (!nsids.length) {
4242- console.error("usage: ./install <nsid>... [--out <dir>] [--js]");
4343- process.exit(1);
4444-}
4545-4646-function authorityDomain(nsid) {
4747- const segs = nsid.split(".");
4848- if (segs.length < 3 || segs.some(s => !/^[a-z][a-z0-9-]*$/i.test(s))) {
4949- throw new Error(`invalid nsid: ${nsid}`);
5050- }
5151- return segs.slice(0, -1).reverse().join(".");
5252-}
5353-5454-async function resolveDid(nsid) {
5555- const host = `_lexicon.${authorityDomain(nsid)}`;
5656- const records = await resolveTxt(host);
5757- for (const chunks of records) {
5858- const m = chunks.join("").match(/^did=(.+)$/);
5959- if (m) return m[1];
6060- }
6161- throw new Error(`no did= TXT at ${host}`);
6262-}
6363-6464-async function resolvePds(did) {
6565- let doc;
6666- if (did.startsWith("did:plc:")) {
6767- const r = await fetch(`https://plc.directory/${did}`);
6868- if (!r.ok) throw new Error(`plc ${r.status}`);
6969- doc = await r.json();
7070- } else if (did.startsWith("did:web:")) {
7171- const host = did.slice(8).replace(/:/g, "/");
7272- const r = await fetch(`https://${host}/.well-known/did.json`);
7373- if (!r.ok) throw new Error(`did:web ${r.status}`);
7474- doc = await r.json();
7575- } else {
7676- throw new Error(`unsupported did method: ${did}`);
7777- }
7878- const svc = (doc.service || []).find(
7979- s => s.id === "#atproto_pds" || s.type === "AtprotoPersonalDataServer"
8080- );
8181- if (!svc) throw new Error("no atproto_pds service in did doc");
8282- return svc.serviceEndpoint.replace(/\/$/, "");
8383-}
8484-8585-async function fetchLexicon(nsid) {
8686- const did = await resolveDid(nsid);
8787- const pds = await resolvePds(did);
8888- const url = `${pds}/xrpc/com.atproto.repo.getRecord`
8989- + `?repo=${encodeURIComponent(did)}`
9090- + `&collection=com.atproto.lexicon.schema`
9191- + `&rkey=${encodeURIComponent(nsid)}`;
9292- const r = await fetch(url);
9393- if (!r.ok) throw new Error(`getRecord ${r.status}`);
9494- const { value } = await r.json();
9595- return value;
9696-}
9797-9898-let failed = 0;
9999-for (const nsid of nsids) {
100100- try {
101101- const lex = await fetchLexicon(nsid);
102102- const out = join(outDir, `${nsid}.${asJs ? "js" : "json"}`);
103103- const body = asJs
104104- ? `export default /** @type {const} */ (${JSON.stringify(lex, null, 2)});\n`
105105- : JSON.stringify(lex, null, 2) + "\n";
106106- await mkdir(dirname(out), { recursive: true });
107107- await writeFile(out, body);
108108- console.log(`ok ${nsid} -> ${out}`);
109109- } catch (e) {
110110- console.error(`fail ${nsid} (${e.message})`);
111111- failed++;
112112- }
113113-}
114114-process.exit(failed ? 1 : 0);