diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-24 14:17:38 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-24 14:17:38 -0700 |
| commit | 261f3bdb77799009344aab4a60686b7186ebd3b0 (patch) | |
| tree | 8e87c6610a307f15f0c4b32f68b19424273fc6ad /source/library | |
| parent | 8a59f92d031963e23ecc84b75feecf43eb4dd146 (diff) | |
| download | graphiql-261f3bdb77799009344aab4a60686b7186ebd3b0.tar.gz graphiql-261f3bdb77799009344aab4a60686b7186ebd3b0.zip | |
Implement v0.4 subscriptions + v0.5 theming and plugin slots
- SSE and WebSocket fetchers via graphql-sse and graphql-ws,
returning AsyncIterable results
- SessionStore.run consumes AsyncIterable streams with
subscriptionMode "append" (timestamped) or "replace" and
honors an AbortSignal for cancellation
- Chrome CSS variables documented in styles/theme.scss with
prefers-color-scheme light/dark gating and .graphiql-light override
- Svelte 5 snippet slots on GraphiQL: toolbarExtras, tabExtras,
resultFooter
Diffstat (limited to '')
| -rw-r--r-- | source/library/GraphiQL.svelte | 30 | ||||
| -rw-r--r-- | source/library/components/ResultViewer.svelte | 16 | ||||
| -rw-r--r-- | source/library/components/TabBar.svelte | 6 | ||||
| -rw-r--r-- | source/library/components/Toolbar.svelte | 4 | ||||
| -rw-r--r-- | source/library/fetcher/sse.ts | 100 | ||||
| -rw-r--r-- | source/library/fetcher/websocket.ts | 97 | ||||
| -rw-r--r-- | source/library/index.ts | 9 | ||||
| -rw-r--r-- | source/library/state/session.svelte.ts | 53 | ||||
| -rw-r--r-- | source/library/styles/theme.scss | 47 |
9 files changed, 334 insertions, 28 deletions
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte index 0a8f01a..329dd6f 100644 --- a/source/library/GraphiQL.svelte +++ b/source/library/GraphiQL.svelte @@ -1,7 +1,7 @@ <script lang="ts"> /*** IMPORT ------------------------------------------- ***/ - import { onMount } from "svelte"; + import { onMount, type Snippet } from "svelte"; /*** UTILITY ------------------------------------------ ***/ @@ -17,6 +17,7 @@ import { HistoryStore } from "./state/history.svelte.ts"; import { SchemaStore } from "./state/schema.svelte.ts"; import { SessionStore } from "./state/session.svelte.ts"; + import type { SubscriptionMode, Tab } from "./state/session.svelte.ts"; import { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; import type { Storage } from "./state/storage.ts"; @@ -24,16 +25,24 @@ fetcher: Fetcher; initialQuery?: string; namespace?: string; + resultFooter?: Snippet<[{ result: string }]>; storage?: Storage; + subscriptionMode?: SubscriptionMode; + tabExtras?: Snippet<[{ tab: Tab }]>; theme?: Extension; + toolbarExtras?: Snippet; }; let { fetcher, initialQuery = "", namespace = "eol-graphiql", + resultFooter, storage, - theme + subscriptionMode = "append", + tabExtras, + theme, + toolbarExtras }: Props = $props(); const resolvedStorage = storage ?? @@ -87,15 +96,23 @@ schema.introspect(fetcher); }); + let runAbort: AbortController | null = null; + async function run() { - if (running) + if (running) { + runAbort?.abort(); return; + } running = true; + runAbort = new AbortController(); try { const tab = session.active; - const ok = await session.run(fetcher); + const ok = await session.run(fetcher, { + signal: runAbort.signal, + subscriptionMode + }); if (ok && tab) { history.add({ @@ -107,6 +124,7 @@ }); } } finally { + runAbort = null; running = false; } } @@ -247,6 +265,7 @@ disabled={running || !session.active} docsAvailable={schema.schema !== null} {docsOpen} + extras={toolbarExtras} {historyOpen} onRun={run} onSelectOperation={(name) => { @@ -261,6 +280,7 @@ schemaLoading={schema.loading}/> <TabBar activeId={session.activeId} + extras={tabExtras} onAdd={() => session.addTab()} onClose={(id) => session.closeTab(id)} onRename={(id, title) => session.renameTab(id, title)} @@ -310,7 +330,7 @@ </div> </div> <div class="right"> - <ResultViewer {theme} value={session.active?.result ?? ""}/> + <ResultViewer footer={resultFooter} {theme} value={session.active?.result ?? ""}/> </div> {#if docsOpen && schema.schema} <DocExplorer schema={schema.schema}/> diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte index e2c74fe..083828d 100644 --- a/source/library/components/ResultViewer.svelte +++ b/source/library/components/ResultViewer.svelte @@ -1,13 +1,15 @@ <script lang="ts"> import Editor from "./Editor.svelte"; import type { Extension } from "@codemirror/state"; + import type { Snippet } from "svelte"; type Props = { + footer?: Snippet<[{ result: string }]>; theme?: Extension; value: string; }; - let { theme, value }: Props = $props(); + let { footer, theme, value }: Props = $props(); function noop(_v: string) {} </script> @@ -15,7 +17,7 @@ <style lang="scss"> .result { display: grid; - grid-template-rows: auto 1fr; + grid-template-rows: auto 1fr auto; height: 100%; min-height: 0; } @@ -27,9 +29,19 @@ padding: 0.25rem 0.75rem; text-transform: uppercase; } + + .footer { + background: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + } </style> <div class="result"> <div class="label">Response</div> <Editor language="json" onChange={noop} readOnly {theme} {value}/> + {#if footer} + <div class="footer">{@render footer({ result: value })}</div> + {/if} </div> diff --git a/source/library/components/TabBar.svelte b/source/library/components/TabBar.svelte index d87449d..9c34f20 100644 --- a/source/library/components/TabBar.svelte +++ b/source/library/components/TabBar.svelte @@ -1,9 +1,10 @@ <script lang="ts"> - import { tick } from "svelte"; + import { tick, type Snippet } from "svelte"; import type { Tab } from "../state/session.svelte.ts"; type Props = { activeId: string; + extras?: Snippet<[{ tab: Tab }]>; onAdd: () => void; onClose: (id: string) => void; onRename: (id: string, title: string) => void; @@ -11,7 +12,7 @@ tabs: Tab[]; }; - let { activeId, onAdd, onClose, onRename, onSelect, tabs }: Props = $props(); + let { activeId, extras, onAdd, onClose, onRename, onSelect, tabs }: Props = $props(); let editingId = $state<string | null>(null); let draft = $state<string>(""); @@ -150,6 +151,7 @@ {:else} <span class="title">{tab.title}</span> {/if} + {#if extras}{@render extras({ tab })}{/if} <button aria-label="Close tab" class="close" diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte index a17191c..8c75668 100644 --- a/source/library/components/Toolbar.svelte +++ b/source/library/components/Toolbar.svelte @@ -1,10 +1,12 @@ <script lang="ts"> import type { OperationInfo } from "../graphql/operations.ts"; + import type { Snippet } from "svelte"; type Props = { disabled: boolean; docsAvailable?: boolean; docsOpen?: boolean; + extras?: Snippet; historyOpen?: boolean; onRun: () => void; onSelectOperation?: (name: string | null) => void; @@ -20,6 +22,7 @@ disabled, docsAvailable = false, docsOpen = false, + extras, historyOpen = false, onRun, onSelectOperation, @@ -125,6 +128,7 @@ {/each} </select> {/if} + {#if extras}{@render extras()}{/if} <span class="hint">⌘/Ctrl + Enter</span> {#if schemaLoading} <span class="hint">Loading schema…</span> diff --git a/source/library/fetcher/sse.ts b/source/library/fetcher/sse.ts index b6805dc..c28436b 100644 --- a/source/library/fetcher/sse.ts +++ b/source/library/fetcher/sse.ts @@ -1,18 +1,102 @@ +/*** IMPORT ------------------------------------------- ***/ + +import { createClient, type Client, type ClientOptions } from "graphql-sse"; + /*** UTILITY ------------------------------------------ ***/ -import type { Fetcher, FetcherOptions } from "./types.ts"; +import type { Fetcher, FetcherOptions, FetcherResult } from "./types.ts"; /*** EXPORT ------------------------------------------- ***/ -/** - * Server-Sent Events fetcher for graphql-sse protocol. - * Stub implementation — see PLAN.md stage v0.4 for full implementation. - */ -export function createSseFetcher(_options: FetcherOptions): Fetcher { - return () => { - throw new Error("SSE fetcher not yet implemented — see PLAN.md v0.4"); +export type SseFetcherOptions = FetcherOptions & { + client?: Partial<Omit<ClientOptions, "url">>; +}; + +export function createSseFetcher(options: SseFetcherOptions): Fetcher { + const client: Client = createClient({ + fetchFn: options.fetch, + headers: options.headers, + url: options.url, + ...options.client + }); + + return (req) => { + return { + [Symbol.asyncIterator](): AsyncIterator<FetcherResult> { + const queue: FetcherResult[] = []; + const resolvers: ((step: IteratorResult<FetcherResult>) => void)[] = []; + let done = false; + let error: unknown = null; + + const dispose = client.subscribe<FetcherResult, Record<string, unknown>>( + { + extensions: req.headers as Record<string, unknown> | undefined, + operationName: req.operationName ?? undefined, + query: req.query, + variables: req.variables + }, + { + complete() { + done = true; + flush(); + }, + error(err) { + error = err; + done = true; + flush(); + }, + next(value) { + queue.push(value as FetcherResult); + flush(); + } + } + ); + + function flush() { + while (resolvers.length > 0 && (queue.length > 0 || done)) { + const resolve = resolvers.shift(); + + if (!resolve) + break; + + if (queue.length > 0) + resolve({ done: false, value: queue.shift() as FetcherResult }); + else if (error) + resolve({ done: true, value: undefined }); + else + resolve({ done: true, value: undefined }); + } + } + + return { + next(): Promise<IteratorResult<FetcherResult>> { + if (error) { + const err = error; + error = null; + return Promise.reject(err); + } + + if (queue.length > 0) + return Promise.resolve({ done: false, value: queue.shift() as FetcherResult }); + + if (done) + return Promise.resolve({ done: true, value: undefined }); + + return new Promise<IteratorResult<FetcherResult>>((resolve) => { + resolvers.push(resolve); + }); + }, + return(): Promise<IteratorResult<FetcherResult>> { + dispose(); + done = true; + flush(); + return Promise.resolve({ done: true, value: undefined }); + } + }; + } + }; }; } diff --git a/source/library/fetcher/websocket.ts b/source/library/fetcher/websocket.ts index 6376e76..6c6bfe8 100644 --- a/source/library/fetcher/websocket.ts +++ b/source/library/fetcher/websocket.ts @@ -1,18 +1,99 @@ +/*** IMPORT ------------------------------------------- ***/ + +import { createClient, type Client, type ClientOptions } from "graphql-ws"; + /*** UTILITY ------------------------------------------ ***/ -import type { Fetcher, FetcherOptions } from "./types.ts"; +import type { Fetcher, FetcherOptions, FetcherResult } from "./types.ts"; /*** EXPORT ------------------------------------------- ***/ -/** - * WebSocket fetcher for graphql-ws protocol. - * Stub implementation — see PLAN.md stage v0.4 for full implementation. - */ -export function createWsFetcher(_options: FetcherOptions): Fetcher { - return () => { - throw new Error("WebSocket fetcher not yet implemented — see PLAN.md v0.4"); +export type WsFetcherOptions = Omit<FetcherOptions, "fetch"> & { + client?: Partial<Omit<ClientOptions, "url">>; +}; + +export function createWsFetcher(options: WsFetcherOptions): Fetcher { + const client: Client = createClient({ + connectionParams: options.headers, + url: options.url, + ...options.client + }); + + return (req) => { + return { + [Symbol.asyncIterator](): AsyncIterator<FetcherResult> { + const queue: FetcherResult[] = []; + const resolvers: ((step: IteratorResult<FetcherResult>) => void)[] = []; + let done = false; + let error: unknown = null; + + const dispose = client.subscribe<FetcherResult, Record<string, unknown>>( + { + extensions: req.headers as Record<string, unknown> | undefined, + operationName: req.operationName ?? undefined, + query: req.query, + variables: req.variables + }, + { + complete() { + done = true; + flush(); + }, + error(err) { + error = err; + done = true; + flush(); + }, + next(value) { + queue.push(value as FetcherResult); + flush(); + } + } + ); + + function flush() { + while (resolvers.length > 0 && (queue.length > 0 || done)) { + const resolve = resolvers.shift(); + + if (!resolve) + break; + + if (queue.length > 0) + resolve({ done: false, value: queue.shift() as FetcherResult }); + else + resolve({ done: true, value: undefined }); + } + } + + return { + next(): Promise<IteratorResult<FetcherResult>> { + if (error) { + const err = error; + error = null; + return Promise.reject(err); + } + + if (queue.length > 0) + return Promise.resolve({ done: false, value: queue.shift() as FetcherResult }); + + if (done) + return Promise.resolve({ done: true, value: undefined }); + + return new Promise<IteratorResult<FetcherResult>>((resolve) => { + resolvers.push(resolve); + }); + }, + return(): Promise<IteratorResult<FetcherResult>> { + dispose(); + done = true; + flush(); + return Promise.resolve({ done: true, value: undefined }); + } + }; + } + }; }; } diff --git a/source/library/index.ts b/source/library/index.ts index 5aecff1..2bec6e4 100644 --- a/source/library/index.ts +++ b/source/library/index.ts @@ -24,5 +24,12 @@ export type { export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts"; export type { OperationInfo } from "./graphql/operations.ts"; +export type { SseFetcherOptions } from "./fetcher/sse.ts"; export type { Storage } from "./state/storage.ts"; -export type { Tab, TabSeed } from "./state/session.svelte.ts"; +export type { + RunOptions, + SubscriptionMode, + Tab, + TabSeed +} from "./state/session.svelte.ts"; +export type { WsFetcherOptions } from "./fetcher/websocket.ts"; diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts index d9f52ff..9345d29 100644 --- a/source/library/state/session.svelte.ts +++ b/source/library/state/session.svelte.ts @@ -3,7 +3,7 @@ /*** UTILITY ------------------------------------------ ***/ -import type { Fetcher } from "../fetcher/types.ts"; +import type { Fetcher, FetcherResult } from "../fetcher/types.ts"; import { deriveTitle, parseOperations, @@ -13,6 +13,12 @@ import type { Storage } from "./storage.ts"; const STORAGE_KEY = "session"; +function isAsyncIterable<T>(value: unknown): value is AsyncIterable<T> { + return typeof value === "object" && + value !== null && + typeof (value as AsyncIterable<T>)[Symbol.asyncIterator] === "function"; +} + type Snapshot = { activeId: string; tabs: Tab[]; @@ -40,6 +46,13 @@ export type TabSeed = { variables?: string; }; +export type SubscriptionMode = "append" | "replace"; + +export type RunOptions = { + signal?: AbortSignal; + subscriptionMode?: SubscriptionMode; +}; + export class SessionStore { activeId = $state<string>(""); tabs = $state<Tab[]>([]); @@ -115,12 +128,15 @@ export class SessionStore { tab.titleDirty = true; } - async run(fetcher: Fetcher): Promise<boolean> { + async run(fetcher: Fetcher, options: RunOptions = {}): Promise<boolean> { const tab = this.active; if (!tab) return false; + const mode = options.subscriptionMode ?? "append"; + const signal = options.signal; + try { const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {}; const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {}; @@ -132,6 +148,39 @@ export class SessionStore { variables }); + if (isAsyncIterable<FetcherResult>(result)) { + tab.result = ""; + const iterator = result[Symbol.asyncIterator](); + + try { + while (true) { + if (signal?.aborted) { + await iterator.return?.(); + break; + } + + const step = await iterator.next(); + + if (step.done) + break; + + const payload = JSON.stringify(step.value, null, 2); + + if (mode === "append") { + const stamp = new Date().toISOString(); + const chunk = `// ${stamp}\n${payload}\n`; + tab.result = tab.result ? `${tab.result}\n${chunk}` : chunk; + } else { + tab.result = payload; + } + } + } finally { + await iterator.return?.(); + } + + return true; + } + tab.result = JSON.stringify(result, null, 2); return true; } catch(err) { diff --git a/source/library/styles/theme.scss b/source/library/styles/theme.scss new file mode 100644 index 0000000..55f24aa --- /dev/null +++ b/source/library/styles/theme.scss @@ -0,0 +1,47 @@ +// @eol/graphiql chrome theme variables. +// +// Override any variable under a scoped selector (e.g. `.graphiql`) or on :root +// to re-skin the chrome. The editor theme is a separate prop (`theme?: Extension`). +// +// Supported variables: +// --graphiql-accent primary action color (Run button, selected state) +// --graphiql-bg base background (editor + result) +// --graphiql-border divider lines between panes +// --graphiql-fg primary foreground +// --graphiql-font font-family for the chrome +// --graphiql-link clickable links in docs / breadcrumbs +// --graphiql-muted muted foreground (hints, timestamps) +// --graphiql-panel panel background (toolbar, tabbar, headers) + +:root { + --graphiql-accent: #0e639c; + --graphiql-bg: #1e1e1e; + --graphiql-border: #333; + --graphiql-fg: #d4d4d4; + --graphiql-font: ui-monospace, SFMono-Regular, monospace; + --graphiql-link: #79b8ff; + --graphiql-muted: #858585; + --graphiql-panel: #252526; +} + +@media (prefers-color-scheme: light) { + :root:not(.graphiql-dark) { + --graphiql-accent: #0366d6; + --graphiql-bg: #ffffff; + --graphiql-border: #e1e4e8; + --graphiql-fg: #24292e; + --graphiql-link: #0366d6; + --graphiql-muted: #6a737d; + --graphiql-panel: #f6f8fa; + } +} + +:root.graphiql-light { + --graphiql-accent: #0366d6; + --graphiql-bg: #ffffff; + --graphiql-border: #e1e4e8; + --graphiql-fg: #24292e; + --graphiql-link: #0366d6; + --graphiql-muted: #6a737d; + --graphiql-panel: #f6f8fa; +} |