my pkgs monorepo

malachite v0.11.0 / malachite-web v0.4.0

- add did:web support to identity resolution (CLI + web)
- expose @ewanc26/malachite/core subpath export
- malachite-web now depends on @ewanc26/malachite via workspace rather than aliasing raw source
- remove $core svelte alias and vite resolve overrides
- drop direct @ipld/car, @ipld/dag-cbor, multiformats deps from malachite-web (now transitive)

ewancroft.uk eef6c280 0d29f2f9

verified
+126 -114
+4 -6
packages/malachite-web/package.json
··· 1 1 { 2 2 "name": "@ewanc26/malachite-web", 3 3 "private": true, 4 - "version": "0.3.3", 4 + "version": "0.4.0", 5 5 "type": "module", 6 6 "scripts": { 7 7 "dev": "vite dev", 8 - "prebuild": "pnpm --filter @ewanc26/tid build", 8 + "prebuild": "pnpm --filter @ewanc26/malachite build", 9 9 "build": "vite build", 10 10 "preview": "vite preview", 11 11 "prepare": "svelte-kit sync || echo ''", ··· 15 15 "format": "prettier --write ." 16 16 }, 17 17 "dependencies": { 18 + "@ewanc26/malachite": "workspace:*", 18 19 "@atproto/api": "^0.18.13", 19 20 "@atproto/common-web": "^0.4.12", 20 21 "@atproto/oauth-client-browser": "^0.3.41", 21 - "@ipld/car": "^5.3.2", 22 - "@ipld/dag-cbor": "^9.2.2", 23 - "@lucide/svelte": "^0.575.0", 24 - "multiformats": "^13.3.1" 22 + "@lucide/svelte": "^0.575.0" 25 23 }, 26 24 "devDependencies": { 27 25 "@sveltejs/adapter-vercel": "^6.3.1",
+3 -3
packages/malachite-web/src/lib/components/steps/AuthStep.svelte
··· 78 78 <input 79 79 type="text" 80 80 bind:value={oauthHandle} 81 - placeholder="you.bsky.social" 81 + placeholder="you.bsky.social or did:web:example.com" 82 82 autocomplete="username" 83 83 spellcheck="false" 84 84 onkeydown={(e) => e.key === 'Enter' && !oauthLoading && oauthHandle && doOAuth()} ··· 110 110 111 111 <div class="form"> 112 112 <label class="field"> 113 - <span class="field-label">Handle</span> 113 + <span class="field-label">Handle or DID</span> 114 114 <input 115 115 type="text" 116 116 bind:value={handle} 117 - placeholder="you.bsky.social" 117 + placeholder="you.bsky.social or did:web:example.com" 118 118 autocomplete="username" 119 119 spellcheck="false" 120 120 />
+2 -2
packages/malachite-web/src/lib/config.ts
··· 1 - // Re-export shared constants from the environment-agnostic core. 1 + // Re-export shared constants from the @ewanc26/malachite package. 2 2 // Keep this file free of side-effects so it stays tree-shakeable. 3 - export { RECORD_TYPE, SLINGSHOT_RESOLVER, MAX_PDS_BATCH_SIZE, POINTS_PER_RECORD } from '$core/config.js'; 3 + export { RECORD_TYPE, SLINGSHOT_RESOLVER, MAX_PDS_BATCH_SIZE, POINTS_PER_RECORD } from '@ewanc26/malachite/core'; 4 4 5 5 // __WEB_VERSION__ is injected at build time by vite.config.ts → define.__WEB_VERSION__ 6 6 declare const __WEB_VERSION__: string;
+5 -41
packages/malachite-web/src/lib/core/auth.ts
··· 1 1 /** 2 - * Browser-compatible ATProto authentication. 3 - * No CLI prompts — credentials come from the web form. 2 + * ATProto authentication — web layer. 3 + * Re-exports the shared core logic (including did:web support) from 4 + * @ewanc26/malachite. No browser-specific additions needed here. 4 5 */ 5 - 6 - import { Agent, AtpAgent } from '@atproto/api'; 7 - import { SLINGSHOT_RESOLVER } from '../config.js'; 8 - 9 - interface ResolvedIdentity { 10 - did: string; 11 - handle: string; 12 - pds: string; 13 - } 14 - 15 - export async function resolveIdentity(identifier: string): Promise<ResolvedIdentity> { 16 - const url = `${SLINGSHOT_RESOLVER}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`; 17 - const res = await fetch(url); 18 - if (!res.ok) { 19 - throw new Error(`Failed to resolve identity: ${res.status} ${res.statusText}`); 20 - } 21 - const data = (await res.json()) as ResolvedIdentity; 22 - if (!data.did || !data.pds) { 23 - throw new Error('Invalid response from identity resolver'); 24 - } 25 - return data; 26 - } 27 - 28 - export async function login( 29 - identifier: string, 30 - password: string, 31 - pdsOverride?: string 32 - ): Promise<Agent> { 33 - if (pdsOverride) { 34 - const agent = new AtpAgent({ service: pdsOverride }); 35 - await agent.login({ identifier, password }); 36 - return agent; 37 - } 38 - 39 - const identity = await resolveIdentity(identifier); 40 - const agent = new AtpAgent({ service: identity.pds }); 41 - await agent.login({ identifier: identity.did, password }); 42 - return agent; 43 - } 6 + export { resolveIdentity, login } from '@ewanc26/malachite/core'; 7 + export type { ResolvedIdentity } from '@ewanc26/malachite/core';
+2 -2
packages/malachite-web/src/lib/core/car-fetch.ts
··· 1 - // Shared implementation lives in src/core/ — no duplication. 2 - export * from '$core/car-fetch.js'; 1 + // Shared implementation — re-exported from the @ewanc26/malachite npm package. 2 + export * from '@ewanc26/malachite/core';
+3 -4
packages/malachite-web/src/lib/core/csv.ts
··· 2 2 * Last.fm CSV — web layer. 3 3 * Re-exports the shared core logic and adds a browser File API loader. 4 4 */ 5 - import type { LastFmCsvRecord } from '$core/types.js'; 6 - import { parseLastFmCsvContent, convertToPlayRecord } from '$core/csv.js'; 5 + import type { LastFmCsvRecord } from '@ewanc26/malachite/core'; 6 + import { parseLastFmCsvContent, convertToPlayRecord } from '@ewanc26/malachite/core'; 7 7 8 8 export { parseLastFmCsvContent, convertToPlayRecord }; 9 9 10 10 /** Read a browser File object and parse it as a Last.fm CSV export. */ 11 11 export async function parseLastFmFile(file: File): Promise<LastFmCsvRecord[]> { 12 - const text = await file.text(); 13 - return parseLastFmCsvContent(text); 12 + return parseLastFmCsvContent(await file.text()); 14 13 }
+4 -4
packages/malachite-web/src/lib/core/import.ts
··· 8 8 */ 9 9 10 10 import type { Agent } from '@atproto/api'; 11 - import type { ImportMode, LogEntry, PlayRecord } from '$core/types.js'; 11 + import type { ImportMode, LogEntry, PlayRecord } from '$lib/types.js'; 12 12 import { CLIENT_AGENT } from '../config.js'; 13 13 import { parseLastFmFile, convertToPlayRecord } from './csv.js'; 14 14 import { parseSpotifyFiles, convertSpotifyToPlayRecord } from './spotify.js'; 15 - import { mergePlayRecords, deduplicateInputRecords, sortRecords } from '$core/merge.js'; 15 + import { mergePlayRecords, deduplicateInputRecords, sortRecords } from '$lib/core/merge.js'; 16 16 import { 17 17 fetchExistingRecords, 18 18 filterNewRecords, ··· 20 20 findDuplicateGroups, 21 21 removeDuplicateRecords, 22 22 type ExistingRecord, 23 - } from '$core/sync.js'; 24 - import { publishRecords, type PublishProgress } from '$core/publisher.js'; 23 + } from '$lib/core/sync.js'; 24 + import { publishRecords, type PublishProgress } from '$lib/core/publisher.js'; 25 25 26 26 export type { PublishProgress }; 27 27
+2 -2
packages/malachite-web/src/lib/core/merge.ts
··· 1 - // Shared implementation lives in src/core/ — no duplication. 2 - export * from '$core/merge.js'; 1 + // Shared implementation — re-exported from the @ewanc26/malachite npm package. 2 + export * from '@ewanc26/malachite/core';
+2 -2
packages/malachite-web/src/lib/core/publisher.ts
··· 1 - // Shared implementation lives in src/core/ — no duplication. 2 - export * from '$core/publisher.js'; 1 + // Shared implementation — re-exported from the @ewanc26/malachite npm package. 2 + export * from '@ewanc26/malachite/core';
+3 -5
packages/malachite-web/src/lib/core/spotify.ts
··· 2 2 * Spotify JSON — web layer. 3 3 * Re-exports the shared core logic and adds a browser File API loader. 4 4 */ 5 - import type { SpotifyRecord } from '$core/types.js'; 6 - import { parseSpotifyJsonContent, convertSpotifyToPlayRecord } from '$core/spotify.js'; 5 + import type { SpotifyRecord } from '@ewanc26/malachite/core'; 6 + import { parseSpotifyJsonContent, convertSpotifyToPlayRecord } from '@ewanc26/malachite/core'; 7 7 8 8 export { parseSpotifyJsonContent, convertSpotifyToPlayRecord }; 9 9 ··· 11 11 export async function parseSpotifyFiles(files: File[]): Promise<SpotifyRecord[]> { 12 12 let all: SpotifyRecord[] = []; 13 13 for (const file of files) { 14 - const text = await file.text(); 15 - const parsed = JSON.parse(text) as SpotifyRecord[]; 16 - all = all.concat(parsed); 14 + all = all.concat(JSON.parse(await file.text()) as SpotifyRecord[]); 17 15 } 18 16 return parseSpotifyJsonContent(all); 19 17 }
+2 -2
packages/malachite-web/src/lib/core/sync.ts
··· 1 - // Shared implementation lives in src/core/ — no duplication. 2 - export * from '$core/sync.js'; 1 + // Shared implementation — re-exported from the @ewanc26/malachite npm package. 2 + export * from '@ewanc26/malachite/core';
+2 -2
packages/malachite-web/src/lib/core/tid.ts
··· 1 - // Shared implementation lives in src/core/ — no duplication. 1 + // Shared implementation — re-exported from the @ewanc26/malachite npm package. 2 2 // src/core/tid.ts already uses globalThis.crypto which works in both Node 20+ 3 3 // and browsers, so no browser-specific shim is needed. 4 - export * from '$core/tid.js'; 4 + export * from '@ewanc26/malachite/core';
+2 -2
packages/malachite-web/src/lib/types.ts
··· 1 - // All shared types live in src/core/types.ts — single source of truth. 1 + // All shared types live in @ewanc26/malachite/core — single source of truth. 2 2 // Re-export everything from there so the rest of the web app can import 3 3 // from '$lib/types.js' as before without any path changes. 4 4 export type { ··· 10 10 SpotifyRecord, 11 11 LogLevel, 12 12 LogEntry, 13 - } from '$core/types.js'; 13 + } from '@ewanc26/malachite/core';
-6
packages/malachite-web/svelte.config.js
··· 8 8 const config = { 9 9 kit: { 10 10 adapter: adapter({ runtime: 'nodejs22.x' }), 11 - alias: { 12 - // Shared, environment-agnostic core — single source of truth. 13 - // CLI uses src/core/ directly; web imports via this alias so there is 14 - // no duplicated logic. 15 - $core: resolve(__dirname, '../malachite/src/core'), 16 - }, 17 11 }, 18 12 }; 19 13
+6 -18
packages/malachite-web/vite.config.ts
··· 2 2 import { sveltekit } from '@sveltejs/kit/vite'; 3 3 import { defineConfig } from 'vite'; 4 4 import { readFileSync } from 'fs'; 5 - import { resolve } from 'path'; 5 + import { resolve } from 'path'; // used for package.json reads below 6 6 7 7 const webPkg = JSON.parse(readFileSync(resolve('package.json'), 'utf-8')); 8 8 const cliPkg = JSON.parse(readFileSync(resolve('../malachite/package.json'), 'utf-8')); ··· 10 10 export default defineConfig({ 11 11 plugins: [tailwindcss(), sveltekit()], 12 12 13 - resolve: { 14 - alias: { 15 - // src/core/ files import these packages, but they live in web/node_modules. 16 - // Without explicit aliases, Rollup resolves bare specifiers relative to the 17 - // importing file (../src/core/) and never finds web/node_modules on Vercel. 18 - '@ipld/car': resolve('node_modules/@ipld/car'), 19 - '@ipld/dag-cbor': resolve('node_modules/@ipld/dag-cbor'), 20 - '@ewanc26/tid': resolve('../tid/dist/index.js'), 21 - }, 22 - }, 23 - 24 13 server: { 25 14 host: '127.0.0.1', 26 15 port: 5173, 27 - fs: { 28 - allow: [ 29 - resolve('..') // allow workspace root 30 - ] 31 - } 32 16 }, 33 17 34 18 define: { ··· 43 27 44 28 build: { 45 29 target: 'es2022', 46 - chunkSizeWarningLimit: 1000 30 + // The /import page chunk is large because it bundles @atproto/api, the OAuth 31 + // client, and the IPLD/CAR parser — all unavoidable for an ATProto import tool. 32 + // The page is client-only (ssr=false, prerender=false) so it's never on the 33 + // critical path; gzipped it's ~350 kB which is acceptable. 34 + chunkSizeWarningLimit: 2000 47 35 } 48 36 });
+8 -1
packages/malachite/package.json
··· 1 1 { 2 2 "name": "@ewanc26/malachite", 3 - "version": "0.10.5", 3 + "version": "0.11.0", 4 4 "description": "Malachite - Import Last.fm and Spotify listening history to ATProto with intelligent deduplication and rate limiting", 5 5 "type": "module", 6 6 "main": "./dist/index.js", 7 7 "types": "./dist/index.d.ts", 8 + "exports": { 9 + ".": "./dist/index.js", 10 + "./core": { 11 + "types": "./dist/core/index.d.ts", 12 + "import": "./dist/core/index.js" 13 + } 14 + }, 8 15 "bin": { 9 16 "malachite": "./dist/index.js" 10 17 },
+47 -1
packages/malachite/src/core/auth.ts
··· 13 13 } 14 14 15 15 /** 16 - * Resolve an AT Protocol handle or DID to its PDS URL via the Slingshot resolver. 16 + * Resolve a did:web DID by fetching its DID document directly. 17 + * 18 + * did:web:example.com → https://example.com/.well-known/did.json 19 + * did:web:example.com:a:b → https://example.com/a/b/did.json 20 + */ 21 + async function resolveDidWeb(did: string): Promise<ResolvedIdentity> { 22 + const withoutPrefix = did.slice('did:web:'.length); 23 + const parts = withoutPrefix.split(':').map(decodeURIComponent); 24 + const domain = parts[0]; 25 + 26 + const url = 27 + parts.length === 1 28 + ? `https://${domain}/.well-known/did.json` 29 + : `https://${domain}/${parts.slice(1).join('/')}/did.json`; 30 + 31 + const res = await fetch(url); 32 + if (!res.ok) { 33 + throw new Error(`Failed to fetch did:web document at ${url}: ${res.status} ${res.statusText}`); 34 + } 35 + 36 + const doc = (await res.json()) as { 37 + id: string; 38 + alsoKnownAs?: string[]; 39 + service?: Array<{ id: string; type: string; serviceEndpoint: string }>; 40 + }; 41 + 42 + const pdsService = doc.service?.find((s) => s.id === '#atproto_pds'); 43 + if (!pdsService) { 44 + throw new Error(`No ATProto PDS service (#atproto_pds) found in DID document for ${did}`); 45 + } 46 + 47 + // Prefer the at:// alias as the canonical handle; fall back to the DID itself. 48 + const handle = 49 + doc.alsoKnownAs?.find((aka) => aka.startsWith('at://'))?.slice('at://'.length) ?? did; 50 + 51 + return { did, handle, pds: pdsService.serviceEndpoint }; 52 + } 53 + 54 + /** 55 + * Resolve an AT Protocol handle or DID to its PDS URL. 56 + * 57 + * - did:web identifiers are resolved by fetching the DID document directly. 58 + * - Everything else (handles and did:plc DIDs) is resolved via the Slingshot resolver. 17 59 */ 18 60 export async function resolveIdentity( 19 61 identifier: string, 20 62 resolverBase = SLINGSHOT_RESOLVER 21 63 ): Promise<ResolvedIdentity> { 64 + if (identifier.startsWith('did:web:')) { 65 + return resolveDidWeb(identifier); 66 + } 67 + 22 68 const url = `${resolverBase}/xrpc/com.bad-example.identity.resolveMiniDoc?identifier=${encodeURIComponent(identifier)}`; 23 69 const res = await fetch(url); 24 70 if (!res.ok) {
+1 -1
packages/malachite/src/core/config.ts
··· 8 8 export const MAX_PDS_BATCH_SIZE = 200; 9 9 export const POINTS_PER_RECORD = 3; 10 10 // Single source of truth for the version string — keep in sync with package.json. 11 - export const VERSION = '0.10.0'; 11 + export const VERSION = '0.11.0';
+24
packages/malachite/src/core/index.ts
··· 1 + /** 2 + * Public library surface for @ewanc26/malachite/core. 3 + * 4 + * Re-exports everything environment-agnostic from the core so that consumers 5 + * (e.g. malachite-web) can import a single subpath instead of reaching into 6 + * individual source modules. 7 + * 8 + * Note: SpotifyRecord is intentionally exported only via types.ts — spotify.ts 9 + * re-declares it as a convenience export but types.ts is the canonical source, 10 + * so we only pull the functions from spotify.ts to avoid a duplicate export. 11 + */ 12 + 13 + export * from './auth.js'; 14 + export * from './car-fetch.js'; 15 + export * from './config.js'; 16 + export * from './csv.js'; 17 + export * from './merge.js'; 18 + export * from './publisher.js'; 19 + export * from './rate-limit-headers.js'; 20 + export * from './rate-limiter.js'; 21 + export { parseSpotifyJsonContent, convertSpotifyToPlayRecord } from './spotify.js'; 22 + export * from './sync.js'; 23 + export * from './tid.js'; 24 + export * from './types.js';
+1 -1
packages/malachite/src/lib/auth.ts
··· 23 23 ui.header('ATProto Login'); 24 24 25 25 if (!identifier) { 26 - identifier = await prompt('Handle or DID: '); 26 + identifier = await prompt('Handle, DID (did:plc or did:web): '); 27 27 } else { 28 28 ui.keyValue('Handle or DID', identifier); 29 29 }
+3 -9
pnpm-lock.yaml
··· 68 68 '@atproto/oauth-client-browser': 69 69 specifier: ^0.3.41 70 70 version: 0.3.41 71 - '@ipld/car': 72 - specifier: ^5.3.2 73 - version: 5.4.2 74 - '@ipld/dag-cbor': 75 - specifier: ^9.2.2 76 - version: 9.2.5 71 + '@ewanc26/malachite': 72 + specifier: workspace:* 73 + version: link:../malachite 77 74 '@lucide/svelte': 78 75 specifier: ^0.575.0 79 76 version: 0.575.0(svelte@5.53.11) 80 - multiformats: 81 - specifier: ^13.3.1 82 - version: 13.4.2 83 77 devDependencies: 84 78 '@sveltejs/adapter-vercel': 85 79 specifier: ^6.3.1