feat(sdk): plugin contract, http client, fixture harness
Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
27ec24eb4b
commit
a14c4e7747
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user