feat(ui): Effect-native data layer + zero-host mock transport

AtomHttpApi.Service derives the typed client straight from the shared
RomManagerApi contract — no hand-mirrored types, no react-query. Queries
carry reactivity keys; mutations pass them per-call (constants exported
beside each mutation). liveStatusAtom merges the kit sseAtom feed over
GET /api/status; statusEventsAtom folds frames into the activity feed.
useConfigDraft keeps a raw-config working copy (defaults never baked in)
with dirty tracking and toast-reporting save.

Mock mode is a full in-memory RomManagerApi as an HttpClient layer plus
a scenario ticker (healthy/firstRun/syncing/errors), registered through
a fixture-free seam so production chunks stay clean — mocks/http.ts is
only imported behind import.meta.env.DEV and by Storybook. Scenario and
mode atoms are keepAlive: the transport reads them outside the reactive
graph and registry-seeded values must survive pages that never subscribe
them.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 19:41:36 +02:00
parent 8856df7e8d
commit bcff17a718
8 changed files with 921 additions and 0 deletions
+155
View File
@@ -0,0 +1,155 @@
// Every atom the pages subscribe to. Queries invalidate via reactivity keys; the
// mutation constants below document which keys each mutation must pass at call time
// (AtomHttpApi invalidation is per-call: `set({ ..., reactivityKeys: [...KEYS] })`).
import { sseAtom } from "@punktfunk/plugin-kit/react";
import { EngineStatus, EVENTS_EVENT, EVENTS_PATH } from "@rom-manager/contract";
import { AsyncResult, Atom } from "effect/unstable/reactivity";
import { installedMockStatusStream } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
import { Api, prefix } from "./client";
// ── Queries ────────────────────────────────────────────────────────────────────
export const statusAtom = Api.query("status", "get", {
reactivityKeys: ["status"],
});
export const configAtom = Api.query("config", "get", {
reactivityKeys: ["config"],
});
export const previewAtom = Api.query("library", "preview", {
reactivityKeys: ["preview"],
});
export const platformsAtom = Api.query("catalog", "platforms", {
reactivityKeys: ["catalog"],
timeToLive: "5 minutes",
});
export const emulatorsAtom = Api.query("catalog", "emulators", {
reactivityKeys: ["emulators"],
});
// ── Mutations + the keys they invalidate ───────────────────────────────────────
export const saveConfig = Api.mutation("config", "put");
/** A config save changes the desired state everywhere derived from it. */
export const SAVE_CONFIG_KEYS = ["config", "preview", "status"] as const;
export const runSync = Api.mutation("sync", "run");
export const RUN_SYNC_KEYS = ["status", "preview"] as const;
export const runDetect = Api.mutation("catalog", "detect");
export const RUN_DETECT_KEYS = ["emulators", "status"] as const;
// ── Live status (SSE with polling fallback) ────────────────────────────────────
/** The real SSE feed. Only mounted in live mode (the mock branch never `get`s it). */
const liveStatusStream = sseAtom({
url: `${prefix}${EVENTS_PATH}`,
event: EVENTS_EVENT,
schema: EngineStatus,
});
/**
* The status frame stream, mode-independent: SSE in live mode, the fixture ticker in
* mock mode. `undefined` until the first frame arrives.
*/
export const statusStreamAtom: Atom.Atom<EngineStatus | undefined> = Atom.make(
(get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockStatusStream();
return mock === undefined ? undefined : get(mock);
}
return get(liveStatusStream);
},
);
/**
* What pages actually render: the freshest engine status — the latest SSE frame once
* one has arrived, the plain GET /api/status result before that (and as error/skeleton
* source, since the SSE stream never fails).
*/
export const liveStatusAtom: Atom.Atom<Atom.Type<typeof statusAtom>> =
Atom.make((get) => {
const frame = get(statusStreamAtom);
if (frame !== undefined) {
return AsyncResult.success<EngineStatus, Atom.Failure<typeof statusAtom>>(
frame,
);
}
return get(statusAtom);
});
// ── Status transitions → activity feed ─────────────────────────────────────────
export type StatusEvent = {
readonly id: string;
readonly at: number;
readonly text: string;
readonly level: "info" | "warn" | "error";
};
const EVENT_LIMIT = 50;
/**
* Folds the status frame stream into a bounded activity list for the Overview feed —
* client-side only, no history endpoint: connect, sync start/finish (with the report's
* count), report warnings, detection runs.
*/
export const statusEventsAtom: Atom.Atom<ReadonlyArray<StatusEvent>> =
Atom.make((get) => {
let prev: EngineStatus | undefined;
let items: ReadonlyArray<StatusEvent> = [];
let seq = 0;
get.subscribe(
statusStreamAtom,
(frame) => {
if (frame === undefined) return;
const next: Array<StatusEvent> = [];
const push = (text: string, level: StatusEvent["level"] = "info") =>
next.push({ id: `ev-${seq++}`, at: Date.now(), text, level });
if (prev === undefined) {
const n = frame.rootsConfigured;
push(`Engine connected — ${n} ROM ${n === 1 ? "source" : "sources"}`);
if (frame.syncing) push("Sync running");
} else {
if (!prev.syncing && frame.syncing) push("Sync started");
if (prev.syncing && !frame.syncing) {
const report = frame.lastReport;
if (report === undefined) {
push("Sync finished");
} else {
push(
`Reconciled ${report.included} ${report.included === 1 ? "title" : "titles"}`,
);
for (const warning of report.warnings) push(warning, "warn");
if (report.overWarn) {
push(
`Library is over the warning threshold (${report.included} entries)`,
"warn",
);
}
}
}
if (
frame.detectedAt !== undefined &&
frame.detectedAt !== prev.detectedAt
) {
push("Emulator detection finished");
}
}
prev = frame;
if (next.length > 0) {
items = [...items, ...next].slice(-EVENT_LIMIT);
get.setSelf(items);
}
},
{ immediate: true },
);
return items;
});
+42
View File
@@ -0,0 +1,42 @@
// The one place the UI talks HTTP: a typed client + atom factory derived from the
// SHARED HttpApi contract via AtomHttpApi. No hand-mirrored types, no fetch calls in
// pages — endpoints, payloads, and errors all flow from @rom-manager/contract.
import { resolvePluginBase } from "@punktfunk/plugin-kit/react";
import { RomManagerApi } from "@rom-manager/contract";
import {
FetchHttpClient,
HttpClient,
HttpClientRequest,
} from "effect/unstable/http";
import { AtomHttpApi } from "effect/unstable/reactivity";
import { installedMockHttpLayer } from "@/mocks/register";
import { apiModeAtom } from "@/mocks/scenario";
/**
* "/plugin-ui/<id>" behind the console proxy, "" in dev — Vite serves at "/", so
* relative fetches hit the dev-server proxy (live mode) or the mock layer (mock mode).
*/
export const prefix = resolvePluginBase();
export class Api extends AtomHttpApi.Service<Api>()("RomApi", {
api: RomManagerApi,
// Mock mode swaps the transport UNDER the same typed client, so pages cannot tell
// fixtures from a live host. The mock layer arrives via mocks/register (dev-only
// import); if it is not installed we fall through to fetch — a loud, honest failure.
httpClient: (get) => {
if (get(apiModeAtom) === "mock") {
const mock = installedMockHttpLayer(get);
if (mock !== undefined) return mock;
}
return FetchHttpClient.layer;
},
// prependUrl("") breaks URL joining — only install the transform when proxied.
...(prefix !== ""
? {
transformClient: HttpClient.mapRequest(
HttpClientRequest.prependUrl(prefix),
),
}
: {}),
}) {}
+101
View File
@@ -0,0 +1,101 @@
// The config draft: a local raw-config working copy seeded from GET /api/config.
// Edits patch the RAW (authored) shape — defaults are never baked in; `resolved`
// decodes the draft through the SHARED RomConfigSchema so controls can display
// effective values (e.g. pollMinutes 15 when the file omits it).
import { useAtomSet, useAtomValue } from "@effect/atom-react";
import {
type Config,
type RawConfig,
resolveConfig,
} from "@rom-manager/contract";
import { toast } from "@unom/ui/toast";
import { Cause, Exit } from "effect";
import { AsyncResult } from "effect/unstable/reactivity";
import { useCallback, useEffect, useMemo, useState } from "react";
import { errorText } from "@/lib/errors";
import { configAtom, SAVE_CONFIG_KEYS, saveConfig } from "./atoms";
export type ConfigDraft = {
/** False until GET /api/config has succeeded once (render a skeleton). */
readonly loaded: boolean;
/** The working copy of the authored (raw) config. `{}` until loaded. */
readonly draft: RawConfig;
/** The draft decoded through RomConfigSchema (defaults filled), if valid. */
readonly resolved: Config | undefined;
readonly dirty: boolean;
readonly saving: boolean;
readonly patch: (patch: Partial<RawConfig>) => void;
readonly save: () => void;
readonly discard: () => void;
};
type DraftState = {
readonly baseline: RawConfig;
readonly draft: RawConfig;
};
export const useConfigDraft = (): ConfigDraft => {
const result = useAtomValue(configAtom);
const saveResult = useAtomValue(saveConfig);
const write = useAtomSet(saveConfig, { mode: "promiseExit" });
const [state, setState] = useState<DraftState | null>(null);
// Seed once from the first successful load; later refetches (our own saves) must
// not clobber in-progress edits.
useEffect(() => {
if (state === null && AsyncResult.isSuccess(result)) {
const raw = result.value.raw as RawConfig;
setState({ baseline: raw, draft: raw });
}
}, [result, state]);
const draft = state?.draft ?? {};
const dirty =
state !== null &&
JSON.stringify(state.draft) !== JSON.stringify(state.baseline);
const resolved = useMemo(() => {
try {
return resolveConfig(draft);
} catch {
return undefined;
}
}, [draft]);
const patch = useCallback((p: Partial<RawConfig>) => {
setState((s) => (s === null ? s : { ...s, draft: { ...s.draft, ...p } }));
}, []);
const discard = useCallback(() => {
setState((s) => (s === null ? s : { ...s, draft: s.baseline }));
}, []);
const save = useCallback(() => {
if (state === null) return;
const raw = state.draft;
void write({ payload: raw, reactivityKeys: [...SAVE_CONFIG_KEYS] }).then(
(exit) => {
if (Exit.isSuccess(exit)) {
setState({ baseline: raw, draft: raw });
toast.success("Configuration saved");
} else {
toast.error("Save failed", {
description: errorText(Cause.squash(exit.cause)),
});
}
},
);
}, [state, write]);
return {
loaded: state !== null,
draft,
resolved,
dirty,
saving: AsyncResult.isWaiting(saveResult),
patch,
save,
discard,
};
};
+18
View File
@@ -0,0 +1,18 @@
// One place to turn a typed API error (or any transport failure) into a sentence.
export const errorText = (error: unknown): string => {
if (typeof error === "object" && error !== null) {
const e = error as {
_tag?: string;
issues?: string;
message?: string;
reason?: unknown;
};
if (e._tag === "ConfigInvalid" && typeof e.issues === "string") {
return e.issues;
}
if (e._tag === "SyncInProgress") return "A sync pass is already running";
if (typeof e.message === "string" && e.message.length > 0) return e.message;
if (typeof e._tag === "string") return e._tag;
}
return String(error);
};
+383
View File
@@ -0,0 +1,383 @@
// Fixture data for the zero-host dev loop + Storybook. Typed AGAINST THE CONTRACT —
// if the contract moves, this file fails typecheck, exactly like the real server would.
// Never imported by production app code (see mocks/register.ts for the seam).
import type {
DetectedEmulator,
EmulatorsPayload,
EngineStatus,
Platform,
PreviewResult,
RawConfig,
SyncReport,
} from "@rom-manager/contract";
import type { Scenario } from "./scenario";
type ProviderEntry = PreviewResult["entries"][number];
// ── Games ──────────────────────────────────────────────────────────────────────
const portrait = (title: string): string =>
`https://placehold.co/600x900/1c1530/a79ff8?text=${encodeURIComponent(title).replace(/%20/g, "+")}`;
const game = (
platform: string,
file: string,
title: string,
launch: string,
): ProviderEntry => ({
external_id: `${platform}/${file}`,
title,
art: { portrait: portrait(title) },
launch: { kind: "command", value: launch },
});
const retroarch = (core: string, platform: string, file: string): string =>
`/usr/bin/retroarch -f -L ${core} '/roms/${platform}/${file}'`;
/** Every game the mock scan finds (13 — one is excluded via config by default). */
export const GAMES: ReadonlyArray<ProviderEntry> = [
game(
"snes",
"Chrono Trigger (USA).sfc",
"Chrono Trigger",
retroarch("snes9x", "snes", "Chrono Trigger (USA).sfc"),
),
game(
"snes",
"Super Metroid (USA).sfc",
"Super Metroid",
retroarch("snes9x", "snes", "Super Metroid (USA).sfc"),
),
game(
"snes",
"The Legend of Zelda - A Link to the Past (USA).sfc",
"The Legend of Zelda: A Link to the Past",
retroarch(
"snes9x",
"snes",
"The Legend of Zelda - A Link to the Past (USA).sfc",
),
),
game(
"snes",
"EarthBound (USA).sfc",
"EarthBound",
retroarch("snes9x", "snes", "EarthBound (USA).sfc"),
),
game(
"snes",
"Super Mario World (USA).sfc",
"Super Mario World",
retroarch("snes9x", "snes", "Super Mario World (USA).sfc"),
),
game(
"n64",
"Super Mario 64 (USA).z64",
"Super Mario 64",
retroarch("mupen64plus_next", "n64", "Super Mario 64 (USA).z64"),
),
game(
"n64",
"The Legend of Zelda - Ocarina of Time (USA).z64",
"The Legend of Zelda: Ocarina of Time",
retroarch(
"mupen64plus_next",
"n64",
"The Legend of Zelda - Ocarina of Time (USA).z64",
),
),
game(
"n64",
"Mario Kart 64 (USA).z64",
"Mario Kart 64",
retroarch("mupen64plus_next", "n64", "Mario Kart 64 (USA).z64"),
),
game(
"n64",
"GoldenEye 007 (USA).z64",
"GoldenEye 007",
retroarch("mupen64plus_next", "n64", "GoldenEye 007 (USA).z64"),
),
game(
"ps1",
"Final Fantasy VII (USA).chd",
"Final Fantasy VII",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Final Fantasy VII (USA).chd'",
),
game(
"ps1",
"Castlevania - Symphony of the Night (USA).chd",
"Castlevania: Symphony of the Night",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Castlevania - Symphony of the Night (USA).chd'",
),
game(
"ps1",
"Metal Gear Solid (USA).chd",
"Metal Gear Solid",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Metal Gear Solid (USA).chd'",
),
game(
"ps1",
"Vagrant Story (USA).chd",
"Vagrant Story",
"/usr/bin/duckstation-qt -batch -fullscreen '/roms/ps1/Vagrant Story (USA).chd'",
),
];
// ── Catalog ────────────────────────────────────────────────────────────────────
export const PLATFORMS: ReadonlyArray<Platform> = [
{
id: "snes",
name: "Super Nintendo",
extensions: ["sfc", "smc", "zip"],
libretroSystem: "Nintendo - Super Nintendo Entertainment System",
defaultLaunch: { emulator: "retroarch", core: "snes9x" },
},
{
id: "n64",
name: "Nintendo 64",
extensions: ["z64", "n64", "v64", "zip"],
libretroSystem: "Nintendo - Nintendo 64",
defaultLaunch: { emulator: "retroarch", core: "mupen64plus_next" },
},
{
id: "ps1",
name: "PlayStation",
extensions: ["chd", "cue", "pbp", "m3u"],
libretroSystem: "Sony - PlayStation",
defaultLaunch: { emulator: "duckstation" },
disc: true,
},
{
id: "gamecube",
name: "GameCube",
extensions: ["iso", "rvz", "gcz"],
libretroSystem: "Nintendo - GameCube",
defaultLaunch: { emulator: "dolphin" },
disc: true,
},
];
export const DETECTED_EMULATORS: ReadonlyArray<DetectedEmulator> = [
{
id: "retroarch",
name: "RetroArch",
template: "{exe} -f -L {core} {rom}",
supportsArchives: true,
exeToken: "retroarch",
exePath: "/usr/bin/retroarch",
via: "path",
coresDir: "/home/deck/.config/retroarch/cores",
cores: ["snes9x", "mupen64plus_next", "mesen", "gambatte", "mgba"],
},
{
id: "duckstation",
name: "DuckStation",
template: "{exe} -batch -fullscreen {rom}",
supportsArchives: false,
exeToken: "duckstation-qt",
exePath: "/usr/bin/duckstation-qt",
via: "path",
},
];
const DETECTED_AT = Date.now() - 2 * 60 * 60 * 1000;
export const emulatorsFor = (scenario: Scenario): EmulatorsPayload =>
scenario === "firstRun"
? { detected: [], detectedAt: null }
: { detected: [...DETECTED_EMULATORS], detectedAt: DETECTED_AT };
// ── Config ─────────────────────────────────────────────────────────────────────
const HEALTHY_CONFIG: RawConfig = {
roots: [
{ dir: "/roms/snes", platform: "snes" },
{ dir: "/roms/n64", platform: "n64" },
{ dir: "/roms/ps1", platform: "ps1", excludes: ["**/*.bak"] },
],
gameOverrides: {
"snes/Super Mario World (USA).sfc": { exclude: true },
},
art: { enabled: true, provider: "steamgriddb", steamGridDbKey: "sgdb-demo" },
};
export const configFor = (scenario: Scenario): RawConfig =>
scenario === "firstRun" ? {} : HEALTHY_CONFIG;
// ── Preview + report (derived from the current raw config, so toggles round-trip) ──
const platformOf = (externalId: string): string =>
externalId.split("/")[0] ?? "unknown";
const SKIPPED = [
{
external_id: "n64/GoldenEye 007 (Europe).z64",
title: "GoldenEye 007 (Europe)",
reason: "region duplicate of GoldenEye 007 (USA)",
},
{
external_id: "ps1/Final Fantasy VII (USA) (Disc 2).chd",
title: "Final Fantasy VII (Disc 2)",
reason: "multi-disc: only disc 1 is listed",
},
];
export const previewFor = (
scenario: Scenario,
raw: RawConfig,
): PreviewResult => {
if (scenario === "firstRun") {
return {
entries: [],
report: {
considered: 0,
included: 0,
skipped: [],
excluded: [],
warnings: [],
truncated: 0,
perPlatform: {},
overWarn: false,
},
};
}
const overrides = raw.gameOverrides ?? {};
const excludedIds = new Set(
Object.entries(overrides)
.filter(([, o]) => o.exclude === true)
.map(([id]) => id),
);
const entries = GAMES.filter((g) => !excludedIds.has(g.external_id));
const excluded = GAMES.filter((g) => excludedIds.has(g.external_id)).map(
(g) => ({ external_id: g.external_id, title: g.title }),
);
const perPlatform: Record<string, number> = {};
for (const entry of entries) {
const p = platformOf(entry.external_id);
perPlatform[p] = (perPlatform[p] ?? 0) + 1;
}
const warnings =
scenario === "errors"
? [
"steamgriddb: 401 unauthorized — check the API key",
"/roms/n64: 3 files with unknown extensions ignored",
]
: [];
const report: SyncReport = {
considered: GAMES.length + SKIPPED.length,
included: entries.length,
skipped: SKIPPED,
excluded,
warnings,
truncated: 0,
perPlatform,
overWarn: scenario === "errors",
};
return { entries, report };
};
export const reportFor = (scenario: Scenario, raw: RawConfig): SyncReport =>
previewFor(scenario, raw).report;
// ── Engine status + the SSE stand-in ticker ────────────────────────────────────
const PATHS = {
dir: "/home/deck/.local/share/punktfunk/plugin-state/rom-manager",
config:
"/home/deck/.local/share/punktfunk/plugin-state/rom-manager/config.json",
cache:
"/home/deck/.local/share/punktfunk/plugin-state/rom-manager/cache.json",
} as const;
export const statusFor = (scenario: Scenario): EngineStatus => {
const report = reportFor(scenario, configFor(scenario));
switch (scenario) {
case "firstRun":
return {
rootsConfigured: 0,
os: "linux",
artProvider: null,
syncing: false,
paths: PATHS,
};
case "syncing":
return {
rootsConfigured: 3,
os: "linux",
artProvider: "steamgriddb",
syncing: true,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 8 * 60 * 1000,
},
lastReport: report,
detectedAt: DETECTED_AT,
paths: PATHS,
};
case "errors":
return {
rootsConfigured: 3,
os: "linux",
artProvider: null,
syncing: false,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 45 * 60 * 1000,
},
lastReport: report,
detectedAt: DETECTED_AT,
paths: PATHS,
};
default:
return {
rootsConfigured: 3,
os: "linux",
artProvider: "steamgriddb",
syncing: false,
lastSync: {
fingerprint: "b3c1a94efd02",
count: report.included,
at: Date.now() - 8 * 60 * 1000,
},
lastReport: report,
detectedAt: DETECTED_AT,
paths: PATHS,
};
}
};
/**
* Frames for the mock status ticker (~2s steps). Multi-frame scenarios loop
* idle → syncing → done so the Overview feed and sync button stay alive without a host.
*/
export const tickerFrames = (
scenario: Scenario,
): ReadonlyArray<EngineStatus> => {
const base = statusFor(scenario);
switch (scenario) {
case "firstRun":
return [base];
case "syncing":
return [base];
case "errors":
return [base, { ...base, syncing: true }, { ...base, syncing: false }];
default:
return [
base,
{ ...base, syncing: true },
{
...base,
syncing: false,
lastSync: {
fingerprint: "c4d2ba50fe13",
count: base.lastReport?.included ?? 0,
at: Date.now(),
},
},
];
}
};
+145
View File
@@ -0,0 +1,145 @@
// The mock transport: a full in-memory implementation of the RomManagerApi surface as
// an HttpClient layer, plus the SSE stand-in ticker. Importing this module REGISTERS
// both (see mocks/register.ts); only main.tsx (behind import.meta.env.DEV) and
// Storybook's preview import it, so production app chunks never contain fixtures.
import type { EngineStatus, RawConfig } from "@rom-manager/contract";
import { Duration, Effect, Layer } from "effect";
import {
HttpClient,
type HttpClientRequest,
HttpClientResponse,
} from "effect/unstable/http";
import { Atom, type AtomRegistry } from "effect/unstable/reactivity";
import * as fx from "./fixtures";
import { registerMocks } from "./register";
import { type Scenario, scenarioAtom } from "./scenario";
/** PUT /api/config lands here so config edits round-trip within a registry.
* keepAlive: written/read outside the reactive graph — see mocks/scenario.ts. */
const mockRawConfigAtom = Atom.keepAlive(Atom.make<RawConfig | null>(null));
const currentRawConfig = (
registry: AtomRegistry.AtomRegistry,
scenario: Scenario,
): RawConfig => registry.get(mockRawConfigAtom) ?? fx.configFor(scenario);
const requestJson = (request: HttpClientRequest.HttpClientRequest): unknown => {
const body = request.body;
if (body._tag === "Uint8Array") {
return JSON.parse(new TextDecoder().decode(body.body));
}
if (body._tag === "Raw") return body.body;
return undefined;
};
export const mockHttpClientLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> => {
const registry = get.registry;
return Layer.succeed(HttpClient.HttpClient)(
HttpClient.make((request, url) =>
Effect.gen(function* () {
// Real-ish latency so skeletons/spinners are honest in dev + Storybook.
yield* Effect.sleep(Duration.millis(150 + Math.random() * 250));
const scenario = registry.get(scenarioAtom);
const json = (body: unknown, status = 200) =>
HttpClientResponse.fromWeb(request, Response.json(body, { status }));
switch (`${request.method} ${url.pathname}`) {
case "GET /api/status":
return json(fx.statusFor(scenario));
case "GET /api/config":
return json({ raw: currentRawConfig(registry, scenario) });
case "PUT /api/config": {
if (scenario === "errors") {
return json(
{
_tag: "ConfigInvalid",
issues: 'roots[1].platform: unknown platform id "n65"',
},
400,
);
}
const raw = requestJson(request) as RawConfig;
registry.set(mockRawConfigAtom, raw);
return json({ raw });
}
case "GET /api/preview": {
if (scenario === "errors") {
return json(
{
_tag: "ScanFailed",
message: "scan failed: /roms/n64: permission denied",
},
500,
);
}
return json(
fx.previewFor(scenario, currentRawConfig(registry, scenario)),
);
}
case "GET /api/platforms":
return json(fx.PLATFORMS);
case "GET /api/emulators":
return json(fx.emulatorsFor(scenario));
case "POST /api/detect": {
if (scenario === "errors") {
return json(
{
_tag: "DetectFailed",
message: "detection failed: PATH probe timed out",
},
500,
);
}
return json({
...fx.emulatorsFor(scenario),
detectedAt: Date.now(),
});
}
case "POST /api/sync": {
if (scenario === "syncing") {
return json({ _tag: "SyncInProgress" }, 409);
}
return json(
fx.reportFor(scenario, currentRawConfig(registry, scenario)),
);
}
default:
return json(
{ error: `mock: unhandled ${request.method} ${url.pathname}` },
404,
);
}
}),
),
);
};
/** The SSE stand-in: cycles fixture frames (~2s steps) per the active scenario. */
export const mockStatusStreamAtom: Atom.Atom<EngineStatus | undefined> =
Atom.make((get) => {
const frames = fx.tickerFrames(get(scenarioAtom));
if (frames.length > 1) {
let i = 0;
const timer = setInterval(() => {
i += 1;
get.setSelf(frames[i % frames.length]);
}, 2000);
get.addFinalizer(() => clearInterval(timer));
}
return frames[0];
});
registerMocks({
httpLayer: mockHttpClientLayer,
statusStream: mockStatusStreamAtom,
});
+34
View File
@@ -0,0 +1,34 @@
// The dev-only seam between the always-shipped data layer and the mock transport.
// client.ts/atoms.ts reference THIS module (tiny, fixture-free); mocks/http.ts calls
// `registerMocks` at import time and is only ever imported by main.tsx behind
// `import.meta.env.DEV` and by Storybook's preview — so production app chunks stay
// free of fixture code without any conditional imports in the data layer.
import type { EngineStatus } from "@rom-manager/contract";
import type { Layer } from "effect";
import type { HttpClient } from "effect/unstable/http";
import type { Atom } from "effect/unstable/reactivity";
export type MockHttpLayerFactory = (
get: Atom.AtomContext,
) => Layer.Layer<HttpClient.HttpClient>;
type Registered = {
readonly httpLayer: MockHttpLayerFactory;
readonly statusStream: Atom.Atom<EngineStatus | undefined>;
};
let registered: Registered | undefined;
export const registerMocks = (mocks: Registered): void => {
registered = mocks;
};
/** The mock HttpClient layer, when mocks/http.ts has been imported. */
export const installedMockHttpLayer = (
get: Atom.AtomContext,
): Layer.Layer<HttpClient.HttpClient> | undefined => registered?.httpLayer(get);
/** The mock status ticker (the SSE stand-in), when mocks/http.ts has been imported. */
export const installedMockStatusStream = ():
| Atom.Atom<EngineStatus | undefined>
| undefined => registered?.statusStream;
+43
View File
@@ -0,0 +1,43 @@
// Mock-mode switches. Fixture-free ON PURPOSE: this module sits in the production
// import graph (client.ts reads apiModeAtom), so it must never pull in fixtures.
import { Atom } from "effect/unstable/reactivity";
export type ApiMode = "mock" | "live";
/** The fixture worlds the mock transport can serve (Storybook picks one per story). */
export type Scenario = "healthy" | "firstRun" | "syncing" | "errors";
/**
* Dev defaults to the zero-host mock loop; `bun run dev:live` (VITE_API_MODE=live) and
* production builds hit the real API. Storybook seeds this atom to "mock" per registry.
*/
export const defaultApiMode: ApiMode =
import.meta.env.DEV && import.meta.env.VITE_API_MODE !== "live"
? "mock"
: "live";
// keepAlive: both atoms are read OUTSIDE the reactive graph (the mock transport does
// a request-time registry.get) — without it, a registry-seeded value on a page that
// never subscribes them is collected and the read falls back to the default.
export const apiModeAtom: Atom.Writable<ApiMode> = Atom.keepAlive(
Atom.make<ApiMode>(defaultApiMode),
);
const SCENARIOS: ReadonlyArray<Scenario> = [
"healthy",
"firstRun",
"syncing",
"errors",
];
const isScenario = (value: unknown): value is Scenario =>
SCENARIOS.includes(value as Scenario);
/** Dev knob: `VITE_MOCK_SCENARIO=firstRun bun run dev` starts in another world. */
const defaultScenario: Scenario = isScenario(import.meta.env.VITE_MOCK_SCENARIO)
? import.meta.env.VITE_MOCK_SCENARIO
: "healthy";
export const scenarioAtom: Atom.Writable<Scenario> = Atom.keepAlive(
Atom.make<Scenario>(defaultScenario),
);