1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
|
# 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.
|