handy online tools for AT Protocol boat.kelinci.net
atproto bluesky atcute typescript solidjs

feat: atproto car explorer

mary.my.id ff38bcbc 2c0e8596

verified
+554 -3
+2
package.json
··· 10 10 "@atcute/bluesky": "^1.0.14", 11 11 "@atcute/car": "^3.0.0", 12 12 "@atcute/cbor": "^2.2.0", 13 + "@atcute/cid": "^2.2.0", 13 14 "@atcute/client": "^2.0.8", 14 15 "@atcute/crypto": "^2.2.0", 15 16 "@atcute/did-plc": "^0.1.1", 16 17 "@atcute/identity": "^0.1.1", 17 18 "@atcute/identity-resolver": "^0.1.2", 18 19 "@atcute/multibase": "^1.1.2", 20 + "@atcute/tid": "^1.0.2", 19 21 "@badrap/valita": "^0.4.3", 20 22 "@mary/array-fns": "npm:@jsr/mary__array-fns@^0.1.4", 21 23 "@mary/events": "npm:@jsr/mary__events@^0.1.0",
+11
pnpm-lock.yaml
··· 17 17 '@atcute/cbor': 18 18 specifier: ^2.2.0 19 19 version: 2.2.0 20 + '@atcute/cid': 21 + specifier: ^2.2.0 22 + version: 2.2.0 20 23 '@atcute/client': 21 24 specifier: ^2.0.8 22 25 version: 2.0.8 ··· 35 38 '@atcute/multibase': 36 39 specifier: ^1.1.2 37 40 version: 1.1.2 41 + '@atcute/tid': 42 + specifier: ^1.0.2 43 + version: 1.0.2 38 44 '@badrap/valita': 39 45 specifier: ^0.4.3 40 46 version: 0.4.3 ··· 137 143 138 144 '@atcute/multibase@1.1.2': 139 145 resolution: {integrity: sha512-KFX+c7a/u2jSNcRw0rLaUHG/XEKf1A1c8XF5soHnsb1JMCShihf/anfZ1kJ4no/IlIp9HEHV3PQRQO2sWL6ASQ==} 146 + 147 + '@atcute/tid@1.0.2': 148 + resolution: {integrity: sha512-ahmjroNyeDPJhtuf3+HTJropaH04HmJ8fhntDu73Gpz/RkAF7+nkz6kcP2QTgfvMCgMPAJUdskAAP82GPDTY9w==} 140 149 141 150 '@atcute/uint8array@1.0.1': 142 151 resolution: {integrity: sha512-AAnlFKyfDRgb9GNZJbhQ6OuMhbmNPirQyapb8KnmcEhxQZ3+tt+4NcwqekEegY4MpNqSTYeeTdyxq0wGZv1JHg==} ··· 1774 1783 '@atcute/multibase@1.1.2': 1775 1784 dependencies: 1776 1785 '@atcute/uint8array': 1.0.1 1786 + 1787 + '@atcute/tid@1.0.2': {} 1777 1788 1778 1789 '@atcute/uint8array@1.0.1': {} 1779 1790
+9
src/components/ic-icons/baseline-chevron-right.tsx
··· 1 + import { createIcon } from './_icon'; 2 + 3 + const ChevronRightIcon = createIcon(() => ( 4 + <svg width="1em" height="1em" viewBox="0 0 24 24"> 5 + <path fill="currentColor" d="M10 6L8.59 7.41L13.17 12l-4.58 4.59L10 18l6-6z" /> 6 + </svg> 7 + )); 8 + 9 + export default ChevronRightIcon;
+12
src/components/ic-icons/baseline-close.tsx
··· 1 + import { createIcon } from './_icon'; 2 + 3 + const CloseIcon = createIcon(() => ( 4 + <svg width="1em" height="1em" viewBox="0 0 24 24"> 5 + <path 6 + fill="currentColor" 7 + d="M19 6.41L17.59 5L12 10.59L6.41 5L5 6.41L10.59 12L5 17.59L6.41 19L12 13.41L17.59 19L19 17.59L13.41 12z" 8 + /> 9 + </svg> 10 + )); 11 + 12 + export default CloseIcon;
+7
src/lib/utils/mutation.ts
··· 49 49 ) & { 50 50 mutate(variables: V): void; 51 51 mutateAsync(variables: V): Promise<D>; 52 + reset(): void; 52 53 }; 53 54 54 55 type MutationFunction<D = unknown, V = unknown> = (variables: V, signal: AbortSignal) => Promise<D>; ··· 74 75 { s: MutationState.IDLE }, 75 76 { equals: (prev, next) => prev.s === next.s }, 76 77 ); 78 + 79 + const reset = () => { 80 + cleanup(); 81 + setState({ s: MutationState.IDLE }); 82 + }; 77 83 78 84 const mutate = async (variables: V): Promise<D> => { 79 85 const signal = getSignal(); ··· 139 145 }, 140 146 mutateAsync: mutate, 141 147 mutate: (variables: V) => mutate(variables).then(noop, noop), 148 + reset: reset, 142 149 } as any; 143 150 }; 144 151
+4
src/routes.ts
··· 44 44 path: '/repo-archive-unpack', 45 45 component: lazy(() => import('./views/repository/repo-archive-unpack')), 46 46 }, 47 + { 48 + path: '/repo-archive-explore', 49 + component: lazy(() => import('./views/repository/repo-archive-explore/page')), 50 + }, 47 51 48 52 { 49 53 path: '*',
+3 -3
src/views/frontpage.tsx
··· 67 67 icon: DirectionsCarOutlinedIcon, 68 68 }, 69 69 { 70 - name: `Repository explorer`, 71 - description: `Explore an account's public records`, 72 - href: null, 70 + name: `Explore archive`, 71 + description: `Explore a repository archive`, 72 + href: '/repo-archive-explore', 73 73 icon: ExploreOutlinedIcon, 74 74 }, 75 75 ],
+62
src/views/repository/repo-archive-explore/page.tsx
··· 1 + import { Match, Switch } from 'solid-js'; 2 + 3 + import { iterateAtpRepo } from '@atcute/car'; 4 + 5 + import { createMutation } from '~/lib/utils/mutation'; 6 + 7 + import WelcomeView from './views/welcome'; 8 + 9 + import { Archive, RecordEntry } from './types'; 10 + import ExploreView from './views/explore'; 11 + 12 + const ArchiveExplorePage = () => { 13 + const mutation = createMutation({ 14 + async mutationFn({ file }: { file: File }): Promise<Archive> { 15 + const buffer = new Uint8Array(await file.arrayBuffer()); 16 + 17 + const collections = new Map<string, RecordEntry[]>(); 18 + const archive: Archive = { 19 + file: file, 20 + entries: [], 21 + }; 22 + 23 + for (const entry of iterateAtpRepo(buffer)) { 24 + let list = collections.get(entry.collection); 25 + if (list === undefined) { 26 + collections.set(entry.collection, (list = [])); 27 + archive.entries.push({ 28 + name: entry.collection, 29 + entries: list, 30 + }); 31 + } 32 + 33 + const carEntry = entry.carEntry; 34 + 35 + list.push({ 36 + key: entry.rkey, 37 + cid: entry.cid.$link, 38 + dataStart: carEntry.bytesStart, 39 + dataEnd: carEntry.bytesEnd, 40 + }); 41 + } 42 + 43 + return archive; 44 + }, 45 + }); 46 + 47 + return ( 48 + <> 49 + <Switch> 50 + <Match when={mutation.data} keyed> 51 + {(archive) => <ExploreView archive={archive} onClose={mutation.reset} />} 52 + </Match> 53 + 54 + <Match when> 55 + <WelcomeView mutation={mutation} /> 56 + </Match> 57 + </Switch> 58 + </> 59 + ); 60 + }; 61 + 62 + export default ArchiveExplorePage;
+29
src/views/repository/repo-archive-explore/types.ts
··· 1 + export interface Archive { 2 + /** Actual CAR file */ 3 + file: File; 4 + /** Collections in the CAR file */ 5 + entries: CollectionEntry[]; 6 + } 7 + 8 + export interface CollectionEntry { 9 + /** Collection name, e.g. "app.bsky.feed.post" */ 10 + name: string; 11 + /** Entries under this collection */ 12 + entries: RecordEntry[]; 13 + } 14 + 15 + export interface RecordEntry { 16 + /** Record key, e.g. "3ll3hjomcxka6" */ 17 + key: string; 18 + /** Record digest, e.g. "bafyreieueqsjugefehodlh4o4idd7fzik3koxno7io7x4qu3q4wofsfjl4" */ 19 + cid: string; 20 + /** Start position of the record in the CAR file */ 21 + dataStart: number; 22 + /** End position of the record in the CAR file */ 23 + dataEnd: number; 24 + } 25 + 26 + export type View = 27 + | { type: 'repo' } 28 + | { type: 'collection'; collection: CollectionEntry } 29 + | { type: 'record'; collection: CollectionEntry; record: RecordEntry };
+132
src/views/repository/repo-archive-explore/views/explore.tsx
··· 1 + import { createSignal, Match, Show, Switch } from 'solid-js'; 2 + 3 + import ChevronRightIcon from '~/components/ic-icons/baseline-chevron-right'; 4 + import ArchiveOutlinedIcon from '~/components/ic-icons/outline-archive'; 5 + 6 + import type { Archive, View } from '../types'; 7 + 8 + import CloseIcon from '~/components/ic-icons/baseline-close'; 9 + import CollectionSubview from './explore/collection'; 10 + import RecordSubview from './explore/record'; 11 + import RepoSubview from './explore/repo'; 12 + 13 + export interface ExploreViewProps { 14 + archive: Archive; 15 + onClose: () => void; 16 + } 17 + 18 + const ExploreView = ({ archive, onClose }: ExploreViewProps) => { 19 + const [view, setView] = createSignal<View>({ type: 'repo' }); 20 + 21 + return ( 22 + <> 23 + <div class="flex flex-wrap items-center p-2"> 24 + <button 25 + type="button" 26 + title="This repository" 27 + disabled={view().type === 'repo'} 28 + onClick={() => { 29 + setView({ type: 'repo' }); 30 + }} 31 + class="grid shrink-0 place-items-center rounded p-1.5 text-xl text-purple-700 hover:bg-gray-200 disabled:pointer-events-none disabled:text-black" 32 + > 33 + <ArchiveOutlinedIcon /> 34 + </button> 35 + 36 + <Show 37 + when={(() => { 38 + const $view = view(); 39 + switch ($view.type) { 40 + case 'collection': 41 + case 'record': { 42 + return $view.collection; 43 + } 44 + } 45 + })()} 46 + > 47 + {(collection) => ( 48 + <> 49 + <ChevronRightIcon class="shrink-0 text-base text-gray-500" /> 50 + <button 51 + type="button" 52 + disabled={view().type === 'collection'} 53 + onClick={() => { 54 + setView({ type: 'collection', collection: collection() }); 55 + }} 56 + class="truncate rounded p-1.5 font-mono font-medium text-purple-700 hover:bg-gray-200 disabled:pointer-events-none disabled:text-black" 57 + > 58 + {collection().name} 59 + </button> 60 + </> 61 + )} 62 + </Show> 63 + 64 + <Show 65 + when={(() => { 66 + const $view = view(); 67 + switch ($view.type) { 68 + case 'record': { 69 + return $view.record; 70 + } 71 + } 72 + })()} 73 + > 74 + {(record) => ( 75 + <> 76 + <ChevronRightIcon class="shrink-0 text-base text-gray-500" /> 77 + <button 78 + type="button" 79 + disabled={view().type === 'record'} 80 + class="truncate rounded p-1.5 font-mono font-medium text-purple-700 hover:bg-gray-200 disabled:pointer-events-none disabled:text-black" 81 + > 82 + {record().key} 83 + </button> 84 + </> 85 + )} 86 + </Show> 87 + 88 + <div class="grow"></div> 89 + 90 + <button 91 + type="button" 92 + onClick={onClose} 93 + class="grid shrink-0 place-items-center rounded p-1.5 text-xl hover:bg-gray-200" 94 + > 95 + <CloseIcon /> 96 + </button> 97 + </div> 98 + 99 + <Switch> 100 + <Match when={view().type === 'repo'}> 101 + <RepoSubview archive={archive} onRoute={setView} /> 102 + </Match> 103 + 104 + <Match 105 + when={(() => { 106 + const $view = view(); 107 + if ($view.type === 'collection') { 108 + return $view; 109 + } 110 + })()} 111 + keyed 112 + > 113 + {({ collection }) => <CollectionSubview collection={collection} onRoute={setView} />} 114 + </Match> 115 + 116 + <Match 117 + when={(() => { 118 + const $view = view(); 119 + if ($view.type === 'record') { 120 + return $view; 121 + } 122 + })()} 123 + keyed 124 + > 125 + {({ record }) => <RecordSubview archive={archive} record={record} />} 126 + </Match> 127 + </Switch> 128 + </> 129 + ); 130 + }; 131 + 132 + export default ExploreView;
+41
src/views/repository/repo-archive-explore/views/explore/collection.tsx
··· 1 + import * as TID from '@atcute/tid'; 2 + 3 + import type { CollectionEntry, View } from '../../types'; 4 + 5 + interface CollectionSubviewProps { 6 + collection: CollectionEntry; 7 + onRoute: (view: View) => void; 8 + } 9 + 10 + const CollectionSubview = ({ collection, onRoute }: CollectionSubviewProps) => { 11 + return ( 12 + <div class="px-2 pb-4 pt-0"> 13 + <ul> 14 + {collection.entries.map((entry) => { 15 + const isTid = TID.validate(entry.key); 16 + 17 + return ( 18 + <li class="flex items-center justify-between gap-1"> 19 + <button 20 + onClick={() => { 21 + onRoute({ type: 'record', collection, record: entry }); 22 + }} 23 + class="flex min-w-0 flex-wrap items-center gap-0.5 rounded p-1.5 font-mono hover:bg-gray-200" 24 + > 25 + <span class="truncate font-medium text-purple-700">{/* @once */ entry.key}</span> 26 + </button> 27 + 28 + {isTid && ( 29 + <span class="p-1.5 font-mono text-xs text-gray-600"> 30 + {/* @once */ new Date(TID.parse(entry.key).timestamp / 1_000).toISOString()} 31 + </span> 32 + )} 33 + </li> 34 + ); 35 + })} 36 + </ul> 37 + </div> 38 + ); 39 + }; 40 + 41 + export default CollectionSubview;
+114
src/views/repository/repo-archive-explore/views/explore/record.tsx
··· 1 + import { Match, Switch } from 'solid-js'; 2 + 3 + import * as CBOR from '@atcute/cbor'; 4 + 5 + import { createQuery } from '~/lib/utils/query'; 6 + 7 + import { Archive, RecordEntry } from '../../types'; 8 + 9 + interface RecordSubviewProps { 10 + archive: Archive; 11 + record: RecordEntry; 12 + } 13 + 14 + const RecordSubview = ({ archive, record }: RecordSubviewProps) => { 15 + const query = createQuery( 16 + () => record, 17 + async (_record, signal) => { 18 + const stream = archive.file.stream(); 19 + 20 + const raw = await readStreamRange(stream, record.dataStart, record.dataEnd, signal); 21 + const decoded = CBOR.decode(raw); 22 + 23 + return { raw, decoded }; 24 + }, 25 + ); 26 + 27 + return ( 28 + <Switch> 29 + <Match when={query.data} keyed> 30 + {({ decoded }) => { 31 + return ( 32 + <div class="flex grow flex-col"> 33 + <textarea 34 + readonly 35 + value={JSON.stringify(decoded, null, 2)} 36 + class="grow resize-none border-0 px-4 pb-4 pt-2 font-mono text-sm" 37 + /> 38 + </div> 39 + ); 40 + }} 41 + </Match> 42 + </Switch> 43 + ); 44 + }; 45 + 46 + export default RecordSubview; 47 + 48 + const readStreamRange = async ( 49 + stream: ReadableStream<Uint8Array>, 50 + start: number, 51 + end: number, 52 + signal?: AbortSignal, 53 + ): Promise<Uint8Array> => { 54 + if (start < 0) { 55 + throw new RangeError(`invalid start position: ${start}`); 56 + } 57 + if (end <= start) { 58 + throw new RangeError(`invalid end position: ${end}`); 59 + } 60 + 61 + const length = end - start; 62 + const result = new Uint8Array(length); 63 + 64 + let read = 0; 65 + let written = 0; 66 + 67 + for await (const chunk of createStreamIterator(stream)) { 68 + signal?.throwIfAborted(); 69 + 70 + if (read + chunk.length <= start) { 71 + read += chunk.length; 72 + continue; 73 + } 74 + 75 + const offset = Math.max(0, start - read); 76 + const toRead = Math.min(chunk.length - offset, length - written); 77 + 78 + result.set(chunk.subarray(offset, offset + toRead), written); 79 + 80 + written += toRead; 81 + read += chunk.length; 82 + 83 + if (written >= length) { 84 + break; 85 + } 86 + } 87 + 88 + return result; 89 + }; 90 + 91 + const createStreamIterator: <T>(stream: ReadableStream<T>) => AsyncIterableIterator<T> = 92 + Symbol.asyncIterator in ReadableStream.prototype 93 + ? // @ts-expect-error 94 + (stream) => stream[Symbol.asyncIterator]() 95 + : (stream) => { 96 + const reader = stream.getReader(); 97 + 98 + return { 99 + [Symbol.asyncIterator]() { 100 + return this; 101 + }, 102 + next() { 103 + return reader.read() as Promise<IteratorResult<any>>; 104 + }, 105 + async return() { 106 + await reader.cancel(); 107 + return { done: true, value: undefined }; 108 + }, 109 + async throw(error: unknown) { 110 + await reader.cancel(error); 111 + return { done: true, value: undefined }; 112 + }, 113 + }; 114 + };
+50
src/views/repository/repo-archive-explore/views/explore/repo.tsx
··· 1 + import ChevronRightIcon from '~/components/ic-icons/baseline-chevron-right'; 2 + 3 + import type { Archive, View } from '../../types'; 4 + 5 + interface RepoSubviewProps { 6 + archive: Archive; 7 + onRoute: (view: View) => void; 8 + } 9 + 10 + const RepoSubview = ({ archive, onRoute }: RepoSubviewProps) => { 11 + return ( 12 + <div class="px-2 pb-4 pt-0"> 13 + <ul> 14 + {archive.entries.map((entry) => { 15 + const hasSingleEntry = entry.entries.length === 1; 16 + 17 + return ( 18 + <li> 19 + <button 20 + onClick={() => { 21 + if (hasSingleEntry) { 22 + onRoute({ type: 'record', collection: entry, record: entry.entries[0] }); 23 + } else { 24 + onRoute({ type: 'collection', collection: entry }); 25 + } 26 + }} 27 + class="flex max-w-full flex-wrap items-center gap-0.5 rounded p-1.5 font-mono hover:bg-gray-200" 28 + > 29 + <span 30 + class={`truncate font-medium` + (hasSingleEntry ? ` text-gray-700` : ` text-purple-700`)} 31 + > 32 + {/* @once */ entry.name} 33 + </span> 34 + 35 + {hasSingleEntry && ( 36 + <> 37 + <ChevronRightIcon class="shrink-0 text-base text-gray-500" /> 38 + <span class="truncate font-medium text-purple-700">{entry.entries[0].key}</span> 39 + </> 40 + )} 41 + </button> 42 + </li> 43 + ); 44 + })} 45 + </ul> 46 + </div> 47 + ); 48 + }; 49 + 50 + export default RepoSubview;
+78
src/views/repository/repo-archive-explore/views/welcome.tsx
··· 1 + import { Show } from 'solid-js'; 2 + 3 + import type { MutationReturn } from '~/lib/utils/mutation'; 4 + 5 + import CircularProgress from '~/components/circular-progress'; 6 + 7 + import { Archive } from '../types'; 8 + import { createDropZone } from '~/lib/hooks/dropzone'; 9 + 10 + interface WelcomeViewProps { 11 + mutation: MutationReturn<Archive, { file: File }>; 12 + } 13 + 14 + const WelcomeView = ({ mutation }: WelcomeViewProps) => { 15 + const { ref: dropRef, isDropping } = createDropZone({ 16 + // Checked, the mime type for CAR files is blank. 17 + dataTypes: [''], 18 + multiple: false, 19 + onDrop(files) { 20 + if (files) { 21 + mutation.mutate({ file: files[0] }); 22 + } 23 + }, 24 + }); 25 + 26 + return ( 27 + <> 28 + <div class="p-4"> 29 + <h1 class="text-lg font-bold text-purple-800">Explore archive</h1> 30 + <p class="text-gray-600">Explore a repository archive</p> 31 + </div> 32 + <hr class="mx-4 border-gray-300" /> 33 + 34 + <div class="flex flex-col gap-4 p-4"> 35 + <fieldset 36 + ref={dropRef} 37 + class={ 38 + `grid place-items-center rounded border border-gray-300 px-6 py-12 disabled:opacity-50` + 39 + (!isDropping() ? ` bg-gray-100` : ` bg-green-100`) 40 + } 41 + > 42 + <div class="flex flex-col items-center gap-4"> 43 + <button 44 + onClick={() => { 45 + const input = document.createElement('input'); 46 + input.type = 'file'; 47 + input.accept = '.car,application/vnd.ipld.car'; 48 + input.oninput = () => mutation.mutate({ file: input.files![0] }); 49 + 50 + input.click(); 51 + }} 52 + class="flex h-9 select-none items-center rounded border border-gray-400 px-4 text-sm font-semibold text-gray-800 hover:bg-gray-200 active:bg-gray-200 disabled:pointer-events-none" 53 + > 54 + Browse files 55 + </button> 56 + <p class="select-none font-medium text-gray-600">or drop your file here</p> 57 + </div> 58 + 59 + <div 60 + hidden={!mutation.isPending} 61 + class="absolute inset-0 flex flex-col items-center justify-center gap-3 bg-gray-50" 62 + > 63 + <CircularProgress /> 64 + <span class="font-medium">Reading CAR file</span> 65 + </div> 66 + </fieldset> 67 + 68 + <Show when={mutation.error}> 69 + <p class="whitespace-pre-wrap text-[0.8125rem] font-medium leading-5 text-red-800"> 70 + {'' + mutation.error} 71 + </p> 72 + </Show> 73 + </div> 74 + </> 75 + ); 76 + }; 77 + 78 + export default WelcomeView;