social components inlay.at
atproto components sdui
86
fork

Configure Feed

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

hydrate blobs and such

+216 -4
+28 -4
packages/@inlay/render/src/validate.ts
··· 1 - import { Lexicons, type LexiconDoc } from "@atproto/lexicon"; 1 + import { Lexicons, type LexiconDoc, jsonToLex } from "@atproto/lexicon"; 2 2 import { AtUri } from "@atproto/syntax"; 3 + import { walkTree, isValidElement } from "@inlay/core"; 3 4 import type { Main as ComponentRecord } from "../../../../generated/at/inlay/component.defs.js"; 4 5 import { 5 6 viewRecord as viewRecordSchema, ··· 11 12 12 13 const lexiconCache = new Map<string, Lexicons>(); 13 14 15 + // --- Hydration --- 16 + 17 + /** 18 + * Hydrate JSON blob refs, CID links, and byte arrays into their 19 + * class instances (BlobRef, CID, Uint8Array) so that @atproto/lexicon 20 + * validation accepts them. Elements are opaque and skipped. 21 + */ 22 + function hydrateProps(props: Record<string, unknown>): Record<string, unknown> { 23 + return walkTree(props, (obj, walk) => { 24 + if (isValidElement(obj)) return obj; 25 + if ( 26 + obj["$type"] === "blob" || 27 + obj["$link"] !== undefined || 28 + obj["$bytes"] !== undefined 29 + ) { 30 + return jsonToLex(obj); 31 + } 32 + const out: Record<string, unknown> = {}; 33 + for (const [k, v] of Object.entries(obj)) out[k] = walk(v); 34 + return out; 35 + }); 36 + } 37 + 14 38 // --- Public API --- 15 39 16 40 export async function validateProps( ··· 19 43 component: ComponentRecord, 20 44 resolver: Resolver 21 45 ): Promise<Record<string, unknown>> { 22 - let result: Record<string, unknown> = props; 46 + let result: Record<string, unknown> = hydrateProps(props); 23 47 24 48 const lex = await resolver.resolveLexicon(type); 25 49 if (lex) { ··· 28 52 lexicons = await buildLexicons(lex as Record<string, unknown>, resolver); 29 53 lexiconCache.set(type, lexicons); 30 54 } 31 - result = lexicons.assertValidXrpcInput(type, props) as Record< 55 + result = lexicons.assertValidXrpcInput(type, result) as Record< 32 56 string, 33 57 unknown 34 58 >; ··· 91 115 }, 92 116 } as unknown as LexiconDoc; 93 117 const lexicons = new Lexicons([syntheticLex]); 94 - result = lexicons.assertValidXrpcInput(type, props) as Record< 118 + result = lexicons.assertValidXrpcInput(type, result) as Record< 95 119 string, 96 120 unknown 97 121 >;
+188
packages/@inlay/render/test/render.test.ts
··· 2870 2870 ` at ${Text}\n at ${Page}` 2871 2871 ); 2872 2872 }); 2873 + 2874 + it("hydrates JSON blob refs into BlobRef instances for validation", async () => { 2875 + const { BlobRef } = await import("@atproto/lexicon"); 2876 + 2877 + // A template that binds avatar src from the record's blob field 2878 + const profileComponent: ComponentRecord = { 2879 + $type: "at.inlay.component", 2880 + body: { 2881 + $type: "at.inlay.component#bodyTemplate", 2882 + node: serializeTree( 2883 + $(Avatar, { 2884 + src: $(Binding, { path: ["record", "avatar"] }), 2885 + did: $(Binding, { path: ["props", "uri", "$did"] }), 2886 + }) 2887 + ), 2888 + }, 2889 + imports: [HOST_DID], 2890 + view: { 2891 + $type: "at.inlay.component#view", 2892 + prop: "uri", 2893 + accepts: [ 2894 + { 2895 + $type: "at.inlay.component#viewRecord", 2896 + collection: "app.bsky.actor.profile", 2897 + rkey: "self", 2898 + }, 2899 + ], 2900 + }, 2901 + }; 2902 + 2903 + // Record with a plain JSON blob (as it comes from a PDS) 2904 + const profileRecord = { 2905 + displayName: "Alice", 2906 + avatar: { 2907 + $type: "blob", 2908 + ref: { 2909 + $link: "bafkreiai3mohqwakra23zdvgihyvyizjdfnf2plev7er5qbzkttgqm5nsy", 2910 + }, 2911 + mimeType: "image/jpeg", 2912 + size: 213686, 2913 + }, 2914 + }; 2915 + 2916 + let capturedSrc: unknown; 2917 + const { resolver } = world({ 2918 + [`at://${APP_DID}/at.inlay.component/${View}`]: profileComponent, 2919 + ["at://did:plc:alice/app.bsky.actor.profile/self"]: profileRecord, 2920 + }); 2921 + 2922 + resolver.resolveLexicon = async (nsid: string) => { 2923 + if (nsid === Avatar) { 2924 + return { 2925 + lexicon: 1, 2926 + id: Avatar, 2927 + defs: { 2928 + main: { 2929 + type: "procedure", 2930 + input: { 2931 + encoding: "application/json", 2932 + schema: { 2933 + type: "object", 2934 + required: ["did"], 2935 + properties: { 2936 + src: { type: "blob" }, 2937 + did: { type: "string", format: "did" }, 2938 + }, 2939 + }, 2940 + }, 2941 + }, 2942 + }, 2943 + }; 2944 + } 2945 + return null; 2946 + }; 2947 + 2948 + const result = await renderToCompletion( 2949 + $(View, { uri: "at://did:plc:alice/app.bsky.actor.profile/self" }), 2950 + { resolver }, 2951 + createContext( 2952 + profileComponent, 2953 + `at://${APP_DID}/at.inlay.component/${View}` 2954 + ), 2955 + { 2956 + [Avatar]: async (el) => { 2957 + const props = el.props as Record<string, unknown>; 2958 + capturedSrc = props.src; 2959 + return h("img", { src: String((capturedSrc as any)?.ref) }); 2960 + }, 2961 + } 2962 + ); 2963 + 2964 + assert.ok( 2965 + capturedSrc instanceof BlobRef, 2966 + "src should be a BlobRef instance" 2967 + ); 2968 + assert.deepEqual( 2969 + result, 2970 + h("img", { 2971 + src: "bafkreiai3mohqwakra23zdvgihyvyizjdfnf2plev7er5qbzkttgqm5nsy", 2972 + }) 2973 + ); 2974 + }); 2975 + 2976 + it("hydrates blobs in xrpc response props", async () => { 2977 + const { BlobRef } = await import("@atproto/lexicon"); 2978 + 2979 + const externalComponent: ComponentRecord = { 2980 + $type: "at.inlay.component", 2981 + body: { 2982 + $type: "at.inlay.component#bodyExternal", 2983 + did: SERVICE_DID, 2984 + }, 2985 + imports: [HOST_DID], 2986 + }; 2987 + 2988 + let capturedSrc: unknown; 2989 + const { resolver } = world({ 2990 + [`at://${APP_DID}/at.inlay.component/${External}`]: externalComponent, 2991 + [`xrpc:${SERVICE_DID}:${External}`]: () => ({ 2992 + node: $(Avatar, { 2993 + src: { 2994 + $type: "blob", 2995 + ref: { 2996 + $link: 2997 + "bafkreiai3mohqwakra23zdvgihyvyizjdfnf2plev7er5qbzkttgqm5nsy", 2998 + }, 2999 + mimeType: "image/jpeg", 3000 + size: 100, 3001 + }, 3002 + did: "did:plc:alice", 3003 + }), 3004 + cache: { life: "hours", tags: [] }, 3005 + }), 3006 + }); 3007 + 3008 + resolver.resolveLexicon = async (nsid: string) => { 3009 + if (nsid === Avatar) { 3010 + return { 3011 + lexicon: 1, 3012 + id: Avatar, 3013 + defs: { 3014 + main: { 3015 + type: "procedure", 3016 + input: { 3017 + encoding: "application/json", 3018 + schema: { 3019 + type: "object", 3020 + required: ["did"], 3021 + properties: { 3022 + src: { type: "blob" }, 3023 + did: { type: "string", format: "did" }, 3024 + }, 3025 + }, 3026 + }, 3027 + }, 3028 + }, 3029 + }; 3030 + } 3031 + return null; 3032 + }; 3033 + 3034 + const result = await renderToCompletion( 3035 + $(External, {}), 3036 + { resolver }, 3037 + createContext( 3038 + externalComponent, 3039 + `at://${APP_DID}/at.inlay.component/${External}` 3040 + ), 3041 + { 3042 + [Avatar]: async (el) => { 3043 + const props = el.props as Record<string, unknown>; 3044 + capturedSrc = props.src; 3045 + return h("img", { src: String((capturedSrc as any)?.ref) }); 3046 + }, 3047 + } 3048 + ); 3049 + 3050 + assert.ok( 3051 + capturedSrc instanceof BlobRef, 3052 + "src should be a BlobRef instance" 3053 + ); 3054 + assert.deepEqual( 3055 + result, 3056 + h("img", { 3057 + src: "bafkreiai3mohqwakra23zdvgihyvyizjdfnf2plev7er5qbzkttgqm5nsy", 3058 + }) 3059 + ); 3060 + }); 2873 3061 }); 2874 3062 2875 3063 // ============================================================================