diff --git a/src/lexicon.ts b/src/lexicon.ts index 66289e0..d090318 100644 --- a/src/lexicon.ts +++ b/src/lexicon.ts @@ -4,6 +4,7 @@ import type { ResolvedLexicon, LexiconDocument, ParsedNsid, + NsidSuggestion, DidDocument, RepoGetRecordResponse } from './types'; @@ -12,6 +13,8 @@ const NSID_PATTERN = /^[a-zA-Z]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+(\.[a-zA-Z]([a-zA-Z0-9]{0,62})?)$/; const LEXICON_SCHEMA_COLLECTION = 'com.atproto.lexicon.schema'; +const LEXICON_CACHE_PREFIX = 'lexicon:'; +const MAX_SUGGESTIONS = 10; export async function sha256Json(value: unknown): Promise { const bytes = new TextEncoder().encode(JSON.stringify(value)); @@ -37,7 +40,7 @@ export async function resolveLexicon(nsid: string, env: RuntimeEnv): Promise(cacheKey, 'json'); if (cached !== null && !isLegacyGeneratedCache(cached)) { return { ...normalizeCachedLexicon(cached, parsed), cache: 'hit' }; @@ -66,6 +69,42 @@ export async function resolveLexicon(nsid: string, env: RuntimeEnv): Promise { + const normalized = normalizeSuggestQuery(query); + if (normalized === '') { + return []; + } + + const boundedLimit = Math.max(1, Math.min(Math.floor(limit), MAX_SUGGESTIONS)); + const results = await env.LEXICONS.list({ prefix: `${LEXICON_CACHE_PREFIX}${normalized}`, limit: boundedLimit * 3 }); + const candidates = await Promise.all( + results.keys.map(async (key) => { + const nsid = key.name.slice(LEXICON_CACHE_PREFIX.length); + const cached = await env.LEXICONS.get(key.name, 'json'); + + return { nsid, cached }; + }) + ); + + return candidates + .filter( + ({ nsid, cached }) => nsid.length > 0 && isValidNsid(nsid) && cached !== null && !isLegacyGeneratedCache(cached) + ) + .map(({ nsid }) => nsid) + .sort((left, right) => left.localeCompare(right)) + .slice(0, boundedLimit) + .map((nsid) => ({ nsid, source: 'cache' })); +} + +function normalizeSuggestQuery(query: string): string { + const trimmed = query.trim(); + if (trimmed.length > 317 || !/^[A-Za-z0-9.-]+$/.test(trimmed)) { + return ''; + } + + return trimmed; +} + function normalizeCachedLexicon(value: ResolvedLexicon, parsed: ParsedNsid): ResolvedLexicon { return { ...value, source: { name: value.source.name ?? parsed.dnsName, url: value.source.url } }; } diff --git a/src/main.ts b/src/main.ts index 7a7fb02..7e62efa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,7 @@ import { Hono } from 'hono'; import { apiError, htmlResponse } from './http'; -import { mapResolveError, resolveLexicon, resolvePathNsid } from './lexicon'; -import { assetResponse, renderIndex, renderJsonPartial } from './view'; +import { mapResolveError, resolveLexicon, resolvePathNsid, suggestNsids } from './lexicon'; +import { assetResponse, renderIndex, renderJsonPartial, renderSuggestionsPartial } from './view'; const app = new Hono<{ Bindings: Env }>(); @@ -37,6 +37,18 @@ app.get('/partials/resolve', async (c) => { } }); +app.get('/partials/suggest', async (c) => { + const query = c.req.query('nsid') ?? ''; + const suggestions = await suggestNsids(query, c.env); + return htmlResponse(renderSuggestionsPartial(suggestions)); +}); + +app.get('/api/suggest', async (c) => { + const query = c.req.query('q') ?? ''; + const suggestions = await suggestNsids(query, c.env); + return c.json({ query, items: suggestions }); +}); + app.get('/api/resolve/*', async (c) => { try { const nsid = resolvePathNsid(new URL(c.req.url).pathname); diff --git a/src/types.ts b/src/types.ts index 59a309a..f31b9fb 100644 --- a/src/types.ts +++ b/src/types.ts @@ -23,6 +23,8 @@ export type ResolvedLexicon = { cache: 'hit' | 'miss'; }; +export type NsidSuggestion = { nsid: string; source: 'cache' }; + export type DidDocument = { service?: Array<{ id?: string; type?: string; serviceEndpoint?: string }> }; export type RepoGetRecordResponse = { value?: unknown }; diff --git a/src/view.ts b/src/view.ts index 4f6cff1..d733069 100644 --- a/src/view.ts +++ b/src/view.ts @@ -15,6 +15,7 @@ import prismJsonJs from './view/vendor/prism-json.min.js.txt'; import prismCss from './view/vendor/mocha.css'; import { FooterConfig } from './types'; export { renderJsonPartial } from './view/partials'; +export { renderSuggestionsPartial } from './view/partials'; const footerConfig = { left: { heading: 'Meta', items: [{ label: 'About', value: 'Resolve ATProto Lexicon schemas from NSIDs' }] }, diff --git a/src/view/partials.ts b/src/view/partials.ts index 137644d..5e325fd 100644 --- a/src/view/partials.ts +++ b/src/view/partials.ts @@ -1,3 +1,5 @@ +import type { NsidSuggestion } from '../types'; + export function renderJsonPartial(value: unknown, nsid: string): string { const json = escapeHtml(JSON.stringify(value, null, 2)); const label = escapeHtml(`Lexicon: ${nsid || 'unknown'}`); @@ -12,6 +14,26 @@ export function renderJsonPartial(value: unknown, nsid: string): string { `; } +export function renderSuggestionsPartial(suggestions: NsidSuggestion[]): string { + if (suggestions.length === 0) { + return ''; + } + + const items = suggestions + .map((suggestion) => { + const nsid = escapeHtml(suggestion.nsid); + return ` + `; + }) + .join(''); + + return `
${items} +
`; +} + function escapeHtml(value: string): string { return value .replaceAll('&', '&') diff --git a/src/view/styles/components/resolver.css b/src/view/styles/components/resolver.css index e0bf7dc..2809f39 100644 --- a/src/view/styles/components/resolver.css +++ b/src/view/styles/components/resolver.css @@ -15,6 +15,11 @@ gap: var(--space-3); } +.resolver-form__field { + display: grid; + gap: var(--space-2); +} + .resolver-form input { inline-size: 100%; min-inline-size: 0; @@ -36,6 +41,60 @@ filter: brightness(0.95); } +.resolver-form__suggestions:empty { + display: none; +} + +.nsid-suggestions { + display: grid; + max-block-size: 14rem; + overflow: auto; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + box-shadow: var(--shadow-panel); +} + +.nsid-suggestions__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-3); + inline-size: 100%; + padding: 0.7rem 0.85rem; + color: var(--text); + background: transparent; + border: 0; + border-radius: 0; + text-align: start; + cursor: pointer; +} + +.nsid-suggestions__item + .nsid-suggestions__item { + border-block-start: 1px solid var(--border); +} + +.nsid-suggestions__item:hover, +.nsid-suggestions__item:focus-visible { + background: var(--accent-bg); + filter: none; +} + +.nsid-suggestions__item span { + min-inline-size: 0; + overflow-wrap: anywhere; + font-family: ui-monospace, 'SFMono-Regular', 'SF Mono', Consolas, 'Liberation Mono', monospace; + font-size: var(--size-sm); +} + +.nsid-suggestions__item small { + flex: 0 0 auto; + color: var(--muted); + font-size: 0.75rem; + font-weight: 600; + text-transform: uppercase; +} + .resolver-result:empty { display: none; } diff --git a/src/view/templates/index.mustache b/src/view/templates/index.mustache index 2fb65ef..44c2e6b 100644 --- a/src/view/templates/index.mustache +++ b/src/view/templates/index.mustache @@ -38,23 +38,36 @@

Lexicon Schema REST API

-
+

Resolve a schema

Enter an NSID and the resolver will look for its published DNS TXT record.

-
+ -
- - +
+
+ + +
+
diff --git a/test/lexicon.test.ts b/test/lexicon.test.ts index 4c2c9be..de242d6 100644 --- a/test/lexicon.test.ts +++ b/test/lexicon.test.ts @@ -1,16 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { isLexiconDocument, resolveLexicon } from '../src/lexicon'; +import { isLexiconDocument, resolveLexicon, suggestNsids } from '../src/lexicon'; import type { RuntimeEnv, ResolvedLexicon } from '../src/types'; const originalFetch = globalThis.fetch; -function resolvedLexicon(): ResolvedLexicon { +function resolvedLexicon(nsid = 'com.example.foo'): ResolvedLexicon { return { - nsid: 'com.example.foo', + nsid, hash: 'sha256:test', fetchedAt: '2026-06-18T00:00:00.000Z', - source: { name: '_lexicon.example.com', url: 'at://did:plc:example/com.atproto.lexicon.schema/com.example.foo' }, - lexicon: { lexicon: 1, id: 'com.example.foo', defs: {} }, + source: { name: '_lexicon.example.com', url: `at://did:plc:example/com.atproto.lexicon.schema/${nsid}` }, + lexicon: { lexicon: 1, id: nsid, defs: {} }, cache: 'miss' }; } @@ -27,6 +27,16 @@ function testEnv(storage = new Map()): RuntimeEnv { }, async put(key: string, value: string): Promise { storage.set(key, value); + }, + async list(options?: KVNamespaceListOptions): Promise> { + const prefix = options?.prefix ?? ''; + const limit = options?.limit ?? 1000; + const keys = [...storage.keys()] + .filter((name) => name.startsWith(prefix)) + .slice(0, limit) + .map((name) => ({ name })); + + return { keys, list_complete: true, cacheStatus: null }; } }; @@ -134,4 +144,35 @@ describe('Lexicon resolver', () => { await expect(resolveLexicon('com.example.foo', env)).rejects.toMatchObject({ status: 404, code: 'not_found' }); expect(fetchMock).toHaveBeenCalledOnce(); }); + + it('suggests NSIDs from cached lexicon keys', async () => { + const env = testEnv( + new Map([ + ['lexicon:app.bsky.feed.post', JSON.stringify(resolvedLexicon('app.bsky.feed.post'))], + ['lexicon:app.bsky.graph.follow', JSON.stringify(resolvedLexicon('app.bsky.graph.follow'))], + ['other:app.bsky.feed.post', '{}'] + ]) + ); + + await expect(suggestNsids('app.bsky.f', env)).resolves.toEqual([{ nsid: 'app.bsky.feed.post', source: 'cache' }]); + }); + + it('does not suggest for empty or unsafe queries', async () => { + const env = testEnv( + new Map([['lexicon:app.bsky.feed.post', JSON.stringify(resolvedLexicon('app.bsky.feed.post'))]]) + ); + + await expect(suggestNsids('', env)).resolves.toEqual([]); + await expect(suggestNsids('../app', env)).resolves.toEqual([]); + }); + + it('does not suggest legacy generated cache entries', async () => { + const legacyCached = { + ...resolvedLexicon('app.bsky.feed.post'), + source: { name: 'atcute', url: 'https://example.com/generated/app.bsky.feed.post.json', type: 'atcute-generated' } + }; + const env = testEnv(new Map([['lexicon:app.bsky.feed.post', JSON.stringify(legacyCached)]])); + + await expect(suggestNsids('app.bsky.f', env)).resolves.toEqual([]); + }); }); diff --git a/test/view.test.ts b/test/view.test.ts index f123a9f..2a27e48 100644 --- a/test/view.test.ts +++ b/test/view.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { renderJsonPartial } from '../src/view/partials'; +import { renderJsonPartial, renderSuggestionsPartial } from '../src/view/partials'; describe('View helpers', () => { it('escapes JSON before rendering the Prism partial', () => { @@ -19,4 +19,19 @@ describe('View helpers', () => { expect(html).toContain('Lexicon: <script>'); expect(html).not.toContain('Lexicon: