aboutsummaryrefslogtreecommitdiff
path: root/source/library
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-24 14:17:38 -0700
committernetop://ウィビ <paul@webb.page>2026-04-24 14:17:38 -0700
commit261f3bdb77799009344aab4a60686b7186ebd3b0 (patch)
tree8e87c6610a307f15f0c4b32f68b19424273fc6ad /source/library
parent8a59f92d031963e23ecc84b75feecf43eb4dd146 (diff)
downloadgraphiql-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.svelte30
-rw-r--r--source/library/components/ResultViewer.svelte16
-rw-r--r--source/library/components/TabBar.svelte6
-rw-r--r--source/library/components/Toolbar.svelte4
-rw-r--r--source/library/fetcher/sse.ts100
-rw-r--r--source/library/fetcher/websocket.ts97
-rw-r--r--source/library/index.ts9
-rw-r--r--source/library/state/session.svelte.ts53
-rw-r--r--source/library/styles/theme.scss47
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;
+}