feat(sdk): plugin contract, http client, fixture harness

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
Lucas Winther
2026-10-30 15:36:55 +01:00
co-authored by Claude Opus 5.5
parent 27ec24eb4b
commit a14c4e7747
6 changed files with 420 additions and 0 deletions
+101
View File
@@ -0,0 +1,101 @@
import type { HttpClient } from "./types.ts";
export class HttpError extends Error {
readonly status: number;
readonly body: string;
constructor(status: number, body: string, url = "") {
super(`HTTP ${status}${url ? ` for ${url}` : ""}`);
this.name = "HttpError";
this.status = status;
this.body = body;
}
}
export interface HttpClientOptions {
ratePerMinute?: number;
burst?: number;
maxRetries?: number;
userAgent: string;
fetchImpl?: typeof fetch;
sleep?: (ms: number) => Promise<void>;
now?: () => number;
}
const retryable = (status: number) => status === 429 || status >= 500;
function retryDelayMs(res: Response, attempt: number, now: number): number {
const ra = res.headers.get("retry-after");
if (ra !== null && ra.trim() !== "") {
const secs = Number(ra);
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
const at = Date.parse(ra);
if (!Number.isNaN(at)) return Math.max(0, at - now);
}
const reset = res.headers.get("x-ratelimit-reset-in");
if (reset !== null) {
const secs = Number(reset);
if (Number.isFinite(secs)) return Math.max(0, secs * 1000);
}
return 500 * 2 ** attempt;
}
export function createHttpClient(opts: HttpClientOptions): HttpClient {
const doFetch = opts.fetchImpl ?? fetch;
const sleep = opts.sleep ?? ((ms: number) => new Promise<void>((r) => setTimeout(r, ms)));
const now = opts.now ?? Date.now;
const maxRetries = opts.maxRetries ?? 3;
// Token bucket: capacity `burst`, refilling at ratePerMinute / 60000 tokens per ms.
const perMs = opts.ratePerMinute ? opts.ratePerMinute / 60_000 : 0;
const capacity = Math.max(1, opts.burst ?? 1);
let tokens = capacity;
let last = now();
let queue: Promise<void> = Promise.resolve();
async function take(): Promise<void> {
for (;;) {
const t = now();
tokens = Math.min(capacity, tokens + (t - last) * perMs);
last = t;
if (tokens >= 1 - 1e-9) {
tokens -= 1;
return;
}
await sleep(Math.ceil((1 - tokens) / perMs));
}
}
function acquire(): Promise<void> {
if (!perMs) return Promise.resolve();
const p = queue.then(take);
queue = p.catch(() => {});
return p;
}
async function send(url: string, init: RequestInit | undefined): Promise<Response> {
const headers = new Headers(init?.headers);
if (!headers.has("user-agent")) headers.set("user-agent", opts.userAgent);
for (let attempt = 0; ; attempt++) {
await acquire();
const res = await doFetch(url, { ...init, headers });
if (!retryable(res.status)) return res;
if (attempt >= maxRetries) throw new HttpError(res.status, await res.text(), url);
const delay = retryDelayMs(res, attempt, now());
await res.body?.cancel();
await sleep(delay);
}
}
return {
raw: (url, init) => send(url, init),
async json<T>(url: string, init?: RequestInit & { query?: Record<string, string | number | undefined> }): Promise<T> {
const { query, ...rest } = init ?? {};
const u = new URL(url);
for (const [k, v] of Object.entries(query ?? {})) if (v !== undefined) u.searchParams.set(k, String(v));
const headers = new Headers(rest.headers);
if (!headers.has("accept")) headers.set("accept", "application/json");
const res = await send(u.toString(), { ...rest, headers });
if (!res.ok) throw new HttpError(res.status, await res.text(), u.toString());
return (await res.json()) as T;
},
};
}
+24
View File
@@ -0,0 +1,24 @@
import type { PartialDate } from "./types.ts";
const RE = /^(\d{4})(?:-(\d{2})(?:-(\d{2})(T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?)?)?)?$/;
/** Parses by string only; never goes through Date, so no timezone shift. */
export function parsePartialDate(input: string | null | undefined): PartialDate | null {
if (input == null) return null;
const s = input.trim();
const m = RE.exec(s);
if (!m) return null;
const year = Number(m[1]);
if (m[2] === undefined) return { value: s, precision: "year" };
const month = Number(m[2]);
if (month < 1 || month > 12) return null;
if (m[3] === undefined) return { value: s, precision: "month" };
const day = Number(m[3]);
const dim = new Date(Date.UTC(year, month, 0)).getUTCDate();
if (day < 1 || day > dim) return null;
return { value: s, precision: "day" };
}
export function comparePartialDates(a: PartialDate, b: PartialDate): number {
return a.value < b.value ? -1 : a.value > b.value ? 1 : 0;
}
+60
View File
@@ -0,0 +1,60 @@
import { createHttpClient } from "./http.ts";
import type { PluginContext } from "./types.ts";
type Route = unknown | ((req: Request) => unknown);
/** Key is "METHOD full-url" or "METHOD url-without-query". Unknown routes throw. */
export function createFixtureHttp(routes: Record<string, Route>): { fetch: typeof fetch; calls: Request[] } {
const calls: Request[] = [];
const fn = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
const req = new Request(input, init);
calls.push(req);
const u = new URL(req.url);
const full = `${req.method} ${u.toString()}`;
const bare = `${req.method} ${u.origin}${u.pathname}`;
const key = full in routes ? full : bare in routes ? bare : null;
if (key === null) throw new Error(`no fixture route for ${full}`);
const r = routes[key];
const out = typeof r === "function" ? (r as (req: Request) => unknown)(req) : r;
return out instanceof Response ? out : Response.json(out);
};
return { fetch: fn as typeof fetch, calls };
}
export function createTestContext(
partial: Partial<PluginContext> & { fetch?: typeof fetch } = {},
): PluginContext & { saved: { credentials: Record<string, string>[]; cursor: string[] } } {
const { fetch: fetchImpl, ...overrides } = partial;
const saved = { credentials: [] as Record<string, string>[], cursor: [] as string[] };
let creds: Record<string, string> | null = null;
let cursor: string | null = null;
const ctx: PluginContext = {
http: createHttpClient({ userAgent: "sttracker-test/0", fetchImpl, sleep: async () => {} }),
log: () => {},
config: {},
credentials: {
get: async () => creds,
save: async (c) => {
creds = c;
saved.credentials.push(c);
},
},
cursor: {
get: async () => cursor,
set: async (c) => {
cursor = c;
saved.cursor.push(c);
},
},
now: () => new Date(),
signal: new AbortController().signal,
...overrides,
};
return Object.assign(ctx, { saved });
}
export async function collect<T>(it: AsyncIterable<T>): Promise<T[]> {
const out: T[] = [];
for await (const x of it) out.push(x);
return out;
}
+51
View File
@@ -0,0 +1,51 @@
export type MediaType =
| "book" | "comic" | "manga" | "game" | "movie" | "show" | "season" | "episode"
| "podcast" | "podcast_episode" | "audio_drama" | "artist" | "album" | "track"
| "video" | "other";
export type Status = "planned" | "in_progress" | "paused" | "completed" | "dropped" | "none";
export type Precision = "day" | "month" | "year" | "unknown";
export interface PartialDate { value: string; precision: Precision }
export type Capability = "pull" | "pullChanges" | "push" | "ownedLibrary" | "ingest" | "lookup" | "importFile" | "exportFile";
export interface ConfigField { key: string; label: string; type: "string" | "secret" | "number" | "boolean" | "select"; required?: boolean; options?: string[]; help?: string }
export interface PluginManifest { id: string; name: string; version: string; sdkVersion: 1; mediaTypes: MediaType[]; capabilities: Capability[]; config: ConfigField[]; attribution?: string }
export interface WorkHint { title: string; mediaType: MediaType; year?: number; creators?: { name: string; role?: string }[]; externalIds: Record<string, string>; coverUrl?: string; description?: string; parent?: WorkHint; position?: { season?: number; episode?: number; index?: number } }
export interface EntryFields { status?: Status; rating?: number | null; review?: string | null; notes?: string | null; favorite?: boolean; tags?: string[] }
export interface EngagementFields { startedAt?: PartialDate | null; finishedAt?: PartialDate | null; progress?: Record<string, number | string>; platform?: string | null; rating?: number | null; notes?: string | null }
export interface ListenFields { listenedAt: string; artist: string; track: string; album?: string; durationMs?: number; mbids?: { recording?: string; release?: string; artist?: string } }
interface Base { key: string; raw: unknown; deleted?: boolean; mergedIntoKey?: string }
export type SourceRecord =
| (Base & { kind: "entry"; work: WorkHint; entry: EntryFields })
| (Base & { kind: "engagement"; work: WorkHint; engagement: EngagementFields })
| (Base & { kind: "listen"; listen: ListenFields })
| (Base & { kind: "list"; list: { name: string; description?: string; items: { work: WorkHint; note?: string }[] } });
export interface HttpClient {
json<T>(url: string, init?: RequestInit & { query?: Record<string, string | number | undefined> }): Promise<T>;
raw(url: string, init?: RequestInit): Promise<Response>;
}
export interface PluginContext {
http: HttpClient;
log: (msg: string) => void;
config: Record<string, unknown>;
credentials: { get(): Promise<Record<string, string> | null>; save(c: Record<string, string>): Promise<void> };
cursor: { get(): Promise<string | null>; set(c: string): Promise<void> };
now(): Date;
signal: AbortSignal;
}
export interface DeviceAuth {
start(ctx: PluginContext): Promise<{ userCode: string; verificationUrl: string; deviceCode: string; intervalSec: number; expiresInSec: number }>;
poll(ctx: PluginContext, deviceCode: string): Promise<"pending" | Record<string, string>>;
}
export interface SourcePlugin {
manifest: PluginManifest;
deviceAuth?: DeviceAuth;
pull?(ctx: PluginContext): AsyncIterable<SourceRecord>;
pullChanges?(ctx: PluginContext): AsyncIterable<SourceRecord>;
lookup?(ctx: PluginContext, q: { title: string; mediaType?: MediaType; year?: number }): Promise<WorkHint[]>;
importFile?(ctx: PluginContext, file: { name: string; bytes: Uint8Array }): AsyncIterable<SourceRecord>;
}
export function definePlugin(p: SourcePlugin): SourcePlugin {
if (p.manifest.sdkVersion !== 1) throw new Error(`plugin "${p.manifest.id}": unsupported sdkVersion ${String(p.manifest.sdkVersion)}`);
if (typeof p.manifest.id !== "string" || p.manifest.id.trim() === "") throw new Error("plugin manifest.id must be a non-empty string");
return p;
}