diff --git a/package.json b/package.json index b48cbdedbc..7c1a7756d9 100644 --- a/package.json +++ b/package.json @@ -62,7 +62,7 @@ "lint": "oxlint --quiet src modules", "lint-native": "swiftlint ./modules && ktlint ./modules", "lint-native:fix": "swiftlint --fix ./modules && ktlint --format ./modules", - "lexicons:generate": "lex build --clear --index-file", + "lexicons:generate": "lex build --clear --index-file --import-ext ''", "lexicons:update": "lex install --update", "typecheck": "pnpm run typecheck:ios && pnpm run typecheck:android && pnpm run typecheck:web", "typecheck:ios": "tsc --project ./tsconfig.check.ios.json", diff --git a/src/state/queries/trending/useGetTrendsQuery.ts b/src/state/queries/trending/useGetTrendsQuery.ts index 757e868d1f..e7d6806b3e 100644 --- a/src/state/queries/trending/useGetTrendsQuery.ts +++ b/src/state/queries/trending/useGetTrendsQuery.ts @@ -1,5 +1,5 @@ import {useCallback, useMemo} from 'react' -import {type AppBskyUnspeccedGetTrends, hasMutedWord} from '@atproto/api' +import {hasMutedWord} from '@atproto/api' import {useQuery} from '@tanstack/react-query' import { @@ -10,7 +10,8 @@ import {logger} from '#/logger' import {getContentLanguages} from '#/state/preferences/languages' import {STALE} from '#/state/queries' import {usePreferencesQuery} from '#/state/queries/preferences' -import {useAgent} from '#/state/session' +import {useLexClient} from '#/state/session' +import {app} from '#/lexicons' export const DEFAULT_LIMIT = 5 @@ -32,7 +33,7 @@ export const createGetTrendsQueryKey = (limit?: number) => limit === undefined ? ['trends'] : ['trends', {limit}] export function useGetTrendsQuery(props: QueryProps = {}) { - const agent = useAgent() + const client = useLexClient() const {data: preferences} = usePreferencesQuery() const limit = props.limit ?? DEFAULT_LIMIT const mutedWords = useMemo(() => { @@ -46,7 +47,8 @@ export function useGetTrendsQuery(props: QueryProps = {}) { queryKey: createGetTrendsQueryKey(limit), queryFn: async () => { const contentLangs = getContentLanguages().join(',') - const {data} = await agent.app.bsky.unspecced.getTrends( + const data = await client.call( + app.bsky.unspecced.getTrends, { limit, }, @@ -63,7 +65,7 @@ export function useGetTrendsQuery(props: QueryProps = {}) { return data }, select: useCallback( - (data: AppBskyUnspeccedGetTrends.OutputSchema) => { + (data: app.bsky.unspecced.getTrends.$OutputBody) => { return { recId: data.recIdStr, trends: dedupe( diff --git a/src/state/session/__tests__/clients-test.ts b/src/state/session/__tests__/clients-test.ts new file mode 100644 index 0000000000..c04e2d9ea7 --- /dev/null +++ b/src/state/session/__tests__/clients-test.ts @@ -0,0 +1,70 @@ +import {type AtpAgent} from '@atproto/api' +import {Client} from '@atproto/lex-client' +import {describe, expect, it, jest} from '@jest/globals' + +import {app} from '#/lexicons' +import {agentToLexClient} from '../clients' + +/** + * Minimal stand-in for the parts of AtpAgent that `agentToLexClient` reads: a + * `did` and a `fetchHandler`. Returned as `AtpAgent` via a cast since we only + * exercise those two members. + */ +function makeFakeAgent(did: string | undefined) { + const fetchHandler = jest.fn( + (_path: string, _init: RequestInit): Promise => + Promise.resolve( + new Response( + JSON.stringify({ + did: 'did:plc:fake', + handle: 'fake.bsky.social', + }), + {status: 200, headers: {'content-type': 'application/json'}}, + ), + ), + ) + const agent = {did, fetchHandler} + return {agent: agent as unknown as AtpAgent, fetchHandler} +} + +describe('agentToLexClient', () => { + it('routes client.call through the agent fetchHandler', async () => { + const {agent, fetchHandler} = makeFakeAgent('did:plc:fake') + const client = agentToLexClient(agent) + + const result = await client.call(app.bsky.actor.getProfile.main, { + actor: 'fake.bsky.social', + }) + + expect(fetchHandler).toHaveBeenCalledTimes(1) + const [path] = fetchHandler.mock.calls[0] + expect(path).toContain('/xrpc/app.bsky.actor.getProfile') + expect(path).toContain('actor=fake.bsky.social') + expect(result.handle).toBe('fake.bsky.social') + }) + + it('passes through the agent did', () => { + const {agent} = makeFakeAgent('did:plc:fake') + const client = agentToLexClient(agent) + expect(client.did).toBe('did:plc:fake') + }) + + it('reflects an undefined did (unauthenticated agent)', () => { + const {agent} = makeFakeAgent(undefined) + const client = agentToLexClient(agent) + expect(client.did).toBeUndefined() + }) + + it('memoizes one client per agent', () => { + const {agent: agentA} = makeFakeAgent('did:plc:a') + const {agent: agentB} = makeFakeAgent('did:plc:b') + + const clientA1 = agentToLexClient(agentA) + const clientA2 = agentToLexClient(agentA) + const clientB = agentToLexClient(agentB) + + expect(clientA1).toBeInstanceOf(Client) + expect(clientA1).toBe(clientA2) + expect(clientA1).not.toBe(clientB) + }) +}) diff --git a/src/state/session/clients.ts b/src/state/session/clients.ts new file mode 100644 index 0000000000..6c4e72bc42 --- /dev/null +++ b/src/state/session/clients.ts @@ -0,0 +1,72 @@ +import {type AtpAgent} from '@atproto/api' +import {Client} from '@atproto/lex-client' + +import {PUBLIC_BSKY_SERVICE} from '#/lib/constants' + +/** + * Stable per-agent cache of lex `Client` instances. We never reuse an + * `AtpAgent` (the session provider disposes the previous one on switch), so a + * `WeakMap` keyed on the agent gives us a client whose identity is stable for + * the lifetime of that agent. This keeps React Query keys and hook consumers + * from churning on every render. + */ +const clientForAgent = new WeakMap() + +/** + * Wrap a live {@link AtpAgent} as a lex {@link Client}, bridging the old session + * source of truth to the new SDK so features can migrate incrementally. + * + * The client talks to the agent through the minimal lex `Agent` interface + * (`{ did, fetchHandler }`). We deliberately route `fetchHandler` through + * `agent.fetchHandler` (the agent's XRPC dispatch layer) rather than + * `agent.sessionManager.fetchHandler`: + * + * - `agent.fetchHandler` (see @atproto/api `agent.js`) is where the agent + * applies its configured `atproto-proxy` header (set via `configureProxy` to + * the appview) and its `atproto-accept-labelers` header, before delegating to + * `sessionManager.fetchHandler` for authorization + token refresh. + * - `sessionManager.fetchHandler` (`CredentialSession`) only adds the auth + * token and handles refresh - it does NOT proxy or attach labelers. Wrapping + * it directly would silently drop appview proxying and moderation labelers. + * + * Because the wrapped agent already applies proxy + labeler headers, we do NOT + * pass a `service` option to the `Client` (lex-client only sets `atproto-proxy` + * when `service` is provided) and we leave `Client.appLabelers` at its default + * empty set. This avoids double-setting either header. + * + * Results are memoized per-agent so the returned client is referentially stable. + */ +export function agentToLexClient(agent: AtpAgent): Client { + const cached = clientForAgent.get(agent) + if (cached) { + return cached + } + const client = new Client({ + get did() { + return agent.did + }, + fetchHandler: (path, init) => agent.fetchHandler(path, init), + }) + clientForAgent.set(agent, client) + return client +} + +/** + * Lazily-constructed unauthenticated client pointed at the public appview. It + * hits {@link PUBLIC_BSKY_SERVICE} directly, mirroring `createPublicAgent`'s + * service URL, so no proxying is required. + */ +let publicClient: Client | undefined + +function getPublicLexClient(): Client { + publicClient ??= new Client(PUBLIC_BSKY_SERVICE) + return publicClient +} + +/** + * Unauthenticated lex {@link Client} for public appview reads. A process-wide + * singleton, so its identity is stable across renders. + */ +export function usePublicLexClient(): Client { + return getPublicLexClient() +} diff --git a/src/state/session/index.tsx b/src/state/session/index.tsx index fd31261a9d..232e72af69 100644 --- a/src/state/session/index.tsx +++ b/src/state/session/index.tsx @@ -9,6 +9,7 @@ import { useSyncExternalStore, } from 'react' import {type AtpAgent, type AtpSessionEvent} from '@atproto/api' +import {type Client} from '@atproto/lex-client' import * as persisted from '#/state/persisted' import {useCloseAllActiveElements} from '#/state/util' @@ -26,6 +27,7 @@ import { } from './agent' import {type Action, getInitialState, reducer, type State} from './reducer' export {isSignupQueued} from './util' +import {agentToLexClient} from './clients' import {addSessionDebugLog} from './logging' export type {SessionAccount} from '#/state/session/types' @@ -460,3 +462,14 @@ export function useAgent(): AtpAgent { } return agent } + +/** + * Authenticated lex {@link Client} wrapping the current session agent. Stable + * per-agent, so it only changes identity when the active account changes. + * + * @see agentToLexClient for how the AtpAgent is bridged to the lex Client. + */ +export function useLexClient(): Client { + const agent = useAgent() + return agentToLexClient(agent) +} diff --git a/src/types/bsky/__tests__/index.test.ts b/src/types/bsky/__tests__/index.test.ts new file mode 100644 index 0000000000..f108ebc98a --- /dev/null +++ b/src/types/bsky/__tests__/index.test.ts @@ -0,0 +1,163 @@ +import {AppBskyFeedPost} from '@atproto/api' + +import {app} from '#/lexicons' +import * as bsky from '#/types/bsky' + +const now = () => new Date().toISOString() + +/** + * A structurally valid `app.bsky.feed.post` record. + */ +const validPost = { + $type: 'app.bsky.feed.post', + text: 'hello world', + createdAt: now(), +} + +/** + * Right `$type`, but the body is invalid (`text` is not a string, `createdAt` + * is not a datetime). Passes a `$type`-only guard, fails full validation. + */ +const invalidPost = { + $type: 'app.bsky.feed.post', + text: 123, + createdAt: 'not-a-datetime', +} + +/** + * A different record type entirely - should fail even the fast guard. + */ +const wrongType = { + $type: 'app.bsky.feed.like', + subject: {uri: 'at://x', cid: 'y'}, + createdAt: now(), +} + +describe('types/bsky new-world helpers (#/lexicons)', () => { + describe('isType (fast, $type-only)', () => { + it('accepts a valid record', () => { + expect(bsky.isType(app.bsky.feed.post, validPost)).toBe(true) + }) + + it('rejects a $type mismatch', () => { + expect(bsky.isType(app.bsky.feed.post, wrongType)).toBe(false) + }) + + it('accepts an invalid body that has the right $type (dangerous semantics)', () => { + // Mirrors the old `dangerousIsType`: only the `$type` is checked, so a + // structurally invalid record still passes. + expect(bsky.isType(app.bsky.feed.post, invalidPost)).toBe(true) + }) + + it('accepts a schema passed as its namespace module or its main schema', () => { + expect(bsky.isType(app.bsky.feed.post, validPost)).toBe(true) + expect(bsky.isType(app.bsky.feed.post.main, validPost)).toBe(true) + }) + + it('returns false (does not throw) for null and undefined', () => { + // Mirrors the old `dangerousIsType`/`is$typed` behavior - call sites + // pass e.g. `post.record` which may be undefined. + expect(bsky.isType(app.bsky.feed.post, null)).toBe(false) + expect(bsky.isType(app.bsky.feed.post, undefined)).toBe(false) + expect(bsky.isType(app.bsky.feed.post, 'string')).toBe(false) + }) + + it('requires a present $type for typed-object def schemas', () => { + /* + * The generated TypedObjectSchema.isTypeOf treats a missing $type as a + * match (maybe-typed semantics). Our helper must NOT: when + * discriminating unions by $type, an object without $type would + * otherwise satisfy every branch. + */ + expect(bsky.isType(app.bsky.feed.defs.postView, {foo: 1})).toBe(false) + expect(bsky.isType(app.bsky.feed.defs.postView, {})).toBe(false) + expect( + bsky.isType(app.bsky.feed.defs.postView, { + $type: 'app.bsky.feed.defs#postView', + }), + ).toBe(true) + }) + }) + + describe('matches (full validation guard)', () => { + it('accepts a valid record', () => { + expect(bsky.matches(app.bsky.feed.post, validPost)).toBe(true) + }) + + it('rejects a $type mismatch', () => { + expect(bsky.matches(app.bsky.feed.post, wrongType)).toBe(false) + }) + + it('rejects an invalid body even though the $type matches', () => { + expect(bsky.matches(app.bsky.feed.post, invalidPost)).toBe(false) + }) + }) + + describe('safeParse (full validation, no throw)', () => { + it('succeeds and returns the value for a valid record', () => { + const result = bsky.safeParse(app.bsky.feed.post, validPost) + expect(result.success).toBe(true) + if (result.success) { + expect(result.value.text).toBe('hello world') + } + }) + + it('fails with a reason for an invalid record', () => { + const result = bsky.safeParse(app.bsky.feed.post, invalidPost) + expect(result.success).toBe(false) + if (!result.success) { + expect(result.reason).toBeDefined() + } + }) + }) + + describe('parse (full validation, throws)', () => { + it('returns the typed value for a valid record', () => { + const record = bsky.parse(app.bsky.feed.post, validPost) + expect(record.text).toBe('hello world') + }) + + it('throws for an invalid record', () => { + expect(() => bsky.parse(app.bsky.feed.post, invalidPost)).toThrow() + }) + }) +}) + +describe('types/bsky legacy helpers (@atproto/api) still work', () => { + it('dangerousIsType accepts the right $type without validating the body', () => { + expect( + bsky.dangerousIsType( + validPost, + AppBskyFeedPost.isRecord, + ), + ).toBe(true) + // Right $type, invalid body - still passes the fast guard. + expect( + bsky.dangerousIsType( + invalidPost, + AppBskyFeedPost.isRecord, + ), + ).toBe(true) + expect( + bsky.dangerousIsType( + wrongType, + AppBskyFeedPost.isRecord, + ), + ).toBe(false) + }) + + it('validate fully validates the body', () => { + expect( + bsky.validate( + validPost, + AppBskyFeedPost.validateRecord, + ), + ).toBe(true) + expect( + bsky.validate( + invalidPost, + AppBskyFeedPost.validateRecord, + ), + ).toBe(false) + }) +}) diff --git a/src/types/bsky/index.ts b/src/types/bsky/index.ts index d482f9515b..5e168858cb 100644 --- a/src/types/bsky/index.ts +++ b/src/types/bsky/index.ts @@ -1,4 +1,16 @@ -import {type ValidationResult} from '@atproto/lexicon' +import { + getMain, + type InferInput, + type InferOutput, + type Main, + type ParseOptions, + type RecordSchema, + type Schema, + type TypedObjectSchema, + type ValidateOptions, + type ValidationResult, +} from '@atproto/lex' +import {type ValidationResult as LegacyValidationResult} from '@atproto/lexicon' export * as post from '#/types/bsky/post' export * as profile from '#/types/bsky/profile' @@ -45,7 +57,157 @@ export function dangerousIsType( */ export function validate( record: unknown, - validator: (v: unknown) => ValidationResult, + validator: (v: unknown) => LegacyValidationResult, ): record is R { return validator(record).success } + +/* + * New-world helpers (below) operate on the generated lexicon schema objects + * from '#/lexicons', replacing the `@atproto/api`-based helpers above. + * + * The old helpers take a record plus a standalone `is*`/`validate*` function + * (e.g. `AppBskyFeedPost.isRecord`). The new codegen instead attaches the + * validation surface directly to each schema, so these helpers take the schema + * object itself: + * + * ```ts + * import {app} from '#/lexicons' + * import * as bsky from '#/types/bsky' + * + * // old: bsky.dangerousIsType(v, AppBskyFeedPost.isRecord) + * bsky.isType(app.bsky.feed.post, v) + * + * // old: bsky.validate(v, AppBskyFeedPost.validateRecord) + * bsky.matches(app.bsky.feed.post, v) + * ``` + * + * Every generated namespace module (e.g. `app.bsky.feed.post`) re-exports its + * `main` schema, and bare defs (e.g. `app.bsky.feed.defs.postView`) are schema + * objects directly. Both forms are accepted here - `getMain` unwraps a module + * to its `main` schema and passes a bare schema through unchanged - so call + * sites can pass whichever is in scope. + */ + +/** + * A generated lexicon schema that carries a `$type` and therefore supports the + * fast, `$type`-only {@link isType} check: a record schema (`app.bsky.feed.post`) + * or a typed-object def schema (`app.bsky.feed.defs.postView`). + */ +type TypedSchema = RecordSchema | TypedObjectSchema + +/** + * Fast type checking without full schema validation, for use with data we + * trust, or for non-critical path use cases. This only compares the `$type` + * string; it does NOT assert the rest of the object matches the schema. An + * invalid record with the right `$type` will pass. + * + * This is the '#/lexicons' equivalent of {@link dangerousIsType}. For full + * validation of the object schema, use {@link matches}, {@link parse}, or + * {@link safeParse} from this same file. + * + * Usage: + * ```ts + * import {app} from '#/lexicons' + * import * as bsky from '#/types/bsky' + * + * if (bsky.isType(app.bsky.feed.post, item)) { + * // `item` is narrowed to the post record type here + * } + * ``` + */ +export function isType( + schema: Main, + value: unknown, +): value is InferInput { + /* + * Deliberately NOT delegating to the schema's `isTypeOf`: the generated + * `TypedObjectSchema.isTypeOf` treats a MISSING `$type` as a match + * (maybe-typed semantics), which would let any plain object satisfy any + * def-schema check and break `$type`-discriminated unions. The old + * `dangerousIsType` required a present, matching `$type`, and so do we. + * The nullish/object guard also mirrors the old `is$typed` behavior of + * returning false (not throwing) for null/undefined input. + */ + return ( + value != null && + typeof value === 'object' && + (value as {$type?: unknown}).$type === getMain(schema).$type + ) +} + +/** + * Fully validates the object against the schema (strict, no coercion), which + * has a performance cost, and narrows the value on success. + * + * This is the '#/lexicons' equivalent of {@link validate}. For faster checks + * with data we trust, like that from our app view, use {@link isType} from this + * same file. + * + * Usage: + * ```ts + * import {app} from '#/lexicons' + * import * as bsky from '#/types/bsky' + * + * if (bsky.matches(app.bsky.feed.post, item)) { + * // `item` is narrowed to the post record type here + * } + * ``` + */ +export function matches( + schema: Main, + value: unknown, + options?: ValidateOptions, +): value is InferInput { + return getMain(schema).matches(value, options) +} + +/** + * Fully validates and parses the value against the schema, returning the typed + * value or throwing an `LexValidationError` on failure. Parsing may apply + * schema transformations such as default values. + * + * Prefer {@link safeParse} where you want to branch on failure without a + * try/catch, or {@link matches} where you only need a type guard. + * + * Usage: + * ```ts + * import {app} from '#/lexicons' + * import * as bsky from '#/types/bsky' + * + * const record = bsky.parse(app.bsky.feed.post, item) // typed, or throws + * ``` + */ +export function parse( + schema: Main, + value: unknown, + options?: ParseOptions, +): InferOutput { + return getMain(schema).parse(value, options) +} + +/** + * Fully validates and parses the value against the schema, returning a + * discriminated `ValidationResult` instead of throwing. Parsing may apply + * schema transformations such as default values. + * + * Usage: + * ```ts + * import {app} from '#/lexicons' + * import * as bsky from '#/types/bsky' + * + * const result = bsky.safeParse(app.bsky.feed.post, item) + * if (result.success) { + * // `result.value` is the typed post record + * } else { + * // `result.reason` is the LexValidationError + * } + * ``` + */ +export function safeParse( + schema: Main, + value: unknown, + options?: ParseOptions, +): ValidationResult> { + return getMain(schema).safeParse(value, options) +}