Suite of AT Protocol TypeScript libraries built on web standards
21
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: lex/json

+448 -10
+1 -1
lex/data/blob.ts
··· 5 5 export type BlobRef = { 6 6 $type: "blob"; 7 7 mimeType: string; 8 - ref: { toString(): string; equals(other: unknown): boolean }; 8 + ref: import("./cid.ts").Cid; 9 9 size: number; 10 10 }; 11 11
+6
lex/data/mod.ts
··· 1 + export * from "./blob.ts"; 2 + export * from "./cid.ts"; 3 + export * from "./lex.ts"; 4 + export * from "./object.ts"; 5 + export * from "./strings.ts"; 6 + export * from "./uint8array.ts";
+3 -1
lex/deno.json
··· 1 1 { 2 2 "name": "@atp/lex", 3 - "version": "0.1.0-alpha.3", 3 + "version": "0.1.0-alpha.4", 4 4 "exports": { 5 5 ".": "./mod.ts", 6 6 "./cbor": "./cbor/mod.ts", 7 + "./data": "./data/mod.ts", 8 + "./json": "./json/mod.ts", 7 9 "./document": "./document/mod.ts", 8 10 "./build": "./build/mod.ts", 9 11 "./installer": "./installer/mod.ts",
+35
lex/json/blob.ts
··· 1 + import type { BlobRef, LexMap } from "../data/mod.ts"; 2 + import { isBlobRef } from "../data/mod.ts"; 3 + import { parseLexLink } from "./link.ts"; 4 + 5 + export function parseTypedBlobRef( 6 + input: LexMap, 7 + options?: { strict?: boolean }, 8 + ): BlobRef | undefined { 9 + if (input.$type !== "blob") { 10 + return undefined; 11 + } 12 + 13 + const ref = input.ref; 14 + if (!ref || typeof ref !== "object") { 15 + return undefined; 16 + } 17 + 18 + if ("$link" in ref) { 19 + const cid = parseLexLink(ref); 20 + if (!cid) { 21 + return undefined; 22 + } 23 + 24 + const blob = { ...input, ref: cid }; 25 + if (isBlobRef(blob, options)) { 26 + return blob; 27 + } 28 + } 29 + 30 + if (isBlobRef(input, options)) { 31 + return input; 32 + } 33 + 34 + return undefined; 35 + }
+30
lex/json/bytes.ts
··· 1 + import { fromString, toString } from "@atp/bytes"; 2 + import type { JsonValue } from "./json.ts"; 3 + 4 + export function parseLexBytes( 5 + input?: Record<string, unknown>, 6 + ): Uint8Array | undefined { 7 + if (!input || !("$bytes" in input)) { 8 + return undefined; 9 + } 10 + 11 + for (const key in input) { 12 + if (key !== "$bytes") { 13 + return undefined; 14 + } 15 + } 16 + 17 + if (typeof input.$bytes !== "string") { 18 + return undefined; 19 + } 20 + 21 + try { 22 + return fromString(input.$bytes, "base64"); 23 + } catch { 24 + return undefined; 25 + } 26 + } 27 + 28 + export function encodeLexBytes(bytes: Uint8Array): JsonValue { 29 + return { $bytes: toString(bytes, "base64") }; 30 + }
+8
lex/json/json.ts
··· 1 + export type JsonScalar = number | string | boolean | null; 2 + 3 + export type JsonValue = 4 + | JsonScalar 5 + | JsonValue[] 6 + | { [_ in string]?: JsonValue }; 7 + 8 + export type JsonObject = { [_ in string]?: JsonValue };
+201
lex/json/lex-json.ts
··· 1 + import { 2 + type BlobRef, 3 + type Cid, 4 + isCid, 5 + type LexArray, 6 + type LexMap, 7 + type LexValue, 8 + } from "../data/mod.ts"; 9 + import { parseTypedBlobRef } from "./blob.ts"; 10 + import { encodeLexBytes, parseLexBytes } from "./bytes.ts"; 11 + import type { JsonObject, JsonValue } from "./json.ts"; 12 + import { encodeLexLink, parseLexLink } from "./link.ts"; 13 + 14 + const TEXT_DECODER = new TextDecoder("utf-8", { fatal: true }); 15 + 16 + export type LexParseOptions = { 17 + strict?: boolean; 18 + }; 19 + 20 + export function lexStringify(input: LexValue): string { 21 + return JSON.stringify(lexToJson(input)); 22 + } 23 + 24 + export function lexParse<T extends LexValue = LexValue>( 25 + input: string, 26 + options: LexParseOptions = { strict: false }, 27 + ): T { 28 + return jsonToLex(JSON.parse(input), options) as T; 29 + } 30 + 31 + export function lexParseJsonBytes( 32 + bytes: Uint8Array, 33 + options?: LexParseOptions, 34 + ): LexValue { 35 + return lexParse(TEXT_DECODER.decode(bytes), options); 36 + } 37 + 38 + export function jsonToLex( 39 + value: JsonValue, 40 + options: LexParseOptions = { strict: false }, 41 + ): LexValue { 42 + switch (typeof value) { 43 + case "object": { 44 + if (value === null) return null; 45 + if (Array.isArray(value)) return jsonArrayToLex(value, options); 46 + return parseSpecialJsonObject(value, options) ?? 47 + jsonObjectToLexMap(value, options); 48 + } 49 + case "number": 50 + if (Number.isSafeInteger(value)) return value; 51 + if (options.strict === false) return value; 52 + throw new TypeError(`Invalid non-integer number: ${value}`); 53 + case "boolean": 54 + case "string": 55 + return value; 56 + default: 57 + throw new TypeError(`Invalid JSON value: ${typeof value}`); 58 + } 59 + } 60 + 61 + function jsonArrayToLex( 62 + input: JsonValue[], 63 + options: LexParseOptions, 64 + ): LexValue[] { 65 + let copy: LexValue[] | undefined; 66 + 67 + for (let i = 0; i < input.length; i++) { 68 + const inputItem = input[i]; 69 + const item = jsonToLex(inputItem, options); 70 + if (item !== inputItem) { 71 + copy ??= Array.from(input); 72 + copy[i] = item; 73 + } 74 + } 75 + 76 + return copy ?? input; 77 + } 78 + 79 + function jsonObjectToLexMap( 80 + input: JsonObject, 81 + options: LexParseOptions, 82 + ): LexMap { 83 + let copy: LexMap | undefined; 84 + 85 + for (const [key, jsonValue] of Object.entries(input)) { 86 + if (key === "__proto__") { 87 + throw new TypeError("Invalid key: __proto__"); 88 + } 89 + 90 + if (jsonValue === undefined) { 91 + copy ??= { ...input }; 92 + delete copy[key]; 93 + continue; 94 + } 95 + 96 + const value = jsonToLex(jsonValue, options); 97 + if (value !== jsonValue) { 98 + copy ??= { ...input }; 99 + copy[key] = value; 100 + } 101 + } 102 + 103 + return copy ?? input; 104 + } 105 + 106 + export function lexToJson(value: LexValue): JsonValue { 107 + switch (typeof value) { 108 + case "object": 109 + if (value === null) { 110 + return value; 111 + } else if (Array.isArray(value)) { 112 + return lexArrayToJson(value); 113 + } else if (isCid(value)) { 114 + return encodeLexLink(value); 115 + } else if (ArrayBuffer.isView(value)) { 116 + return encodeLexBytes( 117 + new Uint8Array( 118 + value.buffer, 119 + value.byteOffset, 120 + value.byteLength, 121 + ), 122 + ); 123 + } else { 124 + return encodeLexMap(value); 125 + } 126 + case "boolean": 127 + case "string": 128 + case "number": 129 + return value; 130 + default: 131 + throw new TypeError(`Invalid Lex value: ${typeof value}`); 132 + } 133 + } 134 + 135 + function lexArrayToJson(input: LexArray): JsonValue[] { 136 + let copy: JsonValue[] | undefined; 137 + 138 + for (let i = 0; i < input.length; i++) { 139 + const inputItem = input[i]; 140 + const item = lexToJson(inputItem); 141 + if (item !== inputItem) { 142 + copy ??= Array.from(input) as JsonValue[]; 143 + copy[i] = item; 144 + } 145 + } 146 + 147 + return copy ?? (input as JsonValue[]); 148 + } 149 + 150 + function encodeLexMap(input: LexMap): JsonObject { 151 + let copy: JsonObject | undefined; 152 + 153 + for (const [key, lexValue] of Object.entries(input)) { 154 + if (key === "__proto__") { 155 + throw new TypeError("Invalid key: __proto__"); 156 + } 157 + 158 + if (lexValue === undefined) { 159 + copy ??= { ...input } as JsonObject; 160 + delete copy[key]; 161 + continue; 162 + } 163 + 164 + const jsonValue = lexToJson(lexValue); 165 + if (jsonValue !== lexValue) { 166 + copy ??= { ...input } as JsonObject; 167 + copy[key] = jsonValue; 168 + } 169 + } 170 + 171 + return copy ?? (input as JsonObject); 172 + } 173 + 174 + export function parseSpecialJsonObject( 175 + input: LexMap, 176 + options: LexParseOptions, 177 + ): Cid | Uint8Array | BlobRef | undefined { 178 + if (input.$link !== undefined) { 179 + const cid = parseLexLink(input); 180 + if (cid) return cid; 181 + if (options.strict) throw new TypeError("Invalid $link object"); 182 + } else if (input.$bytes !== undefined) { 183 + const bytes = parseLexBytes(input); 184 + if (bytes) return bytes; 185 + if (options.strict) throw new TypeError("Invalid $bytes object"); 186 + } else if (input.$type !== undefined) { 187 + if (options.strict) { 188 + if (input.$type === "blob") { 189 + const blob = parseTypedBlobRef(input, options); 190 + if (blob) return blob; 191 + throw new TypeError("Invalid blob object"); 192 + } else if (typeof input.$type !== "string") { 193 + throw new TypeError(`Invalid $type property (${typeof input.$type})`); 194 + } else if (input.$type.length === 0) { 195 + throw new TypeError("Empty $type property"); 196 + } 197 + } 198 + } 199 + 200 + return undefined; 201 + }
+36
lex/json/link.ts
··· 1 + import { type Cid, parseCid } from "../data/cid.ts"; 2 + import type { JsonValue } from "./json.ts"; 3 + 4 + export function parseLexLink( 5 + input?: Record<string, unknown>, 6 + ): Cid | undefined { 7 + if (!input || !("$link" in input)) { 8 + return undefined; 9 + } 10 + 11 + for (const key in input) { 12 + if (key !== "$link") { 13 + return undefined; 14 + } 15 + } 16 + 17 + const { $link } = input; 18 + 19 + if (typeof $link !== "string") { 20 + return undefined; 21 + } 22 + 23 + if ($link.length === 0 || $link.length > 2048) { 24 + return undefined; 25 + } 26 + 27 + try { 28 + return parseCid($link); 29 + } catch { 30 + return undefined; 31 + } 32 + } 33 + 34 + export function encodeLexLink(cid: Cid): JsonValue { 35 + return { $link: cid.toString() }; 36 + }
+4
lex/json/mod.ts
··· 1 + export * from "./bytes.ts"; 2 + export * from "./json.ts"; 3 + export * from "./lex-json.ts"; 4 + export * from "./link.ts";
+1
lex/mod.ts
··· 2 2 3 3 export { l }; 4 4 export * from "./external.ts"; 5 + export * from "./json/mod.ts"; 5 6 6 7 if (import.meta.main) { 7 8 const { default: command } = await import("./cli.ts");
+6 -6
lex/resolver/lex-resolver.ts
··· 1 - import type { CID } from "multiformats/cid"; 1 + import type { Cid } from "../data/mod.ts"; 2 2 import { resolveTxt as resolveTxtWithNode } from "node:dns/promises"; 3 3 import { type AtprotoData, type DidCache, DidResolver } from "@atp/identity"; 4 4 import { ··· 38 38 39 39 export type LexResolverResult = { 40 40 uri: AtUri; 41 - cid: CID; 41 + cid: Cid; 42 42 lexicon: LexiconDocument; 43 43 }; 44 44 45 45 export type LexResolverFetchResult = { 46 - cid: CID; 46 + cid: Cid; 47 47 lexicon: LexiconDocument; 48 48 }; 49 49 ··· 58 58 onFetch?(data: { uri: AtUri }): MaybePromise<LexResolverFetchResult | void>; 59 59 onFetchResult?(data: { 60 60 uri: AtUri; 61 - cid: CID; 61 + cid: Cid; 62 62 lexicon: LexiconDocument; 63 63 }): MaybePromise<void>; 64 64 onFetchError?(data: { uri: AtUri; err: unknown }): MaybePromise<void>; ··· 100 100 }; 101 101 102 102 export { AtUri, NSID }; 103 - export type { CID, LexiconDocument }; 103 + export type { Cid as CID, LexiconDocument }; 104 104 105 105 export class LexResolver { 106 106 protected readonly didResolver: LexResolverDidResolver; ··· 429 429 throw new Error(`Invalid signature on commit: ${root.toString()}`); 430 430 } 431 431 432 - const mst = MST.load(blockstore, (commit as { data: CID }).data); 432 + const mst = MST.load(blockstore, (commit as { data: Cid }).data); 433 433 const cid = await mst.get(`${collection}/${rkey}`); 434 434 if (!cid) { 435 435 throw new Error("Record not found in proof");
+109
lex/tests/json_test.ts
··· 1 + import { equals } from "@atp/bytes"; 2 + import { parseCid } from "@atp/lex/data"; 3 + import { 4 + encodeLexBytes, 5 + encodeLexLink, 6 + jsonToLex, 7 + lexParse, 8 + lexParseJsonBytes, 9 + lexStringify, 10 + lexToJson, 11 + parseLexBytes, 12 + parseLexLink, 13 + } from "@atp/lex/json"; 14 + import { assert, assertEquals, assertRejects } from "@std/assert"; 15 + 16 + Deno.test("parses and encodes lex bytes", () => { 17 + const bytes = new TextEncoder().encode("hello"); 18 + 19 + assertEquals(parseLexBytes({ $bytes: "aGVsbG8" }), bytes); 20 + assertEquals(encodeLexBytes(bytes), { $bytes: "aGVsbG8" }); 21 + }); 22 + 23 + Deno.test("parses and encodes lex links", () => { 24 + const cid = parseCid( 25 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 26 + ); 27 + 28 + assertEquals( 29 + parseLexLink({ $link: cid.toString() })?.toString(), 30 + cid.toString(), 31 + ); 32 + assertEquals(encodeLexLink(cid), { $link: cid.toString() }); 33 + }); 34 + 35 + Deno.test("round trips lex values through json helpers", () => { 36 + const cid = parseCid( 37 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 38 + ); 39 + const blobCid = parseCid( 40 + "bafkreig77vqcdozl2wyk6z3cscaj5q5fggi53aoh64fewkdiri3cdauyn4", 41 + ); 42 + const bytes = new Uint8Array([1, 2, 3]); 43 + const value = { 44 + cid, 45 + bytes, 46 + blob: { 47 + $type: "blob" as const, 48 + ref: blobCid, 49 + mimeType: "image/png", 50 + size: 3, 51 + }, 52 + }; 53 + 54 + const json = lexToJson(value); 55 + const parsed = jsonToLex(json, { strict: true }) as typeof value; 56 + 57 + assertEquals((json as { cid: { $link: string } }).cid.$link, cid.toString()); 58 + assertEquals(parsed.cid.toString(), cid.toString()); 59 + assert(equals(parsed.bytes, bytes)); 60 + assertEquals(parsed.blob.ref.toString(), blobCid.toString()); 61 + }); 62 + 63 + Deno.test("exports lexParse and lexStringify from the root package", () => { 64 + const cid = parseCid( 65 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 66 + ); 67 + const json = lexStringify({ ref: cid }); 68 + const parsed = lexParse<{ ref: typeof cid }>(json); 69 + 70 + assertEquals(json, `{"ref":{"$link":"${cid.toString()}"}}`); 71 + assertEquals(parsed.ref.toString(), cid.toString()); 72 + }); 73 + 74 + Deno.test("parses json bytes into lex values", () => { 75 + const cid = parseCid( 76 + "bafyreidfayvfuwqa7qlnopdjiqrxzs6blmoeu4rujcjtnci5beludirz2a", 77 + ); 78 + const input = 79 + `{"ref":{"$link":"${cid.toString()}"},"bytes":{"$bytes":"AQID"}}`; 80 + const parsed = lexParseJsonBytes( 81 + new TextEncoder().encode(input), 82 + ) as unknown as { 83 + ref: { toString(): string }; 84 + bytes: Uint8Array; 85 + }; 86 + 87 + assertEquals(parsed.ref.toString(), cid.toString()); 88 + assert(equals(parsed.bytes, new Uint8Array([1, 2, 3]))); 89 + }); 90 + 91 + Deno.test("keeps invalid special objects as plain objects in non-strict mode", () => { 92 + const parsed = lexParse<{ $link: string }>('{"$link":"not-a-cid"}', { 93 + strict: false, 94 + }); 95 + 96 + assertEquals(parsed, { $link: "not-a-cid" }); 97 + }); 98 + 99 + Deno.test("rejects invalid special objects in strict mode", async () => { 100 + await assertRejects( 101 + async () => { 102 + await Promise.resolve( 103 + lexParse('{"$link":"not-a-cid"}', { strict: true }), 104 + ); 105 + }, 106 + TypeError, 107 + "Invalid $link object", 108 + ); 109 + });
+8 -2
lex/tests/lex-resolver_test.ts
··· 1 1 import { cidForCbor, streamToBuffer } from "@atp/common"; 2 2 import * as crypto from "@atp/crypto"; 3 - import { getRecords, MemoryBlockstore, Repo, WriteOpAction } from "@atp/repo"; 3 + import { 4 + getRecords, 5 + MemoryBlockstore, 6 + Repo, 7 + type RepoInputRecord, 8 + WriteOpAction, 9 + } from "@atp/repo"; 4 10 import { AtUri } from "@atp/syntax"; 5 11 import { assertEquals, assertInstanceOf, assertRejects } from "@std/assert"; 6 12 import { LexResolver, LexResolverError } from "../resolver/mod.ts"; ··· 26 32 27 33 const createLexiconRecord = ( 28 34 id: string, 29 - ): Record<string, unknown> => ({ 35 + ): RepoInputRecord => ({ 30 36 $type: collection, 31 37 lexicon: 1, 32 38 id,