diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-26 20:18:30 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-26 20:18:30 -0700 |
| commit | 3c06c95f396b6e911076bc3291d5855ed01b5caa (patch) | |
| tree | 17cd218339c52fbeee93d931303b04a3ff294f8b /PLAN.md | |
| parent | f059d97ab7f6d74d61139ac698cb871be7cb632e (diff) | |
| download | graphiql-3c06c95f396b6e911076bc3291d5855ed01b5caa.tar.gz graphiql-3c06c95f396b6e911076bc3291d5855ed01b5caa.zip | |
cleanup and ready for launch
Diffstat (limited to 'PLAN.md')
| -rw-r--r-- | PLAN.md | 218 |
1 files changed, 0 insertions, 218 deletions
diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index f97e7ac..0000000 --- a/PLAN.md +++ /dev/null @@ -1,218 +0,0 @@ -# PLAN.md — @eol/graphiql - -A Svelte 5 GraphiQL alternative. JSR-published under the EOL scope. - -## Current state (v0.2 complete) - -Shipped: - -- HTTP fetcher with injectable `fetch` impl -- Schema introspection and SDL caching -- Session store with tabs, persistence, and rename -- TabBar with double-click inline rename (Enter commits, Escape cancels, blur commits) -- Toolbar with Cmd/Ctrl+Enter run shortcut -- Variables + Headers panes (switchable in bottom-left) -- Debounced localStorage persistence (300ms) with `beforeunload` flush -- Memory storage fallback for SSR -- CodeMirror 6 editor wrapper via `cm6-graphql` with dynamic import (SSR-safe) -- `readOnly` prop on Editor, used by ResultViewer -- Reactive schema updates via `$effect` calling `updateSchema()` -- Public API surface in `src/lib/index.ts` - -Stub files needing implementation: `src/lib/fetcher/sse.ts`, `src/lib/fetcher/websocket.ts`. - -## Coding conventions - -Non-negotiable. Match existing files exactly: - -- Double quotes, semicolons, two-space indent -- No trailing spaces, no unnecessary whitespace -- Svelte component structure: `<script lang="ts">`, then `<style lang="scss">`, then markup -- Object literal keys alphabetical unless semantic ordering matters -- TypeScript strict mode, no `any` -- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`) — no stores, no `writable` -- No direct `window` or `document` access — use `$app/environment`'s `browser`, or `typeof globalThis.X !== "undefined"` guards -- No React, no Node-specific APIs (Deno/Bun first) -- Fix mistakes in place; don't re-scaffold - -## Stages - -### v0.2.1 — Polish (small, worth doing before v0.3) - -**Tab auto-titling.** When typing in a brand-new tab, auto-generate a title from the first meaningful token of the query (operation name if present, else "query"/"mutation"/"subscription", else first 20 chars). Only auto-rename if the user hasn't manually renamed. Track with a `titleDirty: boolean` field on `Tab`. Set it to `true` in `renameTab()` and `renameActive()`. - -**Operation detection.** Parse the query with `graphql`'s `parse()` and set `tab.operationName` automatically when there's a single named operation. If multiple operations exist and none selected, surface a picker next to the Run button. Keep parse errors silent — the CM6 linter already shows them. - -**Theme prop.** Currently hardcoded to `oneDark`. Extract to an optional `theme?: Extension` prop on the root component, defaulting to `oneDark`. Consumers can pass any CM6 theme extension. A light theme preset (`import("@codemirror/theme")` basics or a small hand-rolled one) should ship alongside so people can toggle without adding a dependency. - -### v0.3 — Doc explorer and history - -**Doc explorer (`components/DocExplorer.svelte`).** Right-hand collapsible pane. Walks `schema.schema` (the built `GraphQLSchema` object). Breadcrumb navigation: Root → Type → Field → Arg. Clicking a field pushes onto a navigation stack. Clicking a breadcrumb crumb pops to that level. - -Structure: - -- `DocExplorer.svelte` — container, manages nav stack via `$state` -- `DocExplorer/TypeView.svelte` — renders a `GraphQLObjectType`, `GraphQLInterfaceType`, `GraphQLUnionType`, `GraphQLEnumType`, `GraphQLInputObjectType`, or `GraphQLScalarType` -- `DocExplorer/FieldView.svelte` — renders a single field with args and return type -- `DocExplorer/TypeLink.svelte` — clickable type reference (unwraps `NonNull`/`List`) - -Use the `graphql` package's `isObjectType`, `isInterfaceType`, etc. guards — don't duck-type. - -Toggle visibility with a toolbar button. Width around 320px. Collapsed state persisted via the same storage adapter under a `"docExplorer"` key. - -**History panel (`components/HistoryPanel.svelte`).** Drawer from the left. Logs every successful query execution (query + variables + headers + timestamp + operationName). New history store: `state/history.svelte.ts` with `HistoryStore` class, methods `add(entry)`, `remove(id)`, `clear()`, `favorite(id)`. Cap at 100 entries; favorites never evict. Clicking an entry loads it into the active tab (overwriting). Shift-click opens in a new tab. - -Persist under `"history"` key. Same debounce pattern as session. - -### v0.4 — Subscriptions (SSE + WebSocket) - -Replace the stubs in `fetcher/sse.ts` and `fetcher/websocket.ts`. - -**SSE implementation.** Use `graphql-sse` protocol. The fetcher signature already allows `AsyncIterable<FetcherResult>` as a return type — use it. Stream each `next` event as an iteration. Close the `EventSource` on `complete` or consumer abort. - -**WebSocket implementation.** Use the `graphql-ws` library (JSR: `npm:graphql-ws`). Return an `AsyncIterable` that yields each `next` message. Handle `connection_init`, `ping`/`pong`, subscription IDs, and clean teardown. - -**Root component update.** `GraphiQL.svelte`'s `run()` needs to detect `AsyncIterable` return values and stream them into `tab.result`. Append each payload with a timestamp header, or replace on each — make it a prop choice (`subscriptionMode: "replace" | "append"`, default `"append"`). - -**Protocol detection.** Add a prop to pick transport per-operation: if the query contains a subscription, prefer WS; otherwise HTTP. Let consumers override by passing a custom fetcher that dispatches internally. - -### v0.5 — Theming and plugin slots - -**Theming.** Two layers: the editor theme (a CM6 `Extension` passed as a prop per v0.2.1) and the surrounding chrome theme (CSS custom properties like `--graphiql-bg`, `--graphiql-fg`). Extract the chrome variables into `src/lib/styles/theme.scss` with light/dark mode variables gated by `@media (prefers-color-scheme)` and an override class. Ship at least two CM6 theme presets (dark via `oneDark`, light via a small hand-rolled `EditorView.theme({...})`). Document the full variable list and theme prop API in README. - -**Plugin slots.** Use Svelte snippets (the `{@render}` / `Snippet` type from Svelte 5). Add optional props to `GraphiQL.svelte`: - -- `toolbarExtras?: Snippet` — rendered after the Run button -- `resultFooter?: Snippet<[{ result: string }]>` — rendered below the result pane -- `tabExtras?: Snippet<[{ tab: Tab }]>` — rendered inside each tab button - -This keeps the plugin story type-safe without a bespoke registry. - -### v0.6 — Ergonomics (keyboard + format) - -**Keyboard shortcuts.** Extend the existing `onKeydown` handler in `GraphiQL.svelte` (already bound via `<svelte:window>`). Add, in priority order: - -- `Cmd/Ctrl + Shift + Enter` — new tab (`session.addTab()`). We do **not** use `Cmd+T`; browsers intercept it and `preventDefault` is unreliable across hosts. Document this in README — if an embedder is running in Tauri/Electron they can remap to `Cmd+T` themselves. -- `Cmd/Ctrl + Shift + W` — close active tab (`session.closeTab(session.activeId)`). Same reasoning — `Cmd+W` is browser-owned. -- `Cmd/Ctrl + Alt + Right/Left` — next/prev tab. Wraps around end-to-start. No-op on a single tab. -- `Cmd/Ctrl + Shift + F` — format active query (see below). - -Extract the handler into `state/keyboard.ts` as a pure function `matchShortcut(event) → Action | null` so `tests/keyboard.test.ts` can cover matrix cases without a DOM. - -**Query formatting.** New file `graphql/format.ts` exporting `format(query: string): string` that calls `print(parse(query))` from `graphql`. On parse failure, return the original string unchanged — don't throw. Wire into: - -- A `Format` button in `Toolbar.svelte`, placed between the operation picker and `extras`. Disabled when the active tab query is empty or unparseable (check via `operations.length === 0 && query.trim().length > 0`? No — cheaper to attempt `format()` and compare; if equal to input *and* parse would have failed, disable. Actually simplest: always enable, no-op on unparseable). -- `Cmd/Ctrl + Shift + F` shortcut above. Route through `session.formatActive()` which sets `tab.query = format(tab.query)` and re-derives operations. - -Gotcha: `print()` strips comments. Document this — we are not going to keep a CST round-trip for v0.6. - -### v0.7 — Session portability - -**Export/import session JSON.** Extend `SessionStore` with: - -- `exportTab(id: string): TabExport | null` — strips `id`, `result`, `operations` (derivable), keeps `headers`, `operationName`, `query`, `title`, `variables`, and a version tag. -- `exportAll(): SessionExport` — `{ version: 1, exportedAt: ISO, tabs: TabExport[] }`. -- `importTabs(data: SessionExport, opts: { mode: "replace" | "append" }): ImportResult` — validates the `version` field and tab shape (lightweight hand-rolled check, no zod); rejects unknown versions with a descriptive error; returns `{ added: number, skipped: number, errors: string[] }`. - -Shape validation lives in `state/session-io.ts` so it can be unit tested without the store. Treat imported JSON as untrusted — reject if any string field exceeds 1 MB (covers accidental mega-pastes), cap tab count at 50 per import. - -**UI surface.** Two buttons in the History panel header (already has `Clear`) — `Export` and `Import`. Export downloads via `Blob` + object URL, filename `graphiql-session-{ISO}.json`. Import uses a hidden `<input type="file" accept="application/json">` triggered by the button. Show an `ImportResult` toast-style row inline in the panel on completion; no toast library. - -### v0.8 — Layout resize - -**Splitter component.** `components/Splitter.svelte`, no external deps. Props: `orientation: "horizontal" | "vertical"`, `min: number`, `max: number`, `value: number`, `onChange: (v: number) => void`. Internal: `pointerdown` captures the pointer, `pointermove` computes delta against the parent element's bounding rect, `pointerup` releases capture. Writes to `value` via `onChange` on every move (consumer is expected to throttle via `$effect` if needed — store writes are already debounced). - -**Wire into `GraphiQL.svelte`.** Replace the fixed `grid-template-columns` / `grid-template-rows` with CSS custom properties driven by `$state`: - -- `--graphiql-left-width` (default `1fr`, resized via middle splitter between query column and result) -- `--graphiql-bottom-height` (default split between editor and variables/headers pane — currently `2fr auto 1fr`, change the `1fr` to `var(--graphiql-bottom-height, 1fr)`) -- `--graphiql-docs-width` (default `320px`, only present when docs open) -- `--graphiql-history-width` (default `260px`, only present when history open) - -Persist each to storage under `layout.{key}` keys, hydrated in the constructor. Storage accepts numbers; we re-stringify as `{n}px` or fractions depending on which axis. - -Three splitters total: between history/left, between left/right, between right/docs, and a horizontal one inside `.left` between the query editor and the variables/headers pane. Keep total splitter DOM cost at four elements. - -### v0.9 — Timing display - -**Instrumentation at the run boundary.** Modify `SessionStore.run()` only — don't touch the `Fetcher` signature. Record: - -- `tab.timing.startMs = performance.now()` before the fetcher call. -- `tab.timing.firstByteMs = performance.now()` on first payload (either the awaited object or the first iterator step). -- `tab.timing.endMs = performance.now()` on completion. -- For subscriptions: `tab.streamIntervals: number[]` — delta between each successive payload, capped at 500 entries per run. - -Add `timing` and `streamIntervals` fields to `Tab`. Render a small row under `ResultViewer` (not a footer snippet — that's for consumers) showing `→ 42ms · first byte 18ms` for one-shot, or `→ 12 messages · median 430ms` for streams. Compute the median inline; no helpers dir. - -Subscription streams don't have a single end; treat `endMs` as the time of the last payload and update it on each step so consumers see a rolling duration while streaming. - -**Do not** attempt to surface server-side trace extensions (Apollo Tracing, `response.extensions.tracing`) in v0.9 — that's a separate feature with its own UI. Document as future work. - -### v0.10 — Persisted queries (APQ) - -**Wrapper fetcher.** New file `fetcher/apq.ts`, exported as `createApqFetcher(inner: Fetcher, options?: ApqOptions): Fetcher`. Implements the Apollo Automatic Persisted Queries protocol: - -1. Compute `sha256Hash` of the query string with `globalThis.crypto.subtle.digest("SHA-256", ...)`. Cache per-query in a `Map<string, string>` to avoid rehashing. -2. First attempt — send `{ operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }` without the `query`. Wrap the inner fetcher by passing a synthetic `FetcherRequest` with `query: ""` and stash the hash in `headers["x-apq-hash"]`? No — cleaner: don't reuse `inner`, build the body ourselves. This means APQ needs its own HTTP fetcher variant or needs to extend the inner fetcher contract. - - **Decision:** APQ is HTTP-specific. Implement `createApqFetcher(options: FetcherOptions): Fetcher` that builds the body directly, bypassing `createHttpFetcher`. Share the header-merging logic via a small helper `fetcher/http-body.ts`. SSE/WS get no APQ support in v0.10 — document as out of scope. - -3. If response contains `{ errors: [{ message: "PersistedQueryNotFound", ... }] }`, retry with `{ query, operationName, variables, extensions: { persistedQuery: { version: 1, sha256Hash } } }`. -4. Cache the hash → query mapping in memory for the fetcher's lifetime; no disk persistence in v0.10 (servers already cache). - -`ApqOptions`: - -- Everything from `FetcherOptions` -- `disable?: boolean` — escape hatch for debugging, forces full query on every request. - -**Export.** Add `createApqFetcher` and `ApqOptions` to `source/library/index.ts`. No UI changes — this is consumer-facing plumbing. - -### v1.0 — Stabilization - -Not a feature stage. Before tagging v1.0: - -- README audit: every exported name has an example. -- `deno publish --dry-run` clean, no type errors. -- Every `*.svelte.ts` store has a companion `tests/*.test.ts`. -- Theme variables documented in one table in the README. -- One known-good end-to-end story: run against a real GraphQL endpoint (e.g., the Countries API) via an example app in `examples/` (optional, can skip if the README reproducer is enough). - -## Publishing - -The project publishes to JSR as `@eol/graphiql`. Current `jsr.json` and `deno.json` are set up for v0.2.0. Bump versions with ChronVer per NetOpWibby's convention. The `exports` points at `src/lib/index.ts`; JSR ships `.svelte` files as-is and the consumer's SvelteKit build compiles them. **Do not pre-compile Svelte components** — that breaks SSR and prevents consumers from using their own Svelte version. - -Run `deno publish --dry-run` before tagging. - -## Known gotchas - -**CM6 extension composition.** CodeMirror 6 is extension-based — every feature is an opt-in `Extension`. The current `Editor.svelte` bundles a reasonable default set (line numbers, history, bracket matching, close brackets, indent-on-input, default + history keymaps, syntax highlighting, oneDark theme). If consumers want a leaner editor (e.g., for the result viewer), the cleanest path is to make the extension array a prop. Not done yet — v0.2.1 `theme` prop is the entry point for this refactor. - -**Schema reactivity.** Schema changes are handled by an `$effect` that calls `cm6-graphql`'s `updateSchema(view, newSchema)`. This is the documented CM6 pattern — do not recreate the editor on schema change, as that loses cursor position, history, and undo stack. If `schema` is invalid SDL, the catch block silently skips and the editor keeps working without schema awareness. - -**`crypto.randomUUID()`** works in Deno, modern browsers, and Bun. It does not work in Node.js before 14.17. Since we're Deno/Bun first this is fine; flag in docs if someone reports Node issues. - -**Nested button in TabBar.** The close `×` button is nested inside the tab `<button>`. Technically invalid HTML but works everywhere. If a11y linters complain, refactor the outer `<button>` to `<div role="tab" tabindex="0">` with explicit keyboard handlers. - -**Schema introspection failures.** Some GraphQL servers disable introspection in production. Surface the error clearly (already done in `.status` bar) but make sure queries still run without schema — the CM6 editor falls back to plain GraphQL syntax highlighting when `sdl` is empty. - -## Testing - -No test harness yet. For v0.3 add Deno's built-in test runner targeting the state stores (pure logic, no DOM): - -- `tests/session.test.ts` — tab add/close/rename/persist round-trip -- `tests/storage.test.ts` — memory storage + namespace isolation -- `tests/history.test.ts` — eviction and favorite pinning - -Component tests can wait until there's demand; Svelte 5 + runes + Vitest is still settling. - -## Architecture notes for future me - -The separation between **state stores** (`*.svelte.ts` classes) and **components** is deliberate. Stores own all mutation logic and can be unit tested without a DOM. Components own rendering and side effects (persistence timers, keyboard handlers). - -The `$effect` in `GraphiQL.svelte` intentionally reads `session.tabs` and `session.activeId` via `void` to establish dependencies without using the values. This is idiomatic runes and cleaner than `$effect.root` with explicit subscriptions. - -The fetcher abstraction is intentionally minimal — one function type that returns a Promise or AsyncIterable. This covers HTTP, SSE, WS, and any future transport without an interface explosion. Transport-specific options live in the factory function, not the fetcher itself. - -**Why CodeMirror 6 over Monaco.** Bundle size, build simplicity, and philosophical fit. Monaco is multi-megabyte with required web worker setup in Vite — a real friction point for consumers. CM6 is tens of kilobytes, composed of opt-in extensions, and works with zero build config. `cm6-graphql` is maintained inside the graphql/graphiql monorepo itself and uses the same `graphql-language-service` that powers `monaco-graphql`, so we get schema-aware autocomplete and linting without the bloat. The tradeoff is slightly less polished hover docs and no signature-help popups — acceptable for v1, addressable later via custom CM6 extensions if needed. - -When in doubt about API shape, look at `@graphiql/toolkit` for reference — the GraphiQL maintainers have already factored out the transport concerns nicely, we're just giving it a Svelte + CM6 skin. |