refactor: rewrite on @punktfunk/plugin-kit — contract + plugin workspaces
The framework half of this plugin (engine lifecycle, config/state, host
reconcile, UI server, CLI) was ~80% identical to rom-manager's. That code is
now @punktfunk/plugin-kit; this replaces the local copy with it and adopts the
rom-manager blueprint layout.
contract/ Effect Schemas: the cross-process export contract (the C#
exporter's other half, with the schema-version guard as a decode
check), the config schema (one schema, Encoded = authored file
shape, defaults only via withDecodingDefaultKey), domain DTOs, the
HttpApi contract, API errors.
plugin/ src/domain = the pure core (locate/read, art, reconcile), ported
tests green before any Effect wiring; src/services = the kit's
ConfigService/CacheStore/SyncEngine/ProviderClient; definePluginKit
entry, kit CLI, auth-free dev API.
Preserved, because they are what makes playnite work at all: the ingest inbox
is still read FIRST (the de-privileged LocalService runner cannot reach the
interactive user's %APPDATA%), the host/dataurl/off art modes, and the
playnite:// launch hand-back. The C# exporter is untouched.
New: per-game include/exclude overrides, and an allow-listed /api/art proxy so
the SPA can render local Playnite covers — it serves only paths the currently
ingested export references.
Dropped: the standalone password UI server (console surface + CLI only, as in
rom-manager 0.3.0) and the devEntry flag.
Trap found and fixed here: the HttpApi encoder serialises `undefined` as
`null`, so a `Schema.optional` field the server omits cannot be decoded by the
client — silently in the SSE feed, loudly in the typed client. Every optional
EngineStatus field is `Schema.NullOr` with an explicit value on the wire.
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"name": "@playnite/contract",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"description": "The single source of truth shared by the plugin server and the UI: the cross-process export schema (the C# exporter's other half), the config schema, domain DTOs, the HttpApi contract, and API errors. Never published — bundled into the plugin, aliased into the UI.",
|
||||
"main": "./src/index.ts",
|
||||
"types": "./src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "^4.0.0-beta.99",
|
||||
"@punktfunk/plugin-kit": "^0.1.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"effect": "4.0.0-beta.99",
|
||||
"@punktfunk/plugin-kit": "^0.1.4",
|
||||
"typescript": "^5.9.3",
|
||||
"@types/bun": "^1.3.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
// The one HttpApi contract — HttpApiBuilder implements it server-side, AtomHttpApi
|
||||
// derives the UI's typed client + atoms from it. No hand-mirrored types anywhere.
|
||||
//
|
||||
// Two routes sit OUTSIDE the HttpApi because neither is JSON: the SSE status feed
|
||||
// (`effect/unstable/httpapi` has no event-stream media type at beta.99) and the cover-art
|
||||
// proxy (image bytes). Both are plain HttpRouter routes; their paths live here so the UI
|
||||
// and the server still share one definition.
|
||||
|
||||
import { ProviderEntry } from "@punktfunk/plugin-kit/wire";
|
||||
import { Schema } from "effect";
|
||||
import {
|
||||
HttpApi,
|
||||
HttpApiEndpoint,
|
||||
HttpApiGroup,
|
||||
HttpApiSchema,
|
||||
} from "effect/unstable/httpapi";
|
||||
import { EngineStatus, SyncReport } from "./domain.js";
|
||||
import { ConfigInvalid, ExportUnavailable, SyncInProgress } from "./errors.js";
|
||||
|
||||
/** GET /api/config + PUT /api/config response: the authored raw shape, verbatim.
|
||||
* The UI resolves it locally by decoding through the SHARED PlayniteConfigSchema. */
|
||||
export const ConfigPayload = Schema.Struct({ raw: Schema.Unknown });
|
||||
export type ConfigPayload = typeof ConfigPayload.Type;
|
||||
|
||||
/** GET /api/preview: the dry-run desired state (no art embedding, no host write). */
|
||||
export const PreviewResult = Schema.Struct({
|
||||
entries: Schema.Array(ProviderEntry),
|
||||
report: SyncReport,
|
||||
});
|
||||
export type PreviewResult = typeof PreviewResult.Type;
|
||||
|
||||
export const PlayniteApi = HttpApi.make("playnite")
|
||||
.add(
|
||||
HttpApiGroup.make("status").add(
|
||||
HttpApiEndpoint.get("get", "/api/status", { success: EngineStatus }),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiGroup.make("config")
|
||||
.add(
|
||||
HttpApiEndpoint.get("get", "/api/config", { success: ConfigPayload }),
|
||||
)
|
||||
.add(
|
||||
HttpApiEndpoint.put("put", "/api/config", {
|
||||
payload: Schema.Unknown,
|
||||
success: ConfigPayload,
|
||||
error: ConfigInvalid.pipe(HttpApiSchema.status(400)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiGroup.make("library").add(
|
||||
HttpApiEndpoint.get("preview", "/api/preview", {
|
||||
success: PreviewResult,
|
||||
error: ExportUnavailable.pipe(HttpApiSchema.status(503)),
|
||||
}),
|
||||
),
|
||||
)
|
||||
.add(
|
||||
HttpApiGroup.make("sync").add(
|
||||
HttpApiEndpoint.post("run", "/api/sync", {
|
||||
success: SyncReport,
|
||||
error: Schema.Union([
|
||||
SyncInProgress.pipe(HttpApiSchema.status(409)),
|
||||
ExportUnavailable.pipe(HttpApiSchema.status(503)),
|
||||
]),
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
/** The SSE status feed. Frames: `event: status`, data = EngineStatus JSON. */
|
||||
export const EVENTS_PATH = "/api/events";
|
||||
export const EVENTS_EVENT = "status";
|
||||
|
||||
/**
|
||||
* The cover-art proxy. Playnite art is LOCAL files on the host, which a browser cannot
|
||||
* load — so the SPA asks the plugin for them by path. The server only serves a path that
|
||||
* appears in the currently-ingested export (an allow-list, not an arbitrary file read).
|
||||
*/
|
||||
export const ART_PATH = "/api/art";
|
||||
|
||||
/**
|
||||
* Resolve one of the exporter's art references to something an `<img src>` can load.
|
||||
* Remote art (Playnite passes `http(s)` references through verbatim) and `data:` URLs are
|
||||
* already loadable; a local path goes through the allow-listed proxy above.
|
||||
*/
|
||||
export const artUrl = (
|
||||
prefix: string,
|
||||
reference: string | null | undefined,
|
||||
): string | undefined => {
|
||||
if (reference === null || reference === undefined || reference === "") {
|
||||
return undefined;
|
||||
}
|
||||
if (/^(https?:|data:)/i.test(reference)) return reference;
|
||||
return `${prefix}${ART_PATH}?path=${encodeURIComponent(reference)}`;
|
||||
};
|
||||
@@ -0,0 +1,92 @@
|
||||
// The config model. ONE schema: the Encoded side IS the authored file shape (RawConfig),
|
||||
// the Type side is the fully-resolved shape the engine consumes (Config). Defaults live
|
||||
// ONLY here (`withDecodingDefaultKey` + encodingStrategy "omit") — the kit's ConfigService
|
||||
// persists the raw shape verbatim, so a UI save never bakes defaults into the file.
|
||||
//
|
||||
// Dropped vs 0.1.x: `ui.*` (the standalone password server is gone — console surface + CLI
|
||||
// only) and `devEntry` (a dev flag has no place in the prod shape; `preview` shows the
|
||||
// round-trip instead). Added: `gameOverrides`, the Library page's per-game include toggle.
|
||||
// Stale keys in existing files are tolerated on decode and dropped by the next save.
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
|
||||
schema.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(value), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
);
|
||||
|
||||
/** When and how often we re-read the export and reconcile. */
|
||||
export const SyncOptions = Schema.Struct({
|
||||
/** Interval poll in minutes — the safety net under the exporter-driven file watch. */
|
||||
pollMinutes: withDefault(Schema.Number, 5),
|
||||
/** Watch the ingest inbox and reconcile on change (the primary trigger). */
|
||||
watch: withDefault(Schema.Boolean, true),
|
||||
/** Debounce window for coalescing watch/poll-triggered re-reads (ms). */
|
||||
debounceMs: withDefault(Schema.Number, 1500),
|
||||
});
|
||||
export type SyncOptions = typeof SyncOptions.Type;
|
||||
|
||||
/** Which Playnite games become library entries. */
|
||||
export const FilterOptions = Schema.Struct({
|
||||
/** Only sync games Playnite marks installed. Off = include not-yet-installed titles. */
|
||||
installedOnly: withDefault(Schema.Boolean, true),
|
||||
/** Include games flagged Hidden in Playnite. */
|
||||
includeHidden: withDefault(Schema.Boolean, false),
|
||||
/** If non-empty, keep ONLY games whose source is in this list ("Steam", "GOG", …). */
|
||||
sources: withDefault(Schema.Array(Schema.String), []),
|
||||
/** Drop games whose source is in this list (applied after `sources`). */
|
||||
excludeSources: withDefault(Schema.Array(Schema.String), []),
|
||||
});
|
||||
export type FilterOptions = typeof FilterOptions.Type;
|
||||
|
||||
/**
|
||||
* How cover art reaches the clients. Playnite art is local files on the host:
|
||||
* - `host` (default): send the local file PATH; the host serves the bytes through its art
|
||||
* proxy (`/library/art/…`, like Steam art). The reconcile payload carries no image data,
|
||||
* so this scales to a library of any size — the recommended mode.
|
||||
* - `dataurl`: inline each cover as a size-capped `data:` URL. Self-contained but does NOT
|
||||
* scale — a large library blows past the host's reconcile body limit. Small libraries only.
|
||||
* - `off`: sync titles with no art.
|
||||
*/
|
||||
export const ArtMode = Schema.Literals(["host", "dataurl", "off"]);
|
||||
export type ArtMode = typeof ArtMode.Type;
|
||||
|
||||
export const ArtOptions = Schema.Struct({
|
||||
mode: withDefault(ArtMode, "host"),
|
||||
/** Skip an image whose file is larger than this (bytes) — `dataurl` mode only. */
|
||||
maxBytes: withDefault(Schema.Number, 1_500_000),
|
||||
/** Also send the Playnite background as `hero` art (usually large — off by default). */
|
||||
includeBackground: withDefault(Schema.Boolean, false),
|
||||
});
|
||||
export type ArtOptions = typeof ArtOptions.Type;
|
||||
|
||||
/** A per-game override, keyed by the Playnite game Guid (the reconcile `external_id`). */
|
||||
export const GameOverride = Schema.Struct({
|
||||
exclude: Schema.optionalKey(Schema.Boolean),
|
||||
});
|
||||
export type GameOverride = typeof GameOverride.Type;
|
||||
|
||||
export const PlayniteConfigSchema = Schema.Struct({
|
||||
/** Override the auto-detected Playnite data directory (`%APPDATA%\\Playnite`). Point it
|
||||
* at a portable install, or at the interactive user's profile when the runner runs as
|
||||
* a different user. The ingest inbox is still read FIRST. */
|
||||
playniteDir: Schema.optionalKey(Schema.String),
|
||||
sync: withDefault(SyncOptions, {}),
|
||||
filter: withDefault(FilterOptions, {}),
|
||||
art: withDefault(ArtOptions, {}),
|
||||
gameOverrides: withDefault(Schema.Record(Schema.String, GameOverride), {}),
|
||||
});
|
||||
|
||||
/** The fully-resolved config the engine consumes. */
|
||||
export type Config = typeof PlayniteConfigSchema.Type;
|
||||
/** The on-disk config as authored (all optional). */
|
||||
export type RawConfig = typeof PlayniteConfigSchema.Encoded;
|
||||
|
||||
/**
|
||||
* Resolve an authored raw config to the total shape (defaults filled). Throws on an
|
||||
* invalid shape — the sync path for tests and the UI; the server side uses the kit's
|
||||
* ConfigService (Effect + typed errors) instead.
|
||||
*/
|
||||
export const resolveConfig = (raw: unknown): Config =>
|
||||
Schema.decodeUnknownSync(PlayniteConfigSchema)(raw);
|
||||
@@ -0,0 +1,88 @@
|
||||
// Domain DTOs shared by the plugin server and the UI — Schemas are the source of truth,
|
||||
// the derived types keep the original domain names so the pure core reads unchanged.
|
||||
//
|
||||
// Trap, measured on this API (rom-manager's note only covers half of it): `optionalKey`
|
||||
// rejects a present-but-`undefined` value and 400s at the RESPONSE encoder — but
|
||||
// `Schema.optional` is not the fix, because the HttpApi encoder serialises `undefined` as
|
||||
// `null`, and `string | undefined` then refuses that null when the CLIENT decodes it. The
|
||||
// failure is silent in the SSE feed (invalid frames are skipped) and loud in the typed
|
||||
// client. So: every "may be absent" field here is `Schema.NullOr`, and the server always
|
||||
// sends an explicit value. JSON-native, symmetric, no undefined anywhere on the wire.
|
||||
import { Schema } from "effect";
|
||||
|
||||
/** Where the export was found — drives the console's "exporter connected?" banner. */
|
||||
export const Location = Schema.Struct({
|
||||
/** The resolved source dir (the ingest inbox, or a Playnite data dir), or null. */
|
||||
dir: Schema.NullOr(Schema.String),
|
||||
/** Every dir considered, in probe order (the UI's diagnostics view). */
|
||||
candidates: Schema.Array(Schema.String),
|
||||
/** The resolved export file, or null if the exporter hasn't written one yet. */
|
||||
file: Schema.NullOr(Schema.String),
|
||||
/** mtime (epoch ms) of the export file, or null. */
|
||||
mtime: Schema.NullOr(Schema.Number),
|
||||
/** True when the file came from `<config_dir>/ingest/playnite` (the reliable lane). */
|
||||
viaIngest: Schema.Boolean,
|
||||
});
|
||||
export type Location = typeof Location.Type;
|
||||
|
||||
/** A game the compute rejected, with a human reason (Library page, CLI). */
|
||||
export const SkippedGame = Schema.Struct({
|
||||
id: Schema.String,
|
||||
title: Schema.String,
|
||||
reason: Schema.String,
|
||||
});
|
||||
export type SkippedGame = typeof SkippedGame.Type;
|
||||
|
||||
/** A summary of one desired-state compute (Overview + Library pages). */
|
||||
export const SyncReport = Schema.Struct({
|
||||
/** ISO timestamp the exporter stamped on the source export. */
|
||||
generatedAt: Schema.String,
|
||||
/** Schema version of the export that produced this report. */
|
||||
schemaVersion: Schema.Number,
|
||||
/** Games in the export before filtering. */
|
||||
considered: Schema.Number,
|
||||
/** Entries in the reconcile payload. */
|
||||
included: Schema.Number,
|
||||
skipped: Schema.Array(SkippedGame),
|
||||
/** Dropped by an explicit per-game override (the Library page's toggle). */
|
||||
excluded: Schema.Array(
|
||||
Schema.Struct({ id: Schema.String, title: Schema.String }),
|
||||
),
|
||||
warnings: Schema.Array(Schema.String),
|
||||
/** Included entries per Playnite source ("Steam", "GOG", "(none)", …). */
|
||||
perSource: Schema.Record(Schema.String, Schema.Number),
|
||||
});
|
||||
export type SyncReport = typeof SyncReport.Type;
|
||||
|
||||
export const LastSync = Schema.Struct({
|
||||
fingerprint: Schema.String,
|
||||
count: Schema.Number,
|
||||
at: Schema.Number,
|
||||
});
|
||||
export type LastSync = typeof LastSync.Type;
|
||||
|
||||
/** The engine status the Overview page renders and the SSE feed streams. */
|
||||
export const EngineStatus = Schema.Struct({
|
||||
platform: Schema.String,
|
||||
location: Location,
|
||||
/** Last read/decode error (missing export, corruption, too-new schema), else null. */
|
||||
exportError: Schema.NullOr(Schema.String),
|
||||
/** Playnite's own version + mode, as stamped on the last readable export. */
|
||||
playnite: Schema.NullOr(
|
||||
Schema.Struct({
|
||||
version: Schema.NullOr(Schema.String),
|
||||
mode: Schema.NullOr(Schema.String),
|
||||
}),
|
||||
),
|
||||
artMode: Schema.String,
|
||||
syncing: Schema.Boolean,
|
||||
lastSync: Schema.NullOr(LastSync),
|
||||
lastReport: Schema.NullOr(SyncReport),
|
||||
paths: Schema.Struct({
|
||||
dir: Schema.String,
|
||||
config: Schema.String,
|
||||
cache: Schema.String,
|
||||
ingest: Schema.String,
|
||||
}),
|
||||
});
|
||||
export type EngineStatus = typeof EngineStatus.Type;
|
||||
@@ -0,0 +1,25 @@
|
||||
// API errors — Schema-backed so they cross the wire typed; status set per-endpoint via
|
||||
// HttpApiSchema.status in api.ts.
|
||||
import { Schema } from "effect";
|
||||
|
||||
/** PUT /api/config body failed schema validation. → 400 */
|
||||
export class ConfigInvalid extends Schema.TaggedErrorClass<ConfigInvalid>()(
|
||||
"ConfigInvalid",
|
||||
{ issues: Schema.String },
|
||||
) {}
|
||||
|
||||
/** A sync pass is already running (the trigger was coalesced). → 409 */
|
||||
export class SyncInProgress extends Schema.TaggedErrorClass<SyncInProgress>()(
|
||||
"SyncInProgress",
|
||||
{},
|
||||
) {}
|
||||
|
||||
/**
|
||||
* No exporter output was found, or the file is unreadable / not a valid export / stamped
|
||||
* with a schema this plugin does not understand. → 503, because it is a "the other half
|
||||
* isn't installed yet" state rather than a server fault: the UI turns it into onboarding.
|
||||
*/
|
||||
export class ExportUnavailable extends Schema.TaggedErrorClass<ExportUnavailable>()(
|
||||
"ExportUnavailable",
|
||||
{ message: Schema.String },
|
||||
) {}
|
||||
@@ -0,0 +1,72 @@
|
||||
// The CROSS-PROCESS contract: the other half of this file is C#.
|
||||
//
|
||||
// The Playnite exporter (`exporter/`, a GenericPlugin) writes `punktfunk-library.json` in
|
||||
// exactly this shape on every library change; this plugin decodes it. Keep it in lockstep
|
||||
// with `exporter/Model.cs` — the `[SerializationPropertyName]` attributes there pin the
|
||||
// JSON keys these Schemas read. Bump `SCHEMA` on any breaking change; the exporter stamps
|
||||
// the version it wrote and the `schema` check below turns a too-new file into a decode
|
||||
// error rather than a silent mis-read.
|
||||
//
|
||||
// Leniency is deliberate and asymmetric: every field except `id`, `schema` and `games` has
|
||||
// a decoding default, so an exporter that predates (or postdates) a field addition still
|
||||
// decodes. Per-GAME validity — a malformed Guid, a blank title — is NOT a decode failure:
|
||||
// those become skip reasons in the pure core, so one bad row can never cost you the whole
|
||||
// library.
|
||||
import { Effect, Schema } from "effect";
|
||||
|
||||
/** The file the exporter writes (ingest inbox, and its own `ExtensionsData/<guid>/`). */
|
||||
export const EXPORT_FILE = "punktfunk-library.json";
|
||||
|
||||
/** Schema version this reader understands. A newer major is refused (see `LibraryExport`). */
|
||||
export const SCHEMA = 1;
|
||||
|
||||
const withDefault = <S extends Schema.Top>(schema: S, value: S["Encoded"]) =>
|
||||
schema.pipe(
|
||||
Schema.withDecodingDefaultKey(Effect.succeed(value), {
|
||||
encodingStrategy: "omit",
|
||||
}),
|
||||
);
|
||||
|
||||
/** One game as the Playnite exporter sees it. Art fields are ABSOLUTE local paths on the
|
||||
* host box (resolved via `IPlayniteAPI.Database.GetFullFilePath`) — except a remote
|
||||
* `http(s)` art URL, which the exporter passes through verbatim. */
|
||||
export const ExportedGame = Schema.Struct({
|
||||
/** Playnite's stable game `Guid` ("d" format) — our reconcile `external_id`. */
|
||||
id: Schema.String,
|
||||
name: withDefault(Schema.String, ""),
|
||||
/** Whether Playnite considers the game installed (drives the `installedOnly` filter). */
|
||||
installed: withDefault(Schema.Boolean, false),
|
||||
/** Playnite's per-game "Hidden" flag. */
|
||||
hidden: withDefault(Schema.Boolean, false),
|
||||
/** The library source name ("Steam", "GOG", "Epic", emulator name, …) or null. */
|
||||
source: withDefault(Schema.NullOr(Schema.String), null),
|
||||
/** Platform display names ("PC (Windows)", "Nintendo Switch", …). */
|
||||
platforms: withDefault(Schema.Array(Schema.String), []),
|
||||
cover: withDefault(Schema.NullOr(Schema.String), null),
|
||||
background: withDefault(Schema.NullOr(Schema.String), null),
|
||||
icon: withDefault(Schema.NullOr(Schema.String), null),
|
||||
});
|
||||
export type ExportedGame = typeof ExportedGame.Type;
|
||||
|
||||
export const PlayniteInfo = Schema.Struct({
|
||||
version: withDefault(Schema.NullOr(Schema.String), null),
|
||||
/** "Desktop" | "Fullscreen" — informational. */
|
||||
mode: withDefault(Schema.NullOr(Schema.String), null),
|
||||
});
|
||||
export type PlayniteInfo = typeof PlayniteInfo.Type;
|
||||
|
||||
/** The whole export document. Decoding it IS the schema-version guard. */
|
||||
export const LibraryExport = Schema.Struct({
|
||||
schema: Schema.Number.pipe(
|
||||
Schema.check(
|
||||
Schema.isLessThanOrEqualTo(SCHEMA, {
|
||||
description: `a punktfunk-library.json schema this plugin understands (≤ ${SCHEMA}) — a higher one means the Playnite exporter is newer than the plugin; update the plugin`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
/** ISO-8601 timestamp the exporter stamped on this document. */
|
||||
generatedAt: withDefault(Schema.String, ""),
|
||||
playnite: withDefault(PlayniteInfo, {}),
|
||||
games: Schema.Array(ExportedGame),
|
||||
});
|
||||
export type LibraryExport = typeof LibraryExport.Type;
|
||||
@@ -0,0 +1,8 @@
|
||||
// @playnite/contract — the shared source of truth (the cross-process export schema, the
|
||||
// config schema, domain DTOs, the HttpApi contract, API errors). Bundled into the plugin,
|
||||
// aliased into the UI.
|
||||
export * from "./api.js";
|
||||
export * from "./config.js";
|
||||
export * from "./domain.js";
|
||||
export * from "./errors.js";
|
||||
export * from "./export.js";
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"lib": ["ES2022"],
|
||||
"strict": true,
|
||||
"noUncheckedIndexedAccess": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true,
|
||||
"types": ["bun"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
Reference in New Issue
Block a user