diff --git a/web/lex.config.ts b/web/lex.config.ts --- a/web/lex.config.ts +++ b/web/lex.config.ts @@ -1,30 +1,30 @@ -import { defineLexiconConfig } from '@atcute/lex-cli'; +import { defineLexiconConfig } from "@atcute/lex-cli"; // generate sh.tangled types; resolve com.atproto refs from @atcute/atproto. export default defineLexiconConfig({ - formatter: { type: 'prettier' }, + formatter: { type: "prettier" }, generate: { - outdir: 'src/lib/api/lexicons/', + outdir: "src/lib/api/lexicons/", clean: true, - imports: ['@atcute/atproto'], + imports: ["@atcute/atproto"], files: [ - '../lexicons/*.json', - '../lexicons/actor/**/*.json', - '../lexicons/ci/**/*.json', - '../lexicons/feed/**/*.json', - '../lexicons/git/**/*.json', - '../lexicons/graph/**/*.json', - '../lexicons/issue/**/*.json', - '../lexicons/knot/**/*.json', - '../lexicons/label/**/*.json', - '../lexicons/markup/**/*.json', - '../lexicons/pipeline/**/*.json', - '../lexicons/pulls/**/*.json', - '../lexicons/repo/**/*.json', - '../lexicons/spindle/**/*.json', - '../lexicons/string/**/*.json', - '../lexicons/sync/**/*.json', - '../bobbin/crates/types/lexicons/**/*.json' + "../lexicons/*.json", + "../lexicons/actor/**/*.json", + "../lexicons/ci/**/*.json", + "../lexicons/feed/**/*.json", + "../lexicons/git/**/*.json", + "../lexicons/graph/**/*.json", + "../lexicons/issue/**/*.json", + "../lexicons/knot/**/*.json", + "../lexicons/label/**/*.json", + "../lexicons/markup/**/*.json", + "../lexicons/pipeline/**/*.json", + "../lexicons/pulls/**/*.json", + "../lexicons/repo/**/*.json", + "../lexicons/spindle/**/*.json", + "../lexicons/string/**/*.json", + "../lexicons/sync/**/*.json", + "../bobbin/crates/types/lexicons/**/*.json" ] } }); diff --git a/web/playwright.config.ts b/web/playwright.config.ts --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,6 +1,6 @@ -import { defineConfig } from '@playwright/test'; +import { defineConfig } from "@playwright/test"; export default defineConfig({ - webServer: { command: 'pnpm run build && pnpm run preview', port: 4173 }, - testMatch: '**/*.e2e.{ts,js}' + webServer: { command: "pnpm run build && pnpm run preview", port: 4173 }, + testMatch: "**/*.e2e.{ts,js}" }); diff --git a/web/prettier.config.js b/web/prettier.config.js --- a/web/prettier.config.js +++ b/web/prettier.config.js @@ -1,12 +1,12 @@ /** @type {import("prettier").Config} */ const config = { useTabs: true, - singleQuote: true, - trailingComma: 'none', + singleQuote: false, + trailingComma: "none", printWidth: 100, - plugins: ['prettier-plugin-svelte', 'prettier-plugin-tailwindcss'], - overrides: [{ files: '*.svelte', options: { parser: 'svelte' } }], - tailwindStylesheet: './src/app.css' + plugins: ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], + overrides: [{ files: "*.svelte", options: { parser: "svelte" } }], + tailwindStylesheet: "./src/app.css" }; export default config; diff --git a/web/svelte.config.js b/web/svelte.config.js --- a/web/svelte.config.js +++ b/web/svelte.config.js @@ -1,10 +1,10 @@ -import adapter from '@sveltejs/adapter-node'; -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; +import adapter from "@sveltejs/adapter-node"; +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; const config = { preprocess: vitePreprocess(), compilerOptions: { - runes: ({ filename }) => (filename.split(/[/\\]/).includes('node_modules') ? undefined : true) + runes: ({ filename }) => (filename.split(/[/\\]/).includes("node_modules") ? undefined : true) }, kit: { adapter: adapter() diff --git a/web/src/hooks.server.ts b/web/src/hooks.server.ts --- a/web/src/hooks.server.ts +++ b/web/src/hooks.server.ts @@ -1,4 +1,4 @@ -import type { Handle } from '@sveltejs/kit'; +import type { Handle } from "@sveltejs/kit"; export const handle: Handle = async ({ event, resolve }) => { return resolve(event, { @@ -9,7 +9,7 @@ // the data we already had. // so we allow these headers to have atcute function properly. filterSerializedResponseHeaders(name) { - return name === 'content-type' || name === 'content-length'; + return name === "content-type" || name === "content-length"; } }); }; diff --git a/web/src/icon.d.ts b/web/src/icon.d.ts --- a/web/src/icon.d.ts +++ b/web/src/icon.d.ts @@ -1,11 +1,11 @@ // `$icon/` is a vite alias for unplugin-icons' `~icons/lucide/` // virtual module (see vite.config.ts). mirror its ambient type here so tsc / // svelte-check resolve the aliased imports. must stay a global .d.ts (no export) -declare module '$icon/*' { - import type { Component } from 'svelte'; - import type { SvelteHTMLElements } from 'svelte/elements'; +declare module "$icon/*" { + import type { Component } from "svelte"; + import type { SvelteHTMLElements } from "svelte/elements"; - const component: Component; + const component: Component; export default component; } diff --git a/web/src/lib/auth.svelte.ts b/web/src/lib/auth.svelte.ts --- a/web/src/lib/auth.svelte.ts +++ b/web/src/lib/auth.svelte.ts @@ -1,12 +1,12 @@ -import { browser } from '$app/environment'; +import { browser } from "$app/environment"; import { CompositeDidDocumentResolver, LocalActorResolver, PlcDidDocumentResolver, WebDidDocumentResolver, XrpcHandleResolver -} from '@atcute/identity-resolver'; -import type { ActorIdentifier, Did } from '@atcute/lexicons/syntax'; +} from "@atcute/identity-resolver"; +import type { ActorIdentifier, Did } from "@atcute/lexicons/syntax"; import { OAuthUserAgent, configureOAuth, @@ -15,10 +15,10 @@ finalizeAuthorization, getSession, listStoredSessions -} from '@atcute/oauth-browser-client'; -import { getContext } from 'svelte'; -import { SvelteURL, SvelteURLSearchParams } from 'svelte/reactivity'; -import oauthMetadata from '../../static/oauth-client-metadata.json'; +} from "@atcute/oauth-browser-client"; +import { getContext } from "svelte"; +import { SvelteURL, SvelteURLSearchParams } from "svelte/reactivity"; +import oauthMetadata from "../../static/oauth-client-metadata.json"; import { type AuthAccount, clearActive, @@ -29,19 +29,19 @@ reconcileAccounts, saveAccounts, upsertAccount -} from './auth/accounts'; +} from "./auth/accounts"; -export const AUTH_KEY = Symbol('auth'); -const DEV_REDIRECT_URI = 'http://127.0.0.1:5173/oauth/callback'; +export const AUTH_KEY = Symbol("auth"); +const DEV_REDIRECT_URI = "http://127.0.0.1:5173/oauth/callback"; const DEV_CLIENT_ID = `http://localhost?redirect_uri=${encodeURIComponent(DEV_REDIRECT_URI)}&scope=${encodeURIComponent(oauthMetadata.scope)}`; // local dev (localinfra) points these at the local pds/plc; defaults are the public network. const HANDLE_RESOLVER_URL = - (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, '') ?? - 'https://public.api.bsky.app'; + (import.meta.env.VITE_HANDLE_RESOLVER_URL as string | undefined)?.replace(/\/+$/, "") ?? + "https://public.api.bsky.app"; const PLC_DIRECTORY_URL = (import.meta.env.VITE_PLC_DIRECTORY_URL as string | undefined)?.replace( /\/+$/, - '' + "" ); const identityResolver = new LocalActorResolver({ @@ -68,7 +68,7 @@ avatar?: string; } -export type { AuthAccount } from './auth/accounts'; +export type { AuthAccount } from "./auth/accounts"; export interface Auth { readonly agent: OAuthUserAgent | null; @@ -100,7 +100,7 @@ type OAuthSession = ConstructorParameters[0]; const ENV_OAUTH_REDIRECT_URI = import.meta.env.VITE_OAUTH_REDIRECT_URI as string | undefined; -const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes('://localhost') ?? false; +const HAS_LOCALHOST_REDIRECT = ENV_OAUTH_REDIRECT_URI?.includes("://localhost") ?? false; const OAUTH_CLIENT_ID = HAS_LOCALHOST_REDIRECT ? DEV_CLIENT_ID : ((import.meta.env.VITE_OAUTH_CLIENT_ID as string | undefined) ?? DEV_CLIENT_ID); @@ -125,8 +125,8 @@ const errorMessage = (cause: unknown) => { const message = cause instanceof Error ? cause.message : String(cause); - return message.toLowerCase().includes('unknown state') - ? 'Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost.' + return message.toLowerCase().includes("unknown state") + ? "Could not resume OAuth state. In local development, start login from http://127.0.0.1:5173 instead of localhost." : message; }; @@ -135,9 +135,9 @@ bobbinUrl: string ): Promise => { try { - const url = new URL('/xrpc/com.bad-example.identity.resolveMiniDoc', bobbinUrl); - url.searchParams.set('identifier', identifier); - const response = await fetch(url, { headers: { accept: 'application/json' } }); + const url = new URL("/xrpc/com.bad-example.identity.resolveMiniDoc", bobbinUrl); + url.searchParams.set("identifier", identifier); + const response = await fetch(url, { headers: { accept: "application/json" } }); if (response.ok) { const profile = (await response.json()) as MiniDoc; return { @@ -162,11 +162,11 @@ }; const returnToFromState = (state: object | null): string => { - if (state && typeof state === 'object' && 'returnTo' in state) { + if (state && typeof state === "object" && "returnTo" in state) { const returnTo = state.returnTo; - if (typeof returnTo === 'string') return returnTo; + if (typeof returnTo === "string") return returnTo; } - return '/'; + return "/"; }; export const createAuth = ( @@ -255,22 +255,22 @@ resetLoggedOut(); }; - const signIn = async (identifier: string, returnTo = '/') => { + const signIn = async (identifier: string, returnTo = "/") => { if (!browser) return; configure(); error = null; const trimmed = identifier.trim(); if (!trimmed) { - error = 'Handle or DID required'; + error = "Handle or DID required"; return; } - if (location.hostname === 'localhost') { + if (location.hostname === "localhost") { const url = new SvelteURL(location.href); - url.hostname = '127.0.0.1'; - url.searchParams.set('identifier', trimmed); - url.searchParams.set('return_url', returnTo); + url.hostname = "127.0.0.1"; + url.searchParams.set("identifier", trimmed); + url.searchParams.set("return_url", returnTo); location.replace(url); return; } @@ -278,7 +278,7 @@ try { const url = await createAuthorizationUrl({ target: { - type: 'account', + type: "account", identifier: trimmed as ActorIdentifier }, scope: OAUTH_SCOPE, @@ -293,14 +293,14 @@ }; const completeSignIn = async () => { - if (!browser) return '/'; + if (!browser) return "/"; configure(); error = null; authenticating = true; try { const params = new SvelteURLSearchParams(location.hash.slice(1)); - history.replaceState(null, '', location.pathname + location.search); + history.replaceState(null, "", location.pathname + location.search); const { session, state } = await finalizeAuthorization(params); adoptSession(session); diff --git a/web/src/lib/format.ts b/web/src/lib/format.ts --- a/web/src/lib/format.ts +++ b/web/src/lib/format.ts @@ -1,33 +1,33 @@ const DIVISIONS: { amount: number; unit: Intl.RelativeTimeFormatUnit }[] = [ - { amount: 60, unit: 'seconds' }, - { amount: 60, unit: 'minutes' }, - { amount: 24, unit: 'hours' }, - { amount: 7, unit: 'days' }, - { amount: 4.34524, unit: 'weeks' }, - { amount: 12, unit: 'months' }, - { amount: Number.POSITIVE_INFINITY, unit: 'years' } + { amount: 60, unit: "seconds" }, + { amount: 60, unit: "minutes" }, + { amount: 24, unit: "hours" }, + { amount: 7, unit: "days" }, + { amount: 4.34524, unit: "weeks" }, + { amount: 12, unit: "months" }, + { amount: Number.POSITIVE_INFINITY, unit: "years" } ]; -const rtf = new Intl.RelativeTimeFormat('en', { numeric: 'auto' }); -const dtf = new Intl.DateTimeFormat('en', { year: 'numeric', month: 'short', day: 'numeric' }); +const rtf = new Intl.RelativeTimeFormat("en", { numeric: "auto" }); +const dtf = new Intl.DateTimeFormat("en", { year: "numeric", month: "short", day: "numeric" }); // "3 days ago", "in 2 hours", etc. export const relativeTime = (input: string | Date, now: Date = new Date()): string => { - const date = typeof input === 'string' ? new Date(input) : input; - if (Number.isNaN(date.getTime())) return ''; + const date = typeof input === "string" ? new Date(input) : input; + if (Number.isNaN(date.getTime())) return ""; let duration = (date.getTime() - now.getTime()) / 1000; for (const division of DIVISIONS) { if (Math.abs(duration) < division.amount) return rtf.format(Math.round(duration), division.unit); duration /= division.amount; } - return rtf.format(Math.round(duration), 'years'); + return rtf.format(Math.round(duration), "years"); }; export const compactRelativeTime = (input: string | Date, now: Date = new Date()): string => { - const date = typeof input === 'string' ? new Date(input) : input; - if (Number.isNaN(date.getTime())) return ''; + const date = typeof input === "string" ? new Date(input) : input; + if (Number.isNaN(date.getTime())) return ""; let duration = Math.abs((now.getTime() - date.getTime()) / 1000); - const suffix = date.getTime() > now.getTime() ? 'from now' : 'ago'; + const suffix = date.getTime() > now.getTime() ? "from now" : "ago"; if (duration < 60) return `${Math.floor(duration)}s ${suffix}`; duration /= 60; @@ -45,6 +45,6 @@ }; export const formatDate = (input: string | Date): string => { - const date = typeof input === 'string' ? new Date(input) : input; - return Number.isNaN(date.getTime()) ? '' : dtf.format(date); + const date = typeof input === "string" ? new Date(input) : input; + return Number.isNaN(date.getTime()) ? "" : dtf.format(date); }; diff --git a/web/src/routes/+layout.server.ts b/web/src/routes/+layout.server.ts --- a/web/src/routes/+layout.server.ts +++ b/web/src/routes/+layout.server.ts @@ -1,9 +1,9 @@ -import { getPublicConfig } from '$lib/server/config'; -import type { LayoutServerLoad } from './$types'; +import { getPublicConfig } from "$lib/server/config"; +import type { LayoutServerLoad } from "./$types"; export const load: LayoutServerLoad = ({ cookies }) => { - const did = cookies.get('tangled.currentDid'); - const handle = cookies.get('tangled.currentHandle'); + const did = cookies.get("tangled.currentDid"); + const handle = cookies.get("tangled.currentHandle"); return { publicConfig: getPublicConfig(), diff --git a/web/src/routes/+page.svelte b/web/src/routes/+page.svelte --- a/web/src/routes/+page.svelte +++ b/web/src/routes/+page.svelte @@ -1,6 +1,6 @@ diff --git a/web/src/lib/api/_request.ts b/web/src/lib/api/_request.ts --- a/web/src/lib/api/_request.ts +++ b/web/src/lib/api/_request.ts @@ -1,5 +1,5 @@ -import { ClientResponseError, isXRPCErrorPayload, type XRPCErrorPayload } from '@atcute/client'; -import type { BobbinContext, QueryValue, XrpcRequestInit } from './client'; +import { ClientResponseError, isXRPCErrorPayload, type XRPCErrorPayload } from "@atcute/client"; +import type { BobbinContext, QueryValue, XrpcRequestInit } from "./client"; export const buildUrl = ( origin: string, @@ -21,7 +21,7 @@ }; export const toResponseError = async (response: Response): Promise => { - let data: XRPCErrorPayload = { error: 'XRPCError', message: response.statusText }; + let data: XRPCErrorPayload = { error: "XRPCError", message: response.statusText }; try { const body: unknown = await response.json(); if (isXRPCErrorPayload(body)) data = body; @@ -38,7 +38,7 @@ init?: XrpcRequestInit ): Promise => { const response = await ctx.fetch(buildUrl(ctx.serviceUrl, nsid, params), { - headers: { accept: 'application/json', ...init?.headers }, + headers: { accept: "application/json", ...init?.headers }, signal: init?.signal }); if (!response.ok) throw await toResponseError(response); diff --git a/web/src/lib/api/client.test.ts b/web/src/lib/api/client.test.ts --- a/web/src/lib/api/client.test.ts +++ b/web/src/lib/api/client.test.ts @@ -1,106 +1,106 @@ -import { describe, expect, it, vi, type Mock } from 'vitest'; -import { ClientResponseError, createBobbinClient, type BobbinContext } from './client'; -import { jsonGet, rawGet } from './_request'; +import { describe, expect, it, vi, type Mock } from "vitest"; +import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; +import { jsonGet, rawGet } from "./_request"; const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { status: 200, - headers: { 'content-type': 'application/json' }, + headers: { "content-type": "application/json" }, ...init }); const makeCtx = ( fetchMock: typeof globalThis.fetch, - serviceUrl = 'https://bobbin.test' + serviceUrl = "https://bobbin.test" ): BobbinContext => createBobbinClient({ serviceUrl, fetch: fetchMock }); const fetchedUrl = (mock: Mock, n = 0): URL => new URL(String(mock.mock.calls[n][0])); -describe('createBobbinClient', () => { - it('normalizes trailing slashes off the service url', () => { - const ctx = makeCtx(vi.fn(), 'https://bobbin.test///'); - expect(ctx.serviceUrl).toBe('https://bobbin.test'); +describe("createBobbinClient", () => { + it("normalizes trailing slashes off the service url", () => { + const ctx = makeCtx(vi.fn(), "https://bobbin.test///"); + expect(ctx.serviceUrl).toBe("https://bobbin.test"); }); }); -describe('jsonGet URL construction', () => { - it('appends an array param as repeated keys', async () => { +describe("jsonGet URL construction", () => { + it("appends an array param as repeated keys", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); - await jsonGet(makeCtx(fetchMock), 'sh.tangled.repo.getRepos', { repos: ['a', 'b'] }); + await jsonGet(makeCtx(fetchMock), "sh.tangled.repo.getRepos", { repos: ["a", "b"] }); const url = fetchedUrl(fetchMock); - expect(url.searchParams.getAll('repos')).toEqual(['a', 'b']); - expect(url.search).toBe('?repos=a&repos=b'); + expect(url.searchParams.getAll("repos")).toEqual(["a", "b"]); + expect(url.search).toBe("?repos=a&repos=b"); }); - it('omits undefined params entirely', async () => { + it("omits undefined params entirely", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); - await jsonGet(makeCtx(fetchMock), 'sh.tangled.x', { keep: 'yes', drop: undefined }); + await jsonGet(makeCtx(fetchMock), "sh.tangled.x", { keep: "yes", drop: undefined }); const url = fetchedUrl(fetchMock); - expect(url.searchParams.has('drop')).toBe(false); - expect(url.searchParams.get('keep')).toBe('yes'); + expect(url.searchParams.has("drop")).toBe(false); + expect(url.searchParams.get("keep")).toBe("yes"); }); - it('resolves the query against the normalized origin', async () => { + it("resolves the query against the normalized origin", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); - await jsonGet(makeCtx(fetchMock, 'https://bobbin.test/'), 'sh.tangled.x', { a: '1' }); - expect(fetchedUrl(fetchMock).href).toBe('https://bobbin.test/xrpc/sh.tangled.x?a=1'); + await jsonGet(makeCtx(fetchMock, "https://bobbin.test/"), "sh.tangled.x", { a: "1" }); + expect(fetchedUrl(fetchMock).href).toBe("https://bobbin.test/xrpc/sh.tangled.x?a=1"); }); }); -describe('jsonGet request headers & signal', () => { - it('sends accept: application/json and merges caller headers + signal', async () => { +describe("jsonGet request headers & signal", () => { + it("sends accept: application/json and merges caller headers + signal", async () => { const fetchMock = vi.fn().mockResolvedValue(jsonResponse({ ok: 1 })); const controller = new AbortController(); - await jsonGet(makeCtx(fetchMock), 'sh.tangled.x', undefined, { - headers: { 'x-custom': '1' }, + await jsonGet(makeCtx(fetchMock), "sh.tangled.x", undefined, { + headers: { "x-custom": "1" }, signal: controller.signal }); const init = fetchMock.mock.calls[0][1] as RequestInit; - expect(init.headers).toMatchObject({ accept: 'application/json', 'x-custom': '1' }); + expect(init.headers).toMatchObject({ accept: "application/json", "x-custom": "1" }); expect(init.signal).toBe(controller.signal); }); }); -describe('jsonGet responses', () => { - it('throws ClientResponseError carrying status + error/description for a JSON error body', async () => { +describe("jsonGet responses", () => { + it("throws ClientResponseError carrying status + error/description for a JSON error body", async () => { const fetchMock = vi .fn() .mockResolvedValue( - jsonResponse({ error: 'RecordNotFound', message: 'no such repo' }, { status: 404 }) + jsonResponse({ error: "RecordNotFound", message: "no such repo" }, { status: 404 }) ); - const err = await jsonGet(makeCtx(fetchMock), 'sh.tangled.x').catch((e: unknown) => e); + const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e); expect(err).toBeInstanceOf(ClientResponseError); const cre = err as ClientResponseError; expect(cre.status).toBe(404); - expect(cre.error).toBe('RecordNotFound'); - expect(cre.description).toBe('no such repo'); + expect(cre.error).toBe("RecordNotFound"); + expect(cre.description).toBe("no such repo"); }); - it('falls back to the status line for a non-JSON error body (does not hang)', async () => { + it("falls back to the status line for a non-JSON error body (does not hang)", async () => { const fetchMock = vi.fn().mockResolvedValue( - new Response('502 Bad Gateway', { + new Response("502 Bad Gateway", { status: 502, - statusText: 'Bad Gateway' + statusText: "Bad Gateway" }) ); - const err = await jsonGet(makeCtx(fetchMock), 'sh.tangled.x').catch((e: unknown) => e); + const err = await jsonGet(makeCtx(fetchMock), "sh.tangled.x").catch((e: unknown) => e); expect(err).toBeInstanceOf(ClientResponseError); const cre = err as ClientResponseError; expect(cre.status).toBe(502); - expect(cre.error).toBe('XRPCError'); - expect(cre.description).toBe('Bad Gateway'); + expect(cre.error).toBe("XRPCError"); + expect(cre.description).toBe("Bad Gateway"); }); }); -describe('rawGet', () => { - it('throws ClientResponseError on a non-2xx status', async () => { +describe("rawGet", () => { + it("throws ClientResponseError on a non-2xx status", async () => { const fetchMock = vi .fn() .mockResolvedValue( - jsonResponse({ error: 'UpstreamFailed', message: 'knot down' }, { status: 502 }) + jsonResponse({ error: "UpstreamFailed", message: "knot down" }, { status: 502 }) ); - const err = await rawGet(makeCtx(fetchMock), 'sh.tangled.repo.archive').catch( + const err = await rawGet(makeCtx(fetchMock), "sh.tangled.repo.archive").catch( (e: unknown) => e ); expect(err).toBeInstanceOf(ClientResponseError); diff --git a/web/src/lib/api/client.ts b/web/src/lib/api/client.ts --- a/web/src/lib/api/client.ts +++ b/web/src/lib/api/client.ts @@ -1,7 +1,7 @@ -import { Client, simpleFetchHandler } from '@atcute/client'; +import { Client, simpleFetchHandler } from "@atcute/client"; -export { ClientResponseError, isXRPCErrorPayload, ok } from '@atcute/client'; -export type { XRPCErrorPayload } from '@atcute/client'; +export { ClientResponseError, isXRPCErrorPayload, ok } from "@atcute/client"; +export type { XRPCErrorPayload } from "@atcute/client"; // generated lexicons register ambient types; don't import the barrel at runtime. @@ -17,7 +17,7 @@ } export const createBobbinClient = ({ serviceUrl, fetch }: CreateBobbinOptions): BobbinContext => { - const origin = serviceUrl.replace(/\/+$/, ''); + const origin = serviceUrl.replace(/\/+$/, ""); const boundFetch = fetch ?? globalThis.fetch; const xrpc = new Client({ handler: simpleFetchHandler({ service: origin, fetch: boundFetch }) }); return { xrpc, serviceUrl: origin, fetch: boundFetch }; diff --git a/web/src/lib/api/count.ts b/web/src/lib/api/count.ts --- a/web/src/lib/api/count.ts +++ b/web/src/lib/api/count.ts @@ -1,48 +1,48 @@ -import type { BobbinContext, XrpcRequestInit } from './client'; -import { jsonGet } from './_request'; +import type { BobbinContext, XrpcRequestInit } from "./client"; +import { jsonGet } from "./_request"; // count endpoints share { subject } params and { count, distinctAuthors } output. export type CountName = - | 'sh.tangled.feed.countStars' - | 'sh.tangled.feed.countStarsBy' - | 'sh.tangled.feed.countComments' - | 'sh.tangled.feed.countCommentsBy' - | 'sh.tangled.feed.countReactions' - | 'sh.tangled.feed.countReactionsBy' - | 'sh.tangled.graph.countFollows' - | 'sh.tangled.graph.countFollowsBy' - | 'sh.tangled.graph.countVouches' - | 'sh.tangled.graph.countVouchesBy' - | 'sh.tangled.git.countRefUpdates' - | 'sh.tangled.git.countRefUpdatesBy' - | 'sh.tangled.knot.countKnots' - | 'sh.tangled.knot.countMembers' - | 'sh.tangled.knot.countMembersBy' - | 'sh.tangled.label.countDefinitions' - | 'sh.tangled.label.countOps' - | 'sh.tangled.label.countOpsBy' - | 'sh.tangled.pipeline.countPipelines' - | 'sh.tangled.pipeline.countPipelinesBy' - | 'sh.tangled.pipeline.countStatuses' - | 'sh.tangled.pipeline.countStatusesBy' - | 'sh.tangled.publicKey.countKeys' - | 'sh.tangled.repo.countArtifacts' - | 'sh.tangled.repo.countArtifactsBy' - | 'sh.tangled.repo.countCollaborators' - | 'sh.tangled.repo.countCollaboratorsBy' - | 'sh.tangled.repo.countIssues' - | 'sh.tangled.repo.countIssuesBy' - | 'sh.tangled.repo.countPulls' - | 'sh.tangled.repo.countPullsBy' - | 'sh.tangled.repo.countRepos' - | 'sh.tangled.repo.issue.countStates' - | 'sh.tangled.repo.issue.countStatesBy' - | 'sh.tangled.repo.pull.countStatuses' - | 'sh.tangled.repo.pull.countStatusesBy' - | 'sh.tangled.spindle.countSpindles' - | 'sh.tangled.spindle.countMembers' - | 'sh.tangled.spindle.countMembersBy' - | 'sh.tangled.string.countStrings'; + | "sh.tangled.feed.countStars" + | "sh.tangled.feed.countStarsBy" + | "sh.tangled.feed.countComments" + | "sh.tangled.feed.countCommentsBy" + | "sh.tangled.feed.countReactions" + | "sh.tangled.feed.countReactionsBy" + | "sh.tangled.graph.countFollows" + | "sh.tangled.graph.countFollowsBy" + | "sh.tangled.graph.countVouches" + | "sh.tangled.graph.countVouchesBy" + | "sh.tangled.git.countRefUpdates" + | "sh.tangled.git.countRefUpdatesBy" + | "sh.tangled.knot.countKnots" + | "sh.tangled.knot.countMembers" + | "sh.tangled.knot.countMembersBy" + | "sh.tangled.label.countDefinitions" + | "sh.tangled.label.countOps" + | "sh.tangled.label.countOpsBy" + | "sh.tangled.pipeline.countPipelines" + | "sh.tangled.pipeline.countPipelinesBy" + | "sh.tangled.pipeline.countStatuses" + | "sh.tangled.pipeline.countStatusesBy" + | "sh.tangled.publicKey.countKeys" + | "sh.tangled.repo.countArtifacts" + | "sh.tangled.repo.countArtifactsBy" + | "sh.tangled.repo.countCollaborators" + | "sh.tangled.repo.countCollaboratorsBy" + | "sh.tangled.repo.countIssues" + | "sh.tangled.repo.countIssuesBy" + | "sh.tangled.repo.countPulls" + | "sh.tangled.repo.countPullsBy" + | "sh.tangled.repo.countRepos" + | "sh.tangled.repo.issue.countStates" + | "sh.tangled.repo.issue.countStatesBy" + | "sh.tangled.repo.pull.countStatuses" + | "sh.tangled.repo.pull.countStatusesBy" + | "sh.tangled.spindle.countSpindles" + | "sh.tangled.spindle.countMembers" + | "sh.tangled.spindle.countMembersBy" + | "sh.tangled.string.countStrings"; export interface CountResult { count: number; diff --git a/web/src/lib/api/coverage.ts b/web/src/lib/api/coverage.ts --- a/web/src/lib/api/coverage.ts +++ b/web/src/lib/api/coverage.ts @@ -1,5 +1,5 @@ -import type { BobbinContext, XrpcRequestInit } from './client'; -import { jsonGet } from './_request'; +import type { BobbinContext, XrpcRequestInit } from "./client"; +import { jsonGet } from "./_request"; export interface Coverage { ready: boolean; @@ -8,4 +8,4 @@ } export const getCoverage = (ctx: BobbinContext, init?: XrpcRequestInit): Promise => - jsonGet(ctx, 'sh.tangled.bobbin.getCoverage', undefined, init); + jsonGet(ctx, "sh.tangled.bobbin.getCoverage", undefined, init); diff --git a/web/src/lib/api/graph.ts b/web/src/lib/api/graph.ts --- a/web/src/lib/api/graph.ts +++ b/web/src/lib/api/graph.ts @@ -1,22 +1,22 @@ -import { ok } from '@atcute/client'; -import { mainSchema as createRecordSchema } from '@atcute/atproto/types/repo/createRecord'; -import { mainSchema as deleteRecordSchema } from '@atcute/atproto/types/repo/deleteRecord'; -import type { Did, Nsid, RecordKey } from '@atcute/lexicons/syntax'; -import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; -import { createClient } from '$lib/auth/agent'; -import type { BobbinContext } from './client'; -import { items } from './pagination'; -import { rkeyFromUri } from './uri'; -import type * as ShTangledGraphFollow from './lexicons/types/sh/tangled/graph/follow'; -import type * as ShTangledGraphVouch from './lexicons/types/sh/tangled/graph/vouch'; -import type * as ShTangledFeedStar from './lexicons/types/sh/tangled/feed/star'; +import { ok } from "@atcute/client"; +import { mainSchema as createRecordSchema } from "@atcute/atproto/types/repo/createRecord"; +import { mainSchema as deleteRecordSchema } from "@atcute/atproto/types/repo/deleteRecord"; +import type { Did, Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { createClient } from "$lib/auth/agent"; +import type { BobbinContext } from "./client"; +import { items } from "./pagination"; +import { rkeyFromUri } from "./uri"; +import type * as ShTangledGraphFollow from "./lexicons/types/sh/tangled/graph/follow"; +import type * as ShTangledGraphVouch from "./lexicons/types/sh/tangled/graph/vouch"; +import type * as ShTangledFeedStar from "./lexicons/types/sh/tangled/feed/star"; export type FollowRecord = ShTangledGraphFollow.Main; export type VouchRecord = ShTangledGraphVouch.Main; export type StarRecord = ShTangledFeedStar.Main; -const FOLLOW_COLLECTION = 'sh.tangled.graph.follow' as Nsid; -const STAR_COLLECTION = 'sh.tangled.feed.star' as Nsid; +const FOLLOW_COLLECTION = "sh.tangled.graph.follow" as Nsid; +const STAR_COLLECTION = "sh.tangled.feed.star" as Nsid; // TODO(bobbin): needs a relation point-lookup (e.g. graph.getFollow?actor=&subject=) // or a `viewer` hydration param on lists; scanning listFollowsBy pages is O(follows). @@ -28,7 +28,7 @@ ): Promise => { for await (const item of items( ctx, - 'sh.tangled.graph.listFollowsBy', + "sh.tangled.graph.listFollowsBy", { subject: viewerDid as Did }, { maxPages: options.maxPages ?? 10 } )) { @@ -66,7 +66,7 @@ export const createFollow = (agent: OAuthUserAgent, subject: string): Promise => createGenericRecord(agent, FOLLOW_COLLECTION, { - $type: 'sh.tangled.graph.follow', + $type: "sh.tangled.graph.follow", subject: subject as Did, createdAt: new Date().toISOString() }); @@ -76,9 +76,9 @@ export const createStar = (agent: OAuthUserAgent, repoDid: string): Promise => createGenericRecord(agent, STAR_COLLECTION, { - $type: 'sh.tangled.feed.star', + $type: "sh.tangled.feed.star", subject: { - $type: 'sh.tangled.feed.star#repo', + $type: "sh.tangled.feed.star#repo", did: repoDid as Did }, createdAt: new Date().toISOString() @@ -97,12 +97,12 @@ const rkeys = new Map(); for await (const item of items( ctx, - 'sh.tangled.feed.listStarsBy', + "sh.tangled.feed.listStarsBy", { subject: viewerDid as Did }, { maxPages: options.maxPages ?? 10 } )) { const value = item.value as StarRecord; - if (value.subject.$type === 'sh.tangled.feed.star#repo') { + if (value.subject.$type === "sh.tangled.feed.star#repo") { rkeys.set(value.subject.did, rkeyFromUri(item.uri)); } } diff --git a/web/src/lib/api/identity.test.ts b/web/src/lib/api/identity.test.ts --- a/web/src/lib/api/identity.test.ts +++ b/web/src/lib/api/identity.test.ts @@ -1,17 +1,17 @@ -import { describe, expect, it, vi } from 'vitest'; -import { createBobbinClient, type BobbinContext } from './client'; -import { IdentityCache, type MiniDoc } from './identity'; +import { describe, expect, it, vi } from "vitest"; +import { createBobbinClient, type BobbinContext } from "./client"; +import { IdentityCache, type MiniDoc } from "./identity"; -const DOC: MiniDoc = { did: 'did:plc:x', handle: 'alice.test' }; +const DOC: MiniDoc = { did: "did:plc:x", handle: "alice.test" }; const docResponse = (doc: MiniDoc): Response => new Response(JSON.stringify(doc), { status: 200, - headers: { 'content-type': 'application/json' } + headers: { "content-type": "application/json" } }); const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => - createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); + createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const deferred = (): { promise: Promise; resolve: (value: T) => void } => { let resolve!: (value: T) => void; @@ -21,27 +21,27 @@ return { promise, resolve }; }; -describe('IdentityCache.resolve', () => { - it('fetches on the first miss then serves the cached document', async () => { +describe("IdentityCache.resolve", () => { + it("fetches on the first miss then serves the cached document", async () => { const fetchMock = vi.fn().mockResolvedValue(docResponse(DOC)); const cache = new IdentityCache(makeCtx(fetchMock)); - const first = await cache.resolve('alice.test'); - const second = await cache.resolve('alice.test'); + const first = await cache.resolve("alice.test"); + const second = await cache.resolve("alice.test"); expect(first).toEqual(DOC); expect(second).toEqual(DOC); expect(fetchMock).toHaveBeenCalledTimes(1); - expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('alice.test'); + expect((fetchMock.mock.calls[0][0] as URL).searchParams.get("identifier")).toBe("alice.test"); }); - it('coalesces concurrent misses for the same key into one in-flight fetch', async () => { + it("coalesces concurrent misses for the same key into one in-flight fetch", async () => { const gate = deferred(); const fetchMock = vi.fn().mockReturnValue(gate.promise); const cache = new IdentityCache(makeCtx(fetchMock)); - const a = cache.resolve('alice.test'); - const b = cache.resolve('alice.test'); + const a = cache.resolve("alice.test"); + const b = cache.resolve("alice.test"); expect(fetchMock).toHaveBeenCalledTimes(1); gate.resolve(docResponse(DOC)); @@ -51,50 +51,50 @@ expect(fetchMock).toHaveBeenCalledTimes(1); }); - it('re-fetches after the in-flight promise settles (no permanent stampede lock)', async () => { + it("re-fetches after the in-flight promise settles (no permanent stampede lock)", async () => { const fetchMock = vi.fn().mockResolvedValue(docResponse(DOC)); const cache = new IdentityCache(makeCtx(fetchMock)); - await cache.resolve('bob.test'); - fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:y', handle: 'carol.test' })); - await cache.resolve('carol.test'); + await cache.resolve("bob.test"); + fetchMock.mockResolvedValueOnce(docResponse({ did: "did:plc:y", handle: "carol.test" })); + await cache.resolve("carol.test"); expect(fetchMock).toHaveBeenCalledTimes(2); }); - it('populates both directions of the index from a resolved document', async () => { + it("populates both directions of the index from a resolved document", async () => { const fetchMock = vi.fn().mockResolvedValue(docResponse(DOC)); const cache = new IdentityCache(makeCtx(fetchMock)); - await cache.resolve('alice.test'); - expect(cache.didFor('alice.test')).toBe('did:plc:x'); - expect(cache.handleFor('did:plc:x')).toBe('alice.test'); + await cache.resolve("alice.test"); + expect(cache.didFor("alice.test")).toBe("did:plc:x"); + expect(cache.handleFor("did:plc:x")).toBe("alice.test"); }); }); -describe('IdentityCache.prime & map selection', () => { - it('seeds both directions without any fetch', () => { +describe("IdentityCache.prime & map selection", () => { + it("seeds both directions without any fetch", () => { const fetchMock = vi.fn(); const cache = new IdentityCache(makeCtx(fetchMock)); cache.prime(DOC); - expect(cache.didFor('alice.test')).toBe('did:plc:x'); - expect(cache.handleFor('did:plc:x')).toBe('alice.test'); + expect(cache.didFor("alice.test")).toBe("did:plc:x"); + expect(cache.handleFor("did:plc:x")).toBe("alice.test"); expect(fetchMock).not.toHaveBeenCalled(); }); - it('resolves a DID against the did map and a handle against the handle map', async () => { + it("resolves a DID against the did map and a handle against the handle map", async () => { const fetchMock = vi.fn(); const cache = new IdentityCache(makeCtx(fetchMock)); cache.prime(DOC); - await expect(cache.resolve('did:plc:x')).resolves.toEqual(DOC); - await expect(cache.resolve('alice.test')).resolves.toEqual(DOC); + await expect(cache.resolve("did:plc:x")).resolves.toEqual(DOC); + await expect(cache.resolve("alice.test")).resolves.toEqual(DOC); expect(fetchMock).not.toHaveBeenCalled(); // unknown dids miss the handle map and fetch. - fetchMock.mockResolvedValueOnce(docResponse({ did: 'did:plc:z', handle: 'dan.test' })); - await cache.resolve('did:plc:z'); + fetchMock.mockResolvedValueOnce(docResponse({ did: "did:plc:z", handle: "dan.test" })); + await cache.resolve("did:plc:z"); expect(fetchMock).toHaveBeenCalledTimes(1); - expect((fetchMock.mock.calls[0][0] as URL).searchParams.get('identifier')).toBe('did:plc:z'); + expect((fetchMock.mock.calls[0][0] as URL).searchParams.get("identifier")).toBe("did:plc:z"); }); }); diff --git a/web/src/lib/api/identity.ts b/web/src/lib/api/identity.ts --- a/web/src/lib/api/identity.ts +++ b/web/src/lib/api/identity.ts @@ -1,5 +1,5 @@ -import type { BobbinContext, XrpcRequestInit } from './client'; -import { jsonGet } from './_request'; +import type { BobbinContext, XrpcRequestInit } from "./client"; +import { jsonGet } from "./_request"; export interface MiniDoc { did: string; @@ -13,7 +13,7 @@ identifier: string, init?: XrpcRequestInit ): Promise => - jsonGet(ctx, 'com.bad-example.identity.resolveMiniDoc', { identifier }, init); + jsonGet(ctx, "com.bad-example.identity.resolveMiniDoc", { identifier }, init); // did/handle cache with in-flight de-dupe. export class IdentityCache { @@ -40,7 +40,7 @@ } resolve(identifier: string, init?: XrpcRequestInit): Promise { - const cached = identifier.startsWith('did:') + const cached = identifier.startsWith("did:") ? this.#byDid.get(identifier) : this.#byHandle.get(identifier); if (cached) return Promise.resolve(cached); diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -1,11 +1,11 @@ -export * from './client'; -export * from './pagination'; -export * from './count'; -export * from './records'; -export * as knot from './knot'; -export * from './search'; -export * from './coverage'; -export * from './identity'; -export * from './load'; -export * from './uri'; -export * from './graph'; +export * from "./client"; +export * from "./pagination"; +export * from "./count"; +export * from "./records"; +export * as knot from "./knot"; +export * from "./search"; +export * from "./coverage"; +export * from "./identity"; +export * from "./load"; +export * from "./uri"; +export * from "./graph"; diff --git a/web/src/lib/api/knot.test.ts b/web/src/lib/api/knot.test.ts --- a/web/src/lib/api/knot.test.ts +++ b/web/src/lib/api/knot.test.ts @@ -1,24 +1,24 @@ -import { describe, expect, it, vi } from 'vitest'; -import * as knot from './knot'; -import { ClientResponseError, createBobbinClient, type BobbinContext } from './client'; +import { describe, expect, it, vi } from "vitest"; +import * as knot from "./knot"; +import { ClientResponseError, createBobbinClient, type BobbinContext } from "./client"; const jsonResponse = (body: unknown, init: ResponseInit = {}): Response => new Response(JSON.stringify(body), { status: 200, - headers: { 'content-type': 'application/json' }, + headers: { "content-type": "application/json" }, ...init }); const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => - createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); + createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); -describe('knot.archive (binary passthrough)', () => { - it('throws ClientResponseError on a non-2xx archive response', async () => { +describe("knot.archive (binary passthrough)", () => { + it("throws ClientResponseError on a non-2xx archive response", async () => { const fetchMock = vi .fn() - .mockResolvedValue(jsonResponse({ error: 'UpstreamGone' }, { status: 502 })); + .mockResolvedValue(jsonResponse({ error: "UpstreamGone" }, { status: 502 })); const err = await knot - .archive(makeCtx(fetchMock), { repo: 'did:plc:x/r', ref: 'main' }) + .archive(makeCtx(fetchMock), { repo: "did:plc:x/r", ref: "main" }) .catch((e: unknown) => e); expect(err).toBeInstanceOf(ClientResponseError); expect((err as ClientResponseError).status).toBe(502); diff --git a/web/src/lib/api/knot.ts b/web/src/lib/api/knot.ts --- a/web/src/lib/api/knot.ts +++ b/web/src/lib/api/knot.ts @@ -1,19 +1,19 @@ -import type { BobbinContext, QueryValue, XrpcRequestInit } from './client'; -import { jsonGet, rawGet } from './_request'; -import type * as Archive from './lexicons/types/sh/tangled/repo/archive'; -import type * as Blob_ from './lexicons/types/sh/tangled/repo/blob'; -import type * as Branch from './lexicons/types/sh/tangled/repo/branch'; -import type * as Branches from './lexicons/types/sh/tangled/repo/branches'; -import type * as Compare from './lexicons/types/sh/tangled/repo/compare'; -import type * as DescribeRepo from './lexicons/types/sh/tangled/repo/describeRepo'; -import type * as Diff from './lexicons/types/sh/tangled/repo/diff'; -import type * as GetDefaultBranch from './lexicons/types/sh/tangled/repo/getDefaultBranch'; -import type * as Languages from './lexicons/types/sh/tangled/repo/languages'; -import type * as ListSecrets from './lexicons/types/sh/tangled/repo/listSecrets'; -import type * as Log from './lexicons/types/sh/tangled/repo/log'; -import type * as Tag from './lexicons/types/sh/tangled/repo/tag'; -import type * as Tags from './lexicons/types/sh/tangled/repo/tags'; -import type * as Tree from './lexicons/types/sh/tangled/repo/tree'; +import type { BobbinContext, QueryValue, XrpcRequestInit } from "./client"; +import { jsonGet, rawGet } from "./_request"; +import type * as Archive from "./lexicons/types/sh/tangled/repo/archive"; +import type * as Blob_ from "./lexicons/types/sh/tangled/repo/blob"; +import type * as Branch from "./lexicons/types/sh/tangled/repo/branch"; +import type * as Branches from "./lexicons/types/sh/tangled/repo/branches"; +import type * as Compare from "./lexicons/types/sh/tangled/repo/compare"; +import type * as DescribeRepo from "./lexicons/types/sh/tangled/repo/describeRepo"; +import type * as Diff from "./lexicons/types/sh/tangled/repo/diff"; +import type * as GetDefaultBranch from "./lexicons/types/sh/tangled/repo/getDefaultBranch"; +import type * as Languages from "./lexicons/types/sh/tangled/repo/languages"; +import type * as ListSecrets from "./lexicons/types/sh/tangled/repo/listSecrets"; +import type * as Log from "./lexicons/types/sh/tangled/repo/log"; +import type * as Tag from "./lexicons/types/sh/tangled/repo/tag"; +import type * as Tags from "./lexicons/types/sh/tangled/repo/tags"; +import type * as Tree from "./lexicons/types/sh/tangled/repo/tree"; // wrappers for bobbin's knot-proxied repo endpoints. @@ -21,13 +21,13 @@ params as unknown as Record; export const tree = (ctx: BobbinContext, params: Tree.$params, init?: XrpcRequestInit) => - jsonGet(ctx, 'sh.tangled.repo.tree', asParams(params), init); + jsonGet(ctx, "sh.tangled.repo.tree", asParams(params), init); export const blob = (ctx: BobbinContext, params: Blob_.$params, init?: XrpcRequestInit) => - jsonGet(ctx, 'sh.tangled.repo.blob', asParams(params), init); + jsonGet(ctx, "sh.tangled.repo.blob", asParams(params), init); export const branch = (ctx: BobbinContext, params: Branch.$params, init?: XrpcRequestInit) => - jsonGet(ctx, 'sh.tangled.repo.branch', asParams(params), init); + jsonGet(ctx, "sh.tangled.repo.branch", asParams(params), init); export const getDefaultBranch = ( ctx: BobbinContext, @@ -36,7 +36,7 @@ ) => jsonGet( ctx, - 'sh.tangled.repo.getDefaultBranch', + "sh.tangled.repo.getDefaultBranch", asParams(params), init ); @@ -45,50 +45,50 @@ ctx: BobbinContext, params: DescribeRepo.$params, init?: XrpcRequestInit -) => jsonGet(ctx, 'sh.tangled.repo.describeRepo', asParams(params), init); +) => jsonGet(ctx, "sh.tangled.repo.describeRepo", asParams(params), init); export const languages = (ctx: BobbinContext, params: Languages.$params, init?: XrpcRequestInit) => - jsonGet(ctx, 'sh.tangled.repo.languages', asParams(params), init); + jsonGet(ctx, "sh.tangled.repo.languages", asParams(params), init); export const listSecrets = ( ctx: BobbinContext, params: ListSecrets.$params, init?: XrpcRequestInit -) => jsonGet(ctx, 'sh.tangled.repo.listSecrets', asParams(params), init); +) => jsonGet(ctx, "sh.tangled.repo.listSecrets", asParams(params), init); // schema-less json passthroughs; callers supply the shape. export const log = (ctx: BobbinContext, params: Log.$params, init?: XrpcRequestInit) => - jsonGet(ctx, 'sh.tangled.repo.log', asParams(params), init); + jsonGet(ctx, "sh.tangled.repo.log", asParams(params), init); export const diff = ( ctx: BobbinContext, params: Diff.$params, init?: XrpcRequestInit -) => jsonGet(ctx, 'sh.tangled.repo.diff', asParams(params), init); +) => jsonGet(ctx, "sh.tangled.repo.diff", asParams(params), init); export const compare = ( ctx: BobbinContext, params: Compare.$params, init?: XrpcRequestInit -) => jsonGet(ctx, 'sh.tangled.repo.compare', asParams(params), init); +) => jsonGet(ctx, "sh.tangled.repo.compare", asParams(params), init); export const branches = ( ctx: BobbinContext, params: Branches.$params, init?: XrpcRequestInit -) => jsonGet(ctx, 'sh.tangled.repo.branches', asParams(params), init); +) => jsonGet(ctx, "sh.tangled.repo.branches", asParams(params), init); export const tags = ( ctx: BobbinContext, params: Tags.$params, init?: XrpcRequestInit -) => jsonGet(ctx, 'sh.tangled.repo.tags', asParams(params), init); +) => jsonGet(ctx, "sh.tangled.repo.tags", asParams(params), init); export const tag = (ctx: BobbinContext, params: Tag.$params, init?: XrpcRequestInit) => - jsonGet(ctx, 'sh.tangled.repo.tag', asParams(params), init); + jsonGet(ctx, "sh.tangled.repo.tag", asParams(params), init); // raw response for streaming/download. export const archive = (ctx: BobbinContext, params: Archive.$params, init?: XrpcRequestInit) => - rawGet(ctx, 'sh.tangled.repo.archive', asParams(params), init); + rawGet(ctx, "sh.tangled.repo.archive", asParams(params), init); diff --git a/web/src/lib/api/load.test.ts b/web/src/lib/api/load.test.ts --- a/web/src/lib/api/load.test.ts +++ b/web/src/lib/api/load.test.ts @@ -1,121 +1,121 @@ -import { describe, expect, it, vi } from 'vitest'; -import { ClientResponseError } from './client'; -import { createRequestCache, httpStatusFor, parallel, toHttpError } from './load'; +import { describe, expect, it, vi } from "vitest"; +import { ClientResponseError } from "./client"; +import { createRequestCache, httpStatusFor, parallel, toHttpError } from "./load"; const cre = (status: number, error: string, message?: string): ClientResponseError => new ClientResponseError({ status, data: { error, message } }); -describe('httpStatusFor', () => { - it('passes through an in-band XRPC status', () => { - expect(httpStatusFor(cre(404, 'RecordNotFound'))).toBe(404); +describe("httpStatusFor", () => { + it("passes through an in-band XRPC status", () => { + expect(httpStatusFor(cre(404, "RecordNotFound"))).toBe(404); }); - it('prefers an in-band status over the error-name mapping', () => { + it("prefers an in-band status over the error-name mapping", () => { // status 503 is in [400,599] so it wins even though the name maps to 404. - expect(httpStatusFor(cre(503, 'RecordNotFound'))).toBe(503); + expect(httpStatusFor(cre(503, "RecordNotFound"))).toBe(503); }); - it('maps the error name when the status is out of the 400-599 band', () => { - expect(httpStatusFor(cre(200, 'RecordNotFound'))).toBe(404); - expect(httpStatusFor(cre(200, 'InvalidRequest'))).toBe(400); - expect(httpStatusFor(cre(200, 'UpstreamFailed'))).toBe(502); - expect(httpStatusFor(cre(200, 'UpstreamGone'))).toBe(502); - expect(httpStatusFor(cre(200, 'InvalidRecord'))).toBe(502); - expect(httpStatusFor(cre(200, 'Overloaded'))).toBe(503); - expect(httpStatusFor(cre(200, 'SomethingElse'))).toBe(500); + it("maps the error name when the status is out of the 400-599 band", () => { + expect(httpStatusFor(cre(200, "RecordNotFound"))).toBe(404); + expect(httpStatusFor(cre(200, "InvalidRequest"))).toBe(400); + expect(httpStatusFor(cre(200, "UpstreamFailed"))).toBe(502); + expect(httpStatusFor(cre(200, "UpstreamGone"))).toBe(502); + expect(httpStatusFor(cre(200, "InvalidRecord"))).toBe(502); + expect(httpStatusFor(cre(200, "Overloaded"))).toBe(503); + expect(httpStatusFor(cre(200, "SomethingElse"))).toBe(500); }); - it('maps any non-XRPC cause to 500', () => { - expect(httpStatusFor(new Error('boom'))).toBe(500); - expect(httpStatusFor('a string')).toBe(500); + it("maps any non-XRPC cause to 500", () => { + expect(httpStatusFor(new Error("boom"))).toBe(500); + expect(httpStatusFor("a string")).toBe(500); expect(httpStatusFor(undefined)).toBe(500); expect(httpStatusFor({ status: 404 })).toBe(500); }); }); -describe('toHttpError', () => { - it('throws a SvelteKit error with the mapped status and the XRPC description', () => { +describe("toHttpError", () => { + it("throws a SvelteKit error with the mapped status and the XRPC description", () => { let thrown: unknown; try { - toHttpError(cre(404, 'RecordNotFound', 'no such repo')); + toHttpError(cre(404, "RecordNotFound", "no such repo")); } catch (e) { thrown = e; } - expect(thrown).toMatchObject({ status: 404, body: { message: 'no such repo' } }); + expect(thrown).toMatchObject({ status: 404, body: { message: "no such repo" } }); }); - it('falls back to the error name when the description is absent', () => { + it("falls back to the error name when the description is absent", () => { let thrown: unknown; try { - toHttpError(cre(200, 'Overloaded')); + toHttpError(cre(200, "Overloaded")); } catch (e) { thrown = e; } - expect(thrown).toMatchObject({ status: 503, body: { message: 'Overloaded' } }); + expect(thrown).toMatchObject({ status: 503, body: { message: "Overloaded" } }); }); - it('uses the fallback message + 500 for a non-XRPC cause', () => { + it("uses the fallback message + 500 for a non-XRPC cause", () => { let thrown: unknown; try { - toHttpError(new Error('boom'), 'could not load'); + toHttpError(new Error("boom"), "could not load"); } catch (e) { thrown = e; } - expect(thrown).toMatchObject({ status: 500, body: { message: 'could not load' } }); + expect(thrown).toMatchObject({ status: 500, body: { message: "could not load" } }); }); }); -describe('parallel', () => { - it('preserves key/value pairing regardless of settle order', async () => { +describe("parallel", () => { + it("preserves key/value pairing regardless of settle order", async () => { // deferred across a few microtasks (no real timer) so it settles after `fast`. const slow = Promise.resolve() .then(() => Promise.resolve()) - .then(() => 'slow'); - const result = await parallel({ fast: Promise.resolve('fast'), slow }); - expect(result).toEqual({ fast: 'fast', slow: 'slow' }); + .then(() => "slow"); + const result = await parallel({ fast: Promise.resolve("fast"), slow }); + expect(result).toEqual({ fast: "fast", slow: "slow" }); }); - it('resolves to an empty object for no tasks', async () => { + it("resolves to an empty object for no tasks", async () => { await expect(parallel({})).resolves.toEqual({}); }); - it('rejects if any input promise rejects', async () => { + it("rejects if any input promise rejects", async () => { await expect( - parallel({ a: Promise.resolve(1), b: Promise.reject(new Error('boom')) }) - ).rejects.toThrow('boom'); + parallel({ a: Promise.resolve(1), b: Promise.reject(new Error("boom")) }) + ).rejects.toThrow("boom"); }); }); -describe('createRequestCache', () => { - it('invokes the loader once per key and shares the value', async () => { +describe("createRequestCache", () => { + it("invokes the loader once per key and shares the value", async () => { const cache = createRequestCache(); const load = vi.fn(async () => 42); - const first = await cache.run('k', load); - const second = await cache.run('k', load); + const first = await cache.run("k", load); + const second = await cache.run("k", load); expect(first).toBe(42); expect(second).toBe(42); expect(load).toHaveBeenCalledTimes(1); }); - it('shares one in-flight promise for concurrent same-key calls', () => { + it("shares one in-flight promise for concurrent same-key calls", () => { const cache = createRequestCache(); - const load = vi.fn(async () => 'v'); + const load = vi.fn(async () => "v"); - const a = cache.run('k', load); - const b = cache.run('k', load); + const a = cache.run("k", load); + const b = cache.run("k", load); expect(a).toBe(b); expect(load).toHaveBeenCalledTimes(1); }); - it('runs distinct keys independently', async () => { + it("runs distinct keys independently", async () => { const cache = createRequestCache(); const load = vi.fn(async (key: string) => key.toUpperCase()); - await cache.run('a', () => load('a')); - await cache.run('b', () => load('b')); + await cache.run("a", () => load("a")); + await cache.run("b", () => load("b")); expect(load).toHaveBeenCalledTimes(2); }); diff --git a/web/src/lib/api/load.ts b/web/src/lib/api/load.ts --- a/web/src/lib/api/load.ts +++ b/web/src/lib/api/load.ts @@ -1,19 +1,19 @@ -import { error, type NumericRange } from '@sveltejs/kit'; -import { ClientResponseError } from './client'; +import { error, type NumericRange } from "@sveltejs/kit"; +import { ClientResponseError } from "./client"; export const httpStatusFor = (cause: unknown): number => { if (cause instanceof ClientResponseError) { if (cause.status >= 400 && cause.status <= 599) return cause.status; switch (cause.error) { - case 'RecordNotFound': + case "RecordNotFound": return 404; - case 'InvalidRequest': + case "InvalidRequest": return 400; - case 'UpstreamFailed': - case 'UpstreamGone': - case 'InvalidRecord': + case "UpstreamFailed": + case "UpstreamGone": + case "InvalidRecord": return 502; - case 'Overloaded': + case "Overloaded": return 503; default: return 500; @@ -22,7 +22,7 @@ return 500; }; -export const toHttpError = (cause: unknown, fallbackMessage = 'Request failed'): never => { +export const toHttpError = (cause: unknown, fallbackMessage = "Request failed"): never => { const status = httpStatusFor(cause) as NumericRange<400, 599>; const message = cause instanceof ClientResponseError ? (cause.description ?? cause.error) : fallbackMessage; diff --git a/web/src/lib/api/pagination.test.ts b/web/src/lib/api/pagination.test.ts --- a/web/src/lib/api/pagination.test.ts +++ b/web/src/lib/api/pagination.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, it, vi, type Mock } from 'vitest'; -import { createBobbinClient, type BobbinContext } from './client'; -import { collect, pages, paginateBy } from './pagination'; +import { describe, expect, it, vi, type Mock } from "vitest"; +import { createBobbinClient, type BobbinContext } from "./client"; +import { collect, pages, paginateBy } from "./pagination"; -const NAME = 'sh.tangled.feed.listStars'; -const PARAMS = { subject: 'did:plc:x' } as const; +const NAME = "sh.tangled.feed.listStars"; +const PARAMS = { subject: "did:plc:x" } as const; interface Page { items: readonly { uri: string }[]; @@ -13,59 +13,59 @@ const pageResponse = (page: Page): Response => new Response(JSON.stringify(page), { status: 200, - headers: { 'content-type': 'application/json' } + headers: { "content-type": "application/json" } }); const makeCtx = (fetchMock: typeof globalThis.fetch): BobbinContext => - createBobbinClient({ serviceUrl: 'https://bobbin.test', fetch: fetchMock }); + createBobbinClient({ serviceUrl: "https://bobbin.test", fetch: fetchMock }); const cursorOf = (mock: Mock, n: number): string | null => - new URL(String(mock.mock.calls[n][0])).searchParams.get('cursor'); + new URL(String(mock.mock.calls[n][0])).searchParams.get("cursor"); const threePageFetch = (): Mock => vi .fn() - .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }], cursor: 'c1' })) - .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'c' }], cursor: 'c2' })) - .mockResolvedValueOnce(pageResponse({ items: [{ uri: 'd' }] })); + .mockResolvedValueOnce(pageResponse({ items: [{ uri: "a" }, { uri: "b" }], cursor: "c1" })) + .mockResolvedValueOnce(pageResponse({ items: [{ uri: "c" }], cursor: "c2" })) + .mockResolvedValueOnce(pageResponse({ items: [{ uri: "d" }] })); -describe('pages / items / collect follow the cursor', () => { - it('pages() yields every page then stops when a page omits the cursor', async () => { +describe("pages / items / collect follow the cursor", () => { + it("pages() yields every page then stops when a page omits the cursor", async () => { const fetchMock = threePageFetch(); const seen: Page[] = []; for await (const p of pages(makeCtx(fetchMock), NAME, PARAMS)) seen.push(p as Page); expect(fetchMock).toHaveBeenCalledTimes(3); - expect(seen.map((p) => p.cursor)).toEqual(['c1', 'c2', undefined]); + expect(seen.map((p) => p.cursor)).toEqual(["c1", "c2", undefined]); expect(cursorOf(fetchMock, 0)).toBeNull(); - expect(cursorOf(fetchMock, 1)).toBe('c1'); - expect(cursorOf(fetchMock, 2)).toBe('c2'); + expect(cursorOf(fetchMock, 1)).toBe("c1"); + expect(cursorOf(fetchMock, 2)).toBe("c2"); }); }); -describe('pagination caps', () => { - it('maxPages caps the number of network round-trips even with unbounded cursors', async () => { +describe("pagination caps", () => { + it("maxPages caps the number of network round-trips even with unbounded cursors", async () => { const fetchMock = vi .fn() - .mockImplementation(async () => pageResponse({ items: [{ uri: 'x' }], cursor: 'always' })); + .mockImplementation(async () => pageResponse({ items: [{ uri: "x" }], cursor: "always" })); const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { maxPages: 2 }); expect(fetchMock).toHaveBeenCalledTimes(2); expect(out).toHaveLength(2); }); - it('collect({ max }) caps items mid-page without fetching the next page', async () => { + it("collect({ max }) caps items mid-page without fetching the next page", async () => { const fetchMock = vi .fn() .mockResolvedValueOnce( - pageResponse({ items: [{ uri: 'a' }, { uri: 'b' }, { uri: 'c' }], cursor: 'c1' }) + pageResponse({ items: [{ uri: "a" }, { uri: "b" }, { uri: "c" }], cursor: "c1" }) ); const out = await collect(makeCtx(fetchMock), NAME, PARAMS, { max: 2 }); - expect(out).toEqual([{ uri: 'a' }, { uri: 'b' }]); + expect(out).toEqual([{ uri: "a" }, { uri: "b" }]); expect(fetchMock).toHaveBeenCalledTimes(1); }); }); -describe('paginateBy', () => { - it('treats cursor: null as the end of the stream', async () => { +describe("paginateBy", () => { + it("treats cursor: null as the end of the stream", async () => { const load = vi.fn(async () => ({ items: [1, 2] as const, cursor: null @@ -76,27 +76,27 @@ expect(load).toHaveBeenCalledTimes(1); }); - it('follows a string cursor and forwards it to the loader', async () => { + it("follows a string cursor and forwards it to the loader", async () => { const load = vi .fn< ( cursor: string | undefined ) => Promise<{ items: readonly number[]; cursor?: string | null }> >() - .mockResolvedValueOnce({ items: [1], cursor: 'p2' }) + .mockResolvedValueOnce({ items: [1], cursor: "p2" }) .mockResolvedValueOnce({ items: [2], cursor: undefined }); const out: number[] = []; for await (const n of paginateBy(load)) out.push(n); expect(out).toEqual([1, 2]); expect(load).toHaveBeenCalledTimes(2); expect(load.mock.calls[0][0]).toBeUndefined(); - expect(load.mock.calls[1][0]).toBe('p2'); + expect(load.mock.calls[1][0]).toBe("p2"); }); - it('maxPages caps loader invocations', async () => { + it("maxPages caps loader invocations", async () => { const load = vi.fn(async () => ({ items: [0], - cursor: 'always' + cursor: "always" })); const out: number[] = []; for await (const n of paginateBy(load, { maxPages: 3 })) out.push(n); diff --git a/web/src/lib/api/pagination.ts b/web/src/lib/api/pagination.ts --- a/web/src/lib/api/pagination.ts +++ b/web/src/lib/api/pagination.ts @@ -1,20 +1,20 @@ -import type { XRPCQueries } from '@atcute/lexicons/ambient'; +import type { XRPCQueries } from "@atcute/lexicons/ambient"; import type { InferInput, InferOutput, ObjectSchema, XRPCLexBodyParam -} from '@atcute/lexicons/validations'; -import { ok, type BobbinContext, type XrpcRequestInit } from './client'; +} from "@atcute/lexicons/validations"; +import { ok, type BobbinContext, type XrpcRequestInit } from "./client"; export type QueryOutput = - XRPCQueries[TName]['output'] extends XRPCLexBodyParam - ? InferOutput + XRPCQueries[TName]["output"] extends XRPCLexBodyParam + ? InferOutput : never; export type QueryParams = - XRPCQueries[TName]['params'] extends ObjectSchema - ? InferInput + XRPCQueries[TName]["params"] extends ObjectSchema + ? InferInput : Record; type CursorPage = { items: readonly unknown[]; cursor?: string }; @@ -27,7 +27,7 @@ export type PageItem = QueryOutput extends { items: readonly (infer I)[] } ? I : never; -export type PageParams = Omit, 'cursor'>; +export type PageParams = Omit, "cursor">; export interface PaginateOptions extends XrpcRequestInit { /** stop after this many network round-trips. */ diff --git a/web/src/lib/api/profile.ts b/web/src/lib/api/profile.ts --- a/web/src/lib/api/profile.ts +++ b/web/src/lib/api/profile.ts @@ -1,11 +1,11 @@ -import { ok } from '@atcute/client'; -import { mainSchema as putRecordSchema } from '@atcute/atproto/types/repo/putRecord'; -import type { Nsid, RecordKey } from '@atcute/lexicons/syntax'; -import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; -import { createClient } from '$lib/auth/agent'; -import type { ProfileRecord } from './records'; +import { ok } from "@atcute/client"; +import { mainSchema as putRecordSchema } from "@atcute/atproto/types/repo/putRecord"; +import type { Nsid, RecordKey } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; +import { createClient } from "$lib/auth/agent"; +import type { ProfileRecord } from "./records"; -const PROFILE_COLLECTION = 'sh.tangled.actor.profile' as Nsid; +const PROFILE_COLLECTION = "sh.tangled.actor.profile" as Nsid; // the profile record lives at the fixed rkey `self`; put upserts it. export const putProfile = async (agent: OAuthUserAgent, record: ProfileRecord): Promise => { @@ -15,7 +15,7 @@ input: { repo: agent.sub, collection: PROFILE_COLLECTION, - rkey: 'self' as RecordKey, + rkey: "self" as RecordKey, record } }) diff --git a/web/src/lib/api/records.ts b/web/src/lib/api/records.ts --- a/web/src/lib/api/records.ts +++ b/web/src/lib/api/records.ts @@ -1,9 +1,9 @@ -import type { BobbinContext, XrpcRequestInit } from './client'; -import { jsonGet } from './_request'; -import type * as ShTangledActorProfile from './lexicons/types/sh/tangled/actor/profile'; -import type * as ShTangledRepo from './lexicons/types/sh/tangled/repo'; -import type * as ShTangledRepoIssue from './lexicons/types/sh/tangled/repo/issue'; -import type * as ShTangledRepoPull from './lexicons/types/sh/tangled/repo/pull'; +import type { BobbinContext, XrpcRequestInit } from "./client"; +import { jsonGet } from "./_request"; +import type * as ShTangledActorProfile from "./lexicons/types/sh/tangled/actor/profile"; +import type * as ShTangledRepo from "./lexicons/types/sh/tangled/repo"; +import type * as ShTangledRepoIssue from "./lexicons/types/sh/tangled/repo/issue"; +import type * as ShTangledRepoPull from "./lexicons/types/sh/tangled/repo/pull"; export interface RecordView { uri: string; @@ -23,36 +23,36 @@ export const BULK_LIMIT = 50; export const getRepo = (ctx: BobbinContext, repo: string, init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.repo.getRepo', { repo }, init); + jsonGet>(ctx, "sh.tangled.repo.getRepo", { repo }, init); export const getRepoByRepoDid = (ctx: BobbinContext, repoDid: string, init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.repo.getRepoByRepoDid', { repoDid }, init); + jsonGet>(ctx, "sh.tangled.repo.getRepoByRepoDid", { repoDid }, init); export const getProfile = (ctx: BobbinContext, did: string, init?: XrpcRequestInit) => jsonGet>( ctx, - 'sh.tangled.actor.getProfile', + "sh.tangled.actor.getProfile", { actor: `at://${did}/sh.tangled.actor.profile/self` }, init ); export const getIssue = (ctx: BobbinContext, issue: string, init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.repo.getIssue', { issue }, init); + jsonGet>(ctx, "sh.tangled.repo.getIssue", { issue }, init); export const getPull = (ctx: BobbinContext, pull: string, init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.repo.getPull', { pull }, init); + jsonGet>(ctx, "sh.tangled.repo.getPull", { pull }, init); export const getRepos = (ctx: BobbinContext, repos: readonly string[], init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.repo.getRepos', { repos }, init); + jsonGet>(ctx, "sh.tangled.repo.getRepos", { repos }, init); export const getProfiles = ( ctx: BobbinContext, actors: readonly string[], init?: XrpcRequestInit -) => jsonGet>(ctx, 'sh.tangled.actor.getProfiles', { actors }, init); +) => jsonGet>(ctx, "sh.tangled.actor.getProfiles", { actors }, init); export const getIssues = (ctx: BobbinContext, issues: readonly string[], init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.repo.getIssues', { issues }, init); + jsonGet>(ctx, "sh.tangled.repo.getIssues", { issues }, init); export const getPulls = (ctx: BobbinContext, pulls: readonly string[], init?: XrpcRequestInit) => - jsonGet>(ctx, 'sh.tangled.repo.getPulls', { pulls }, init); + jsonGet>(ctx, "sh.tangled.repo.getPulls", { pulls }, init); diff --git a/web/src/lib/api/search.ts b/web/src/lib/api/search.ts --- a/web/src/lib/api/search.ts +++ b/web/src/lib/api/search.ts @@ -1,6 +1,6 @@ -import type { BobbinContext, XrpcRequestInit } from './client'; -import { jsonGet } from './_request'; -import { paginateBy } from './pagination'; +import type { BobbinContext, XrpcRequestInit } from "./client"; +import { jsonGet } from "./_request"; +import { paginateBy } from "./pagination"; export interface SearchHit { uri: string; @@ -28,7 +28,7 @@ } export const search = (ctx: BobbinContext, params: SearchParams, init?: XrpcRequestInit) => - jsonGet(ctx, 'sh.tangled.search.query', { ...params }, init); + jsonGet(ctx, "sh.tangled.search.query", { ...params }, init); export interface SearchAllOptions extends XrpcRequestInit { maxPages?: number; @@ -37,7 +37,7 @@ /** Async-iterate every search hit across cursor pages. */ export async function* searchAll( ctx: BobbinContext, - params: Omit, + params: Omit, options: SearchAllOptions = {} ): AsyncGenerator { yield* paginateBy(async (cursor) => { diff --git a/web/src/lib/api/uri.ts b/web/src/lib/api/uri.ts --- a/web/src/lib/api/uri.ts +++ b/web/src/lib/api/uri.ts @@ -1,9 +1,9 @@ // at-uri helpers: at://// export const didFromUri = (uri: string): string => { - const rest = uri.startsWith('at://') ? uri.slice(5) : uri; - const slash = rest.indexOf('/'); + const rest = uri.startsWith("at://") ? uri.slice(5) : uri; + const slash = rest.indexOf("/"); return slash === -1 ? rest : rest.slice(0, slash); }; -export const rkeyFromUri = (uri: string): string => uri.slice(uri.lastIndexOf('/') + 1); +export const rkeyFromUri = (uri: string): string => uri.slice(uri.lastIndexOf("/") + 1); diff --git a/web/src/lib/auth/accounts.ts b/web/src/lib/auth/accounts.ts --- a/web/src/lib/auth/accounts.ts +++ b/web/src/lib/auth/accounts.ts @@ -1,11 +1,11 @@ -import { browser } from '$app/environment'; -import type { Did } from '@atcute/lexicons/syntax'; +import { browser } from "$app/environment"; +import type { Did } from "@atcute/lexicons/syntax"; // atcute owns oauth sessions; this stores metadata/order and active-account cookies. -export const CURRENT_DID_KEY = 'tangled.currentDid'; -export const CURRENT_HANDLE_KEY = 'tangled.currentHandle'; -const ACCOUNTS_KEY = 'tangled.accounts'; +export const CURRENT_DID_KEY = "tangled.currentDid"; +export const CURRENT_HANDLE_KEY = "tangled.currentHandle"; +const ACCOUNTS_KEY = "tangled.accounts"; // appview account cap parity export const MAX_ACCOUNTS = 20; @@ -19,13 +19,13 @@ } const isDid = (value: unknown): value is Did => - typeof value === 'string' && value.startsWith('did:'); + typeof value === "string" && value.startsWith("did:"); const isAccount = (value: unknown): value is AuthAccount => !!value && - typeof value === 'object' && + typeof value === "object" && isDid((value as AuthAccount).did) && - typeof (value as AuthAccount).handle === 'string'; + typeof (value as AuthAccount).handle === "string"; export const loadAccounts = (): AuthAccount[] => { if (!browser) return []; @@ -38,7 +38,7 @@ did: account.did, handle: account.handle, avatar: account.avatar, - addedAt: typeof account.addedAt === 'number' ? account.addedAt : 0 + addedAt: typeof account.addedAt === "number" ? account.addedAt : 0 })); } catch { return []; @@ -88,7 +88,7 @@ if (!browser) return; localStorage.setItem(CURRENT_DID_KEY, did); localStorage.setItem(CURRENT_HANDLE_KEY, handle); - const secure = location.protocol === 'https:' ? '; secure' : ''; + const secure = location.protocol === "https:" ? "; secure" : ""; const attrs = `; path=/; max-age=31536000; samesite=lax${secure}`; document.cookie = `${CURRENT_DID_KEY}=${encodeURIComponent(did)}${attrs}`; document.cookie = `${CURRENT_HANDLE_KEY}=${encodeURIComponent(handle)}${attrs}`; diff --git a/web/src/lib/auth/agent.ts b/web/src/lib/auth/agent.ts --- a/web/src/lib/auth/agent.ts +++ b/web/src/lib/auth/agent.ts @@ -1,7 +1,7 @@ -import { Client, ok } from '@atcute/client'; -import { mainSchema as getServiceAuthSchema } from '@atcute/atproto/types/server/getServiceAuth'; -import type { Nsid } from '@atcute/lexicons/syntax'; -import type { OAuthUserAgent } from '@atcute/oauth-browser-client'; +import { Client, ok } from "@atcute/client"; +import { mainSchema as getServiceAuthSchema } from "@atcute/atproto/types/server/getServiceAuth"; +import type { Nsid } from "@atcute/lexicons/syntax"; +import type { OAuthUserAgent } from "@atcute/oauth-browser-client"; export const createClient = (agent: OAuthUserAgent): Client => new Client({ handler: agent }); @@ -14,7 +14,7 @@ } // did:web service id, with ports percent-encoded like serviceauth didweb. -export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, '%3A')}`; +export const serviceDidForHost = (host: string): string => `did:web:${host.replace(/:/g, "%3A")}`; // mint a service-auth jwt for knot/spindle xrpc calls. export const mintServiceAuth = async ( diff --git a/web/src/lib/auth/guards.ts b/web/src/lib/auth/guards.ts --- a/web/src/lib/auth/guards.ts +++ b/web/src/lib/auth/guards.ts @@ -1,8 +1,8 @@ -import { goto } from '$app/navigation'; -import { resolve } from '$app/paths'; -import { redirect, type RequestEvent } from '@sveltejs/kit'; -import { CURRENT_DID_KEY, CURRENT_HANDLE_KEY } from './accounts'; -import type { Auth } from '../auth.svelte'; +import { goto } from "$app/navigation"; +import { resolve } from "$app/paths"; +import { redirect, type RequestEvent } from "@sveltejs/kit"; +import { CURRENT_DID_KEY, CURRENT_HANDLE_KEY } from "./accounts"; +import type { Auth } from "../auth.svelte"; export interface RequireAuthResult { did: string; @@ -22,7 +22,7 @@ export const requireAuthClient = (auth: Auth, url: URL): boolean => { if (auth.currentDid) return true; - void goto(resolve(loginWithReturn(url.pathname + url.search) as '/login'), { + void goto(resolve(loginWithReturn(url.pathname + url.search) as "/login"), { replaceState: true }); return false; diff --git a/web/src/lib/server/config.ts b/web/src/lib/server/config.ts --- a/web/src/lib/server/config.ts +++ b/web/src/lib/server/config.ts @@ -1,8 +1,8 @@ -import { env } from '$env/dynamic/private'; +import { env } from "$env/dynamic/private"; const cleanUrl = (value: string | undefined, fallback: string) => { const raw = value?.trim() || fallback; - return raw.replace(/\/+$/, ''); + return raw.replace(/\/+$/, ""); }; export type WebConfig = { @@ -14,7 +14,7 @@ export type PublicWebConfig = Pick< WebConfig, - 'bobbinUrl' | 'apiUrl' | 'knotResolverUrl' | 'camoUrl' + "bobbinUrl" | "apiUrl" | "knotResolverUrl" | "camoUrl" >; type WebConfigEnv = { @@ -26,10 +26,10 @@ }; export const resolveConfig = (values: WebConfigEnv): WebConfig => ({ - bobbinUrl: cleanUrl(values.BOBBIN_URL, 'http://127.0.0.1:8090'), - apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, 'http://127.0.0.1:8080'), - knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, 'https://knot1.tangled.sh'), - camoUrl: cleanUrl(values.CAMO_URL, 'https://camo.tangled.sh') + bobbinUrl: cleanUrl(values.BOBBIN_URL, "http://127.0.0.1:8090"), + apiUrl: cleanUrl(values.TANGLED_API_URL ?? values.API_URL, "http://127.0.0.1:8080"), + knotResolverUrl: cleanUrl(values.KNOT_RESOLVER_URL, "https://knot1.tangled.sh"), + camoUrl: cleanUrl(values.CAMO_URL, "https://camo.tangled.sh") }); export const getConfig = (): WebConfig => resolveConfig(env as WebConfigEnv); diff --git a/web/src/routes/[handle]/+layout.ts b/web/src/routes/[handle]/+layout.ts --- a/web/src/routes/[handle]/+layout.ts +++ b/web/src/routes/[handle]/+layout.ts @@ -1,13 +1,13 @@ -import { error, redirect } from '@sveltejs/kit'; -import { createBobbinClient } from '$lib/api/client'; -import { resolveMiniDoc } from '$lib/api/identity'; -import { getProfile, type ProfileRecord } from '$lib/api/records'; -import { count } from '$lib/api/count'; -import { parallel, toHttpError, httpStatusFor } from '$lib/api/load'; -import { ClientResponseError } from '$lib/api/client'; -import { findFollowRkey } from '$lib/api/graph'; -import type { ProfileCounts } from '$lib/components/profile/types'; -import type { LayoutLoad } from './$types'; +import { error, redirect } from "@sveltejs/kit"; +import { createBobbinClient } from "$lib/api/client"; +import { resolveMiniDoc } from "$lib/api/identity"; +import { getProfile, type ProfileRecord } from "$lib/api/records"; +import { count } from "$lib/api/count"; +import { parallel, toHttpError, httpStatusFor } from "$lib/api/load"; +import { ClientResponseError } from "$lib/api/client"; +import { findFollowRkey } from "$lib/api/graph"; +import type { ProfileCounts } from "$lib/components/profile/types"; +import type { LayoutLoad } from "./$types"; export const load: LayoutLoad = async (event) => { const parent = await event.parent(); @@ -15,17 +15,17 @@ // actor identifiers are dids or dotted handles; reject bare words early so // unrelated paths (/settings, /signup, ...) 404 instead of resolving. - if (!identifier.startsWith('did:') && !identifier.includes('.')) { - error(404, 'Not found'); + if (!identifier.startsWith("did:") && !identifier.includes(".")) { + error(404, "Not found"); } const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const doc = await resolveMiniDoc(ctx, identifier).catch((cause) => - toHttpError(cause, 'Could not resolve user') + toHttpError(cause, "Could not resolve user") ); // canonical url is the handle; redirect dids and stale handles. - const canonical = doc.handle && !doc.handle.endsWith('.invalid') ? doc.handle : null; + const canonical = doc.handle && !doc.handle.endsWith(".invalid") ? doc.handle : null; if (canonical && identifier.toLowerCase() !== canonical.toLowerCase()) { redirect(307, `/${canonical}${event.url.search}`); } @@ -38,17 +38,17 @@ profile = (await getProfile(ctx, did)).value; } catch (cause) { if (!(cause instanceof ClientResponseError && httpStatusFor(cause) === 404)) { - toHttpError(cause, 'Could not load profile'); + toHttpError(cause, "Could not load profile"); } } const raw = await parallel({ - repos: count(ctx, 'sh.tangled.repo.countRepos', did), - strings: count(ctx, 'sh.tangled.string.countStrings', did), - stars: count(ctx, 'sh.tangled.feed.countStarsBy', did), - followers: count(ctx, 'sh.tangled.graph.countFollows', did), - following: count(ctx, 'sh.tangled.graph.countFollowsBy', did), - vouches: count(ctx, 'sh.tangled.graph.countVouches', did), + repos: count(ctx, "sh.tangled.repo.countRepos", did), + strings: count(ctx, "sh.tangled.string.countStrings", did), + stars: count(ctx, "sh.tangled.feed.countStarsBy", did), + followers: count(ctx, "sh.tangled.graph.countFollows", did), + following: count(ctx, "sh.tangled.graph.countFollowsBy", did), + vouches: count(ctx, "sh.tangled.graph.countVouches", did), viewerFollowRkey: viewerDid && viewerDid !== did ? findFollowRkey(ctx, viewerDid, did).catch(() => null) diff --git a/web/src/routes/[handle]/+page.svelte b/web/src/routes/[handle]/+page.svelte --- a/web/src/routes/[handle]/+page.svelte +++ b/web/src/routes/[handle]/+page.svelte @@ -1,26 +1,26 @@ -{#if data.tab === 'overview'} +{#if data.tab === "overview"} -{:else if data.tab === 'repos'} +{:else if data.tab === "repos"} -{:else if data.tab === 'starred'} +{:else if data.tab === "starred"} -{:else if data.tab === 'strings'} +{:else if data.tab === "strings"} -{:else if data.tab === 'followers'} +{:else if data.tab === "followers"} -{:else if data.tab === 'following'} +{:else if data.tab === "following"} -{:else if data.tab === 'vouches'} +{:else if data.tab === "vouches"} {/if} diff --git a/web/src/routes/[handle]/+page.ts b/web/src/routes/[handle]/+page.ts --- a/web/src/routes/[handle]/+page.ts +++ b/web/src/routes/[handle]/+page.ts @@ -1,41 +1,41 @@ -import type { Did } from '@atcute/lexicons/syntax'; -import { createBobbinClient } from '$lib/api/client'; -import { fetchPage, items } from '$lib/api/pagination'; -import { count } from '$lib/api/count'; -import { getRepoByRepoDid, type RepoRecord } from '$lib/api/records'; -import { IdentityCache } from '$lib/api/identity'; -import { didFromUri, rkeyFromUri } from '$lib/api/uri'; -import { toHttpError, parallel } from '$lib/api/load'; -import { search } from '$lib/api/search'; -import type { BobbinContext } from '$lib/api/client'; -import { listStarRkeys, type VouchRecord, type FollowRecord } from '$lib/api/graph'; -import type * as ShTangledFeedStar from '$lib/api/lexicons/types/sh/tangled/feed/star'; -import type * as ShTangledString from '$lib/api/lexicons/types/sh/tangled/string'; -import type * as ShTangledGraphFollow from '$lib/api/lexicons/types/sh/tangled/graph/follow'; +import type { Did } from "@atcute/lexicons/syntax"; +import { createBobbinClient } from "$lib/api/client"; +import { fetchPage, items } from "$lib/api/pagination"; +import { count } from "$lib/api/count"; +import { getRepoByRepoDid, type RepoRecord } from "$lib/api/records"; +import { IdentityCache } from "$lib/api/identity"; +import { didFromUri, rkeyFromUri } from "$lib/api/uri"; +import { toHttpError, parallel } from "$lib/api/load"; +import { search } from "$lib/api/search"; +import type { BobbinContext } from "$lib/api/client"; +import { listStarRkeys, type VouchRecord, type FollowRecord } from "$lib/api/graph"; +import type * as ShTangledFeedStar from "$lib/api/lexicons/types/sh/tangled/feed/star"; +import type * as ShTangledString from "$lib/api/lexicons/types/sh/tangled/string"; +import type * as ShTangledGraphFollow from "$lib/api/lexicons/types/sh/tangled/graph/follow"; import type { RepoCardData, StringCardData, PersonData, VouchData, StarData -} from '$lib/components/profile/types'; -import type { PageLoad } from './$types'; +} from "$lib/components/profile/types"; +import type { PageLoad } from "./$types"; const PAGE_LIMIT = 50; const TABS = [ - 'overview', - 'repos', - 'starred', - 'strings', - 'followers', - 'following', - 'vouches' + "overview", + "repos", + "starred", + "strings", + "followers", + "following", + "vouches" ] as const; type Tab = (typeof TABS)[number]; const normalizeTab = (raw: string | null): Tab => - TABS.includes(raw as Tab) ? (raw as Tab) : 'overview'; + TABS.includes(raw as Tab) ? (raw as Tab) : "overview"; interface ListItem { uri: string; @@ -47,7 +47,7 @@ return { rkey: rkeyFromUri(item.uri), name: value.name ?? rkeyFromUri(item.uri), - repoDid: value.repoDid ?? '', + repoDid: value.repoDid ?? "", ownerHandle, description: value.description, knot: value.knot, @@ -69,7 +69,7 @@ if (!repo.repoDid) return { ...repo, stars: 0, viewerStarRkey: null }; // TODO(bobbin): instead of doing this, listing repos should return star counts // and most likely other stats as well. - const stars = await count(ctx, 'sh.tangled.feed.countStars', repo.repoDid); + const stars = await count(ctx, "sh.tangled.feed.countStars", repo.repoDid); return { ...repo, stars: stars.count, @@ -87,7 +87,7 @@ filename: value.filename, description: value.description, createdAt: value.createdAt, - lines: value.contents?.split('\n').length ?? 1 + lines: value.contents?.split("\n").length ?? 1 }; }; @@ -105,10 +105,10 @@ const counts = await parallel( unique.reduce( (acc, did) => { - acc[`${did}-followers`] = count(ctx, 'sh.tangled.graph.countFollows', did) + acc[`${did}-followers`] = count(ctx, "sh.tangled.graph.countFollows", did) .then((result) => result.count) .catch(() => 0); - acc[`${did}-following`] = count(ctx, 'sh.tangled.graph.countFollowsBy', did) + acc[`${did}-following`] = count(ctx, "sh.tangled.graph.countFollowsBy", did) .then((result) => result.count) .catch(() => 0); return acc; @@ -121,7 +121,7 @@ if (viewerDid) { for await (const item of items( ctx, - 'sh.tangled.graph.listFollowsBy', + "sh.tangled.graph.listFollowsBy", { subject: viewerDid as Did }, { maxPages: 10 } )) { @@ -167,7 +167,7 @@ did: voucher, handle: doc?.handle ?? voucher, avatar: doc?.avatar, - kind: value.kind === 'denounce' ? 'denounce' : 'vouch', + kind: value.kind === "denounce" ? "denounce" : "vouch", reason: value.reason, createdAt: value.createdAt }; @@ -185,13 +185,13 @@ items.map(async (item): Promise => { const value = item.value as ShTangledFeedStar.Main; const subject = value.subject; - if (subject && 'did' in subject && subject.did) { + if (subject && "did" in subject && subject.did) { try { const repo = await getRepoByRepoDid(ctx, subject.did); const ownerDid = didFromUri(repo.uri); const owner = await cache.resolve(ownerDid).catch(() => null); return { - kind: 'repo', + kind: "repo", uri: item.uri, createdAt: value.createdAt, repo: await resolveRepoCard(ctx, repo, owner?.handle ?? ownerDid, options) @@ -200,11 +200,11 @@ return null; } } - if (subject && 'uri' in subject && subject.uri) { + if (subject && "uri" in subject && subject.uri) { const ownerDid = didFromUri(subject.uri); const owner = await cache.resolve(ownerDid).catch(() => null); return { - kind: 'string', + kind: "string", uri: item.uri, createdAt: value.createdAt, ownerHandle: owner?.handle ?? ownerDid, @@ -219,9 +219,9 @@ export const load: PageLoad = async (event) => { const parent = await event.parent(); - const tab = normalizeTab(event.url.searchParams.get('tab')); + const tab = normalizeTab(event.url.searchParams.get("tab")); - if (parent.notJoined) return { tab: 'overview' as const, overview: { pinned: [] } }; + if (parent.notJoined) return { tab: "overview" as const, overview: { pinned: [] } }; const ctx = createBobbinClient({ serviceUrl: parent.publicConfig.bobbinUrl, fetch: event.fetch }); const did = parent.identity.did as Did; @@ -229,14 +229,14 @@ try { switch (tab) { - case 'repos': { - const q = event.url.searchParams.get('q')?.trim(); + case "repos": { + const q = event.url.searchParams.get("q")?.trim(); const [found, viewerStarRkeys] = await Promise.all([ q - ? search(ctx, { q, nsid: 'sh.tangled.repo', author: did, limit: PAGE_LIMIT }).then( + ? search(ctx, { q, nsid: "sh.tangled.repo", author: did, limit: PAGE_LIMIT }).then( (page) => page.hits ) - : fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }).then( + : fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }).then( (page) => page.items ), parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined @@ -248,15 +248,15 @@ ) }; } - case 'strings': { - const page = await fetchPage(ctx, 'sh.tangled.string.listStrings', { + case "strings": { + const page = await fetchPage(ctx, "sh.tangled.string.listStrings", { subject: did, limit: PAGE_LIMIT }); return { tab, strings: page.items.map((item) => toStringCard(item, handle)) }; } - case 'followers': { - const page = await fetchPage(ctx, 'sh.tangled.graph.listFollows', { + case "followers": { + const page = await fetchPage(ctx, "sh.tangled.graph.listFollows", { subject: did, limit: PAGE_LIMIT }); @@ -266,8 +266,8 @@ people: await resolvePeople(ctx, dids, parent.auth?.did) }; } - case 'following': { - const page = await fetchPage(ctx, 'sh.tangled.graph.listFollowsBy', { + case "following": { + const page = await fetchPage(ctx, "sh.tangled.graph.listFollowsBy", { subject: did, limit: PAGE_LIMIT }); @@ -277,16 +277,16 @@ people: await resolvePeople(ctx, dids, parent.auth?.did) }; } - case 'vouches': { - const page = await fetchPage(ctx, 'sh.tangled.graph.listVouches', { + case "vouches": { + const page = await fetchPage(ctx, "sh.tangled.graph.listVouches", { subject: did, limit: PAGE_LIMIT }); return { tab, vouches: await resolveVouches(ctx, page.items) }; } - case 'starred': { + case "starred": { const [page, viewerStarRkeys] = await Promise.all([ - fetchPage(ctx, 'sh.tangled.feed.listStarsBy', { subject: did, limit: PAGE_LIMIT }), + fetchPage(ctx, "sh.tangled.feed.listStarsBy", { subject: did, limit: PAGE_LIMIT }), parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined ]); return { @@ -294,10 +294,10 @@ stars: await resolveStars(ctx, page.items, { viewerStarRkeys }) }; } - case 'overview': + case "overview": default: { const [page, viewerStarRkeys] = await Promise.all([ - fetchPage(ctx, 'sh.tangled.repo.listRepos', { subject: did, limit: PAGE_LIMIT }), + fetchPage(ctx, "sh.tangled.repo.listRepos", { subject: did, limit: PAGE_LIMIT }), parent.auth?.did ? listStarRkeys(ctx, parent.auth.did) : undefined ]); @@ -315,11 +315,11 @@ pinnedItems.map((item) => resolveRepoCard(ctx, item, handle, { viewerStarRkeys })) ); - return { tab: 'overview' as const, overview: { pinned } }; + return { tab: "overview" as const, overview: { pinned } }; } } } catch (cause) { - console.error('Page load error:', cause); - toHttpError(cause, 'Could not load profile data'); + console.error("Page load error:", cause); + toHttpError(cause, "Could not load profile data"); } }; diff --git a/web/src/routes/about/+page.svelte b/web/src/routes/about/+page.svelte --- a/web/src/routes/about/+page.svelte +++ b/web/src/routes/about/+page.svelte @@ -1,5 +1,5 @@ diff --git a/web/src/lib/components/profile/FollowerFollowing.svelte b/web/src/lib/components/profile/FollowerFollowing.svelte --- a/web/src/lib/components/profile/FollowerFollowing.svelte +++ b/web/src/lib/components/profile/FollowerFollowing.svelte @@ -1,6 +1,6 @@