Split web/src/api.ts: one request<T> seam, typed errors, stores out #112

Closed
opened 2026-07-18 14:19:31 +02:00 by dominik.polakovics · 1 comment

This was generated by AI during triage.

Found by an architecture review (deepening opportunities — turning shallow modules into deep ones).

Files: web/src/api.ts (2,127 lines).

Problem: one file mixes ~60 type declarations, ~40 bare fetch functions (17 copies of if (!res.ok) throw new Error(...), 8 hand-rolled JSON POST bodies), and four Solid store factories with polling timers (createHugoStatus, createPageTree, createPagetypes, createGitState). Each fetch function is shallow — interface ≈ implementation — and errors are untyped strings matched by text. Importing one pure fetch drags in the Solid runtime and setInterval loops. The 422-with-errors vs plain-error decoding is re-implemented per endpoint (savePage, savePageSettings, undoPage).

Solution: split into api/types.ts, an api/client.ts with a single request<T>(path, init) helper (one place for headers, JSON serialization, status handling, and typed decoding of the tagged SaveResult/ElementOpResult/PublishOutcome unions), and separate stores/*.ts modules for the polling state.

Benefits: Leverage — ~300 lines of repetition collapse behind one seam; error typing happens once. Locality — "how do we talk to the harness" becomes one module. The fetch layer becomes DOM/Solid-free and unit-testable without jsdom, and the installMockApi test seam gets simpler.

> *This was generated by AI during triage.* Found by an architecture review (deepening opportunities — turning shallow modules into deep ones). **Files**: `web/src/api.ts` (2,127 lines). **Problem**: one file mixes ~60 type declarations, ~40 bare `fetch` functions (17 copies of `if (!res.ok) throw new Error(...)`, 8 hand-rolled JSON POST bodies), and four Solid store factories with polling timers (`createHugoStatus`, `createPageTree`, `createPagetypes`, `createGitState`). Each fetch function is shallow — interface ≈ implementation — and errors are untyped strings matched by text. Importing one pure fetch drags in the Solid runtime and `setInterval` loops. The 422-with-`errors` vs plain-error decoding is re-implemented per endpoint (`savePage`, `savePageSettings`, `undoPage`). **Solution**: split into `api/types.ts`, an `api/client.ts` with a single `request<T>(path, init)` helper (one place for headers, JSON serialization, status handling, and typed decoding of the tagged `SaveResult`/`ElementOpResult`/`PublishOutcome` unions), and separate `stores/*.ts` modules for the polling state. **Benefits**: *Leverage* — ~300 lines of repetition collapse behind one seam; error typing happens once. *Locality* — "how do we talk to the harness" becomes one module. The fetch layer becomes DOM/Solid-free and unit-testable without jsdom, and the `installMockApi` test seam gets simpler.
Author
Owner

This was generated by AI during triage.

Agent Brief

Category: enhancement
Summary: Split the web client's monolithic API module into a small transport seam, domain endpoint modules with colocated types, and per-store polling modules, introducing a typed ApiError.

Design decisions below were settled with the maintainer during triage grilling; they refine the issue body and win where they differ from it.

Current behavior:
The web client has a single API module (web/src/api.ts, ~2,100 lines at time of triage) mixing ~60 type declarations, ~40 fetch wrapper functions (17 duplicated if (!res.ok) throw new Error(...) blocks, ~8 hand-rolled JSON POST bodies), and four Solid store factories with setInterval polling loops (createHugoStatus, createPageTree, createPagetypes, createGitState). Errors are thrown as plain Error with ad-hoc strings; components display err.message directly. Tests mock the network by stubbing global fetch (installMockApi uses vi.stubGlobal("fetch", ...)), so the seam is transport-level, not module-level.

Desired behavior:
A pure refactor — zero runtime behavior change (same endpoints, same polling intervals, byte-identical error message strings) — with this structure:

  1. No barrel. The old monolith is deleted and not replaced by an index/re-export module. Every importer (~55 files) deep-imports the specific module it needs. Nothing may re-export the whole API surface from one hub.
  2. Transport seam. One small client module exporting only the transport: a single request<T>(path, init?) helper (the only place that calls fetch, sets JSON headers, serializes bodies, checks status, and decodes JSON) and ApiError extends Error carrying status: number and the decoded response body. ApiError.message must reproduce today's strings exactly — components text-match on them, and their migration to typed inspection belongs to #117, not this issue.
  3. Domain endpoint modules. The ~40 fetch functions split into modules mirroring the harness API surface (e.g. pages, elements, media, publish, git/status — exact grouping is the implementing agent's judgment). Each domain module colocates the request/response types it owns; a shared types module holds only genuinely cross-domain shapes (e.g. TreeNode, PageId). Domain modules must be Solid-free and DOM-free.
  4. Tagged unions stay return values. SaveResult, ElementOpResult, PublishOutcome keep their current shapes and remain returned (not thrown); the 422-with-errors decoding that several endpoints re-implement is centralized so it exists once.
  5. Stores. The four polling store factories move to one module per store under a stores/ directory, unchanged in behavior. Their importers at time of triage: the app shell, the tree pane, the modules registry, and one test file.

Key interfaces:

  • request<T>(path: string, init?: RequestInit-like): Promise<T> — sole owner of fetch/status/JSON handling
  • ApiError extends Error { status: number; body?: unknown } — message strings identical to current literals
  • SaveResult / ElementOpResult / PublishOutcome — unchanged shapes, unchanged return-value semantics
  • installMockApi (test helper) — must keep working; it stubs global fetch, so it survives the split as long as fetch remains the transport

Acceptance criteria:

  • The monolithic API module is gone; no barrel/index module re-exports the API surface; all importers deep-import domain/store/client modules
  • Exactly one call site performs fetch + status-check + JSON decode; zero if (!res.ok) blocks outside the client module
  • ApiError carries the HTTP status; all thrown error message strings are byte-identical to before the refactor
  • No module under the API directory imports from solid-js or touches the DOM
  • The four store factories live one-per-file under stores/ with unchanged polling intervals and public shapes
  • The existing vitest suite passes unchanged (no test expectation edits except import paths and moved-file references)
  • New jsdom-free unit tests cover request<T>: success decode, non-2xx → ApiError with correct status/message, and the centralized 422 field-error decoding
  • The Go suite and web build still pass (web/dist must exist for go test ./...; toolchains via nix shell)

Out of scope:

  • Migrating components off err.message text display to typed ApiError inspection — that is #117's errorMessage work, which will consume the ApiError introduced here
  • Extracting page-editor controllers from panes (#113)
  • Any behavior change: endpoints, payloads, polling cadence, retry semantics, error wording

Sequencing: No blockers. #113 and #117 touch the same importers and should be triaged as Blocked by #112.

> *This was generated by AI during triage.* ## Agent Brief **Category:** enhancement **Summary:** Split the web client's monolithic API module into a small transport seam, domain endpoint modules with colocated types, and per-store polling modules, introducing a typed `ApiError`. Design decisions below were settled with the maintainer during triage grilling; they refine the issue body and win where they differ from it. **Current behavior:** The web client has a single API module (`web/src/api.ts`, ~2,100 lines at time of triage) mixing ~60 type declarations, ~40 `fetch` wrapper functions (17 duplicated `if (!res.ok) throw new Error(...)` blocks, ~8 hand-rolled JSON POST bodies), and four Solid store factories with `setInterval` polling loops (`createHugoStatus`, `createPageTree`, `createPagetypes`, `createGitState`). Errors are thrown as plain `Error` with ad-hoc strings; components display `err.message` directly. Tests mock the network by stubbing **global `fetch`** (`installMockApi` uses `vi.stubGlobal("fetch", ...)`), so the seam is transport-level, not module-level. **Desired behavior:** A pure refactor — zero runtime behavior change (same endpoints, same polling intervals, byte-identical error message strings) — with this structure: 1. **No barrel.** The old monolith is deleted and **not** replaced by an index/re-export module. Every importer (~55 files) deep-imports the specific module it needs. Nothing may re-export the whole API surface from one hub. 2. **Transport seam.** One small client module exporting only the transport: a single `request<T>(path, init?)` helper (the only place that calls `fetch`, sets JSON headers, serializes bodies, checks status, and decodes JSON) and `ApiError extends Error` carrying `status: number` and the decoded response body. `ApiError.message` must reproduce today's strings exactly — components text-match on them, and their migration to typed inspection belongs to #117, not this issue. 3. **Domain endpoint modules.** The ~40 fetch functions split into modules mirroring the harness API surface (e.g. pages, elements, media, publish, git/status — exact grouping is the implementing agent's judgment). Each domain module **colocates the request/response types it owns**; a shared types module holds only genuinely cross-domain shapes (e.g. `TreeNode`, `PageId`). Domain modules must be Solid-free and DOM-free. 4. **Tagged unions stay return values.** `SaveResult`, `ElementOpResult`, `PublishOutcome` keep their current shapes and remain returned (not thrown); the 422-with-`errors` decoding that several endpoints re-implement is centralized so it exists once. 5. **Stores.** The four polling store factories move to one module per store under a `stores/` directory, unchanged in behavior. Their importers at time of triage: the app shell, the tree pane, the modules registry, and one test file. **Key interfaces:** - `request<T>(path: string, init?: RequestInit-like): Promise<T>` — sole owner of fetch/status/JSON handling - `ApiError extends Error { status: number; body?: unknown }` — message strings identical to current literals - `SaveResult` / `ElementOpResult` / `PublishOutcome` — unchanged shapes, unchanged return-value semantics - `installMockApi` (test helper) — must keep working; it stubs global `fetch`, so it survives the split as long as `fetch` remains the transport **Acceptance criteria:** - [ ] The monolithic API module is gone; no barrel/index module re-exports the API surface; all importers deep-import domain/store/client modules - [ ] Exactly one call site performs `fetch` + status-check + JSON decode; zero `if (!res.ok)` blocks outside the client module - [ ] `ApiError` carries the HTTP status; all thrown error message strings are byte-identical to before the refactor - [ ] No module under the API directory imports from `solid-js` or touches the DOM - [ ] The four store factories live one-per-file under `stores/` with unchanged polling intervals and public shapes - [ ] The existing vitest suite passes **unchanged** (no test expectation edits except import paths and moved-file references) - [ ] New jsdom-free unit tests cover `request<T>`: success decode, non-2xx → `ApiError` with correct status/message, and the centralized 422 field-error decoding - [ ] The Go suite and web build still pass (`web/dist` must exist for `go test ./...`; toolchains via `nix shell`) **Out of scope:** - Migrating components off `err.message` text display to typed `ApiError` inspection — that is #117's `errorMessage` work, which will consume the `ApiError` introduced here - Extracting page-editor controllers from panes (#113) - Any behavior change: endpoints, payloads, polling cadence, retry semantics, error wording **Sequencing:** No blockers. #113 and #117 touch the same importers and should be triaged as `Blocked by #112`.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle#112
No description provided.