diff options
Diffstat (limited to 'source/library')
| -rw-r--r-- | source/library/GraphiQL.svelte | 307 | ||||
| -rw-r--r-- | source/library/components/HistoryPanel.svelte | 106 | ||||
| -rw-r--r-- | source/library/components/ResultViewer.svelte | 51 | ||||
| -rw-r--r-- | source/library/components/Splitter.svelte | 123 | ||||
| -rw-r--r-- | source/library/components/Toolbar.svelte | 5 | ||||
| -rw-r--r-- | source/library/fetcher/apq.ts | 101 | ||||
| -rw-r--r-- | source/library/fetcher/http-body.ts | 31 | ||||
| -rw-r--r-- | source/library/fetcher/http.ts | 19 | ||||
| -rw-r--r-- | source/library/graphql/format.ts | 22 | ||||
| -rw-r--r-- | source/library/index.ts | 15 | ||||
| -rw-r--r-- | source/library/state/keyboard.ts | 50 | ||||
| -rw-r--r-- | source/library/state/session-io.ts | 127 | ||||
| -rw-r--r-- | source/library/state/session.svelte.ts | 142 |
13 files changed, 1031 insertions, 68 deletions
diff --git a/source/library/GraphiQL.svelte b/source/library/GraphiQL.svelte index 329dd6f..4250fd3 100644 --- a/source/library/GraphiQL.svelte +++ b/source/library/GraphiQL.svelte @@ -10,14 +10,17 @@ import HeadersEditor from "./components/HeadersEditor.svelte"; import HistoryPanel from "./components/HistoryPanel.svelte"; import ResultViewer from "./components/ResultViewer.svelte"; + import Splitter from "./components/Splitter.svelte"; import TabBar from "./components/TabBar.svelte"; import Toolbar from "./components/Toolbar.svelte"; import type { Extension } from "@codemirror/state"; import type { Fetcher } from "./fetcher/types.ts"; import { HistoryStore } from "./state/history.svelte.ts"; + import { matchShortcut } from "./state/keyboard.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 { validateSessionExport } from "./state/session-io.ts"; import { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; import type { Storage } from "./state/storage.ts"; @@ -59,10 +62,28 @@ session.updateQuery(session.active.id, initialQuery); let bottomPane = $state<"variables" | "headers">("variables"); + let bottomHeight = $state(resolvedStorage.get<number>("layout.bottomHeight") ?? 35); + let centerEl = $state<HTMLDivElement | null>(null); let docsOpen = $state(resolvedStorage.get<boolean>("docExplorer") ?? false); + let docsWidth = $state(resolvedStorage.get<number>("layout.docsWidth") ?? 320); + let historyNotice = $state<string | null>(null); let historyOpen = $state(resolvedStorage.get<boolean>("historyPanel") ?? false); + let historyWidth = $state(resolvedStorage.get<number>("layout.historyWidth") ?? 260); + let leftEl = $state<HTMLDivElement | null>(null); + let leftWidth = $state(resolvedStorage.get<number>("layout.leftWidth") ?? 50); let running = $state(false); + let dragStartHistoryWidth = 0; + let dragStartDocsWidth = 0; + let dragStartLeftWidth = 0; + let dragStartLeftWidthPx = 0; + let dragStartBottomHeight = 0; + let dragStartBottomHeightPx = 0; + + function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), max); + } + $effect(() => { void session.tabs; void session.activeId; @@ -92,6 +113,22 @@ resolvedStorage.set("historyPanel", historyOpen); }); + $effect(() => { + resolvedStorage.set("layout.bottomHeight", bottomHeight); + }); + + $effect(() => { + resolvedStorage.set("layout.docsWidth", docsWidth); + }); + + $effect(() => { + resolvedStorage.set("layout.historyWidth", historyWidth); + }); + + $effect(() => { + resolvedStorage.set("layout.leftWidth", leftWidth); + }); + onMount(() => { schema.introspect(fetcher); }); @@ -149,6 +186,61 @@ session.overwriteActive(seed); } + function onExportSession() { + if (typeof globalThis.URL === "undefined") + return; + + if (typeof globalThis.document === "undefined") + return; + + const data = session.exportAll(); + const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = globalThis.document.createElement("a"); + a.download = `graphiql-session-${data.exportedAt}.json`; + a.href = url; + a.click(); + URL.revokeObjectURL(url); + } + + async function onImportSession(file: File) { + let text: string; + + try { + text = await file.text(); + } catch (err) { + historyNotice = `Import failed: ${String(err)}`; + return; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(text); + } catch (err) { + historyNotice = `Import failed: invalid JSON (${String(err)})`; + return; + } + + const valid = validateSessionExport(parsed); + + if ("error" in valid) { + historyNotice = `Import failed: ${valid.error}`; + return; + } + + const result = session.importTabs(valid, { mode: "append" }); + const parts = [`Imported ${result.added} tab${result.added === 1 ? "" : "s"}`]; + + if (result.skipped > 0) + parts.push(`skipped ${result.skipped}`); + + if (result.errors.length > 0) + parts.push(`${result.errors.length} error${result.errors.length === 1 ? "" : "s"}`); + + historyNotice = parts.join(", "); + } + function onQueryChange(value: string) { if (session.active) session.updateQuery(session.active.id, value); @@ -165,17 +257,96 @@ } function onKeydown(event: KeyboardEvent) { - const meta = event.metaKey || event.ctrlKey; + const action = matchShortcut(event); - if (meta && event.key === "Enter") { - event.preventDefault(); - run(); + if (!action) + return; + + event.preventDefault(); + + switch (action.type) { + case "closeTab": + session.closeTab(session.activeId); + break; + case "format": + session.formatActive(); + break; + case "newTab": + session.addTab(); + break; + case "nextTab": + session.nextTab(); + break; + case "prevTab": + session.prevTab(); + break; + case "run": + run(); + break; } } function onBeforeUnload() { session.persist(); } + + function onHistoryDragStart() { + dragStartHistoryWidth = historyWidth; + } + + function onHistoryDrag(dx: number) { + historyWidth = clamp(dragStartHistoryWidth + dx, 200, 500); + } + + function onHistoryKeyAdjust(delta: number) { + historyWidth = clamp(historyWidth + delta, 200, 500); + } + + function onDocsDragStart() { + dragStartDocsWidth = docsWidth; + } + + function onDocsDrag(dx: number) { + docsWidth = clamp(dragStartDocsWidth - dx, 240, 600); + } + + function onDocsKeyAdjust(delta: number) { + docsWidth = clamp(docsWidth - delta, 240, 600); + } + + function onLeftDragStart() { + dragStartLeftWidth = leftWidth; + dragStartLeftWidthPx = centerEl?.getBoundingClientRect().width ?? 0; + } + + function onLeftDrag(dx: number) { + if (dragStartLeftWidthPx === 0) + return; + + const percentDelta = (dx / dragStartLeftWidthPx) * 100; + leftWidth = clamp(dragStartLeftWidth + percentDelta, 20, 80); + } + + function onLeftKeyAdjust(delta: number) { + leftWidth = clamp(leftWidth + delta, 20, 80); + } + + function onBottomDragStart() { + dragStartBottomHeight = bottomHeight; + dragStartBottomHeightPx = leftEl?.getBoundingClientRect().height ?? 0; + } + + function onBottomDrag(_dx: number, dy: number) { + if (dragStartBottomHeightPx === 0) + return; + + const percentDelta = (dy / dragStartBottomHeightPx) * 100; + bottomHeight = clamp(dragStartBottomHeight - percentDelta, 15, 70); + } + + function onBottomKeyAdjust(delta: number) { + bottomHeight = clamp(bottomHeight - delta, 15, 70); + } </script> <style lang="scss"> @@ -191,27 +362,34 @@ .panes { display: grid; - grid-template-columns: 1fr 1fr; + grid-template-columns: 1fr; min-height: 0; &.history-open { - grid-template-columns: 260px 1fr 1fr; + grid-template-columns: var(--graphiql-history-width) 6px 1fr; } &.docs-open { - grid-template-columns: 1fr 1fr 320px; + grid-template-columns: 1fr 6px var(--graphiql-docs-width); } &.history-open.docs-open { - grid-template-columns: 260px 1fr 1fr 320px; + grid-template-columns: var(--graphiql-history-width) 6px 1fr 6px var(--graphiql-docs-width); } } + .center { + display: grid; + grid-template-columns: var(--graphiql-left-width) 6px calc(100% - var(--graphiql-left-width) - 6px); + min-height: 0; + min-width: 0; + } + .left { - border-right: 1px solid var(--graphiql-border, #333); display: grid; - grid-template-rows: 2fr auto 1fr; + grid-template-rows: 1fr auto 6px var(--graphiql-bottom-height); min-height: 0; + min-width: 0; } .query { @@ -267,6 +445,7 @@ {docsOpen} extras={toolbarExtras} {historyOpen} + onFormat={() => session.formatActive()} onRun={run} onSelectOperation={(name) => { if (session.active) @@ -286,53 +465,93 @@ onRename={(id, title) => session.renameTab(id, title)} onSelect={(id) => session.selectTab(id)} tabs={session.tabs}/> - <div class="panes" class:docs-open={docsOpen && schema.schema} class:history-open={historyOpen}> + <div + class="panes" + class:docs-open={docsOpen && schema.schema} + class:history-open={historyOpen} + style="--graphiql-history-width: {historyWidth}px; --graphiql-docs-width: {docsWidth}px;" + > {#if historyOpen} <HistoryPanel entries={history.entries} + notice={historyNotice} onClear={() => history.clear()} + onDismissNotice={() => (historyNotice = null)} + onExport={onExportSession} onFavorite={(id) => history.favorite(id)} + onImport={onImportSession} onLoad={loadHistory} onRemove={(id) => history.remove(id)}/> + <Splitter + onDrag={onHistoryDrag} + onDragStart={onHistoryDragStart} + onKeyAdjust={onHistoryKeyAdjust} + orientation="horizontal"/> {/if} - <div class="left"> - <div class="query"> - <Editor - language="graphql" - onChange={onQueryChange} - schema={schema.sdl} - {theme} - value={session.active?.query ?? ""}/> - </div> - <div class="switcher"> - <button - class="switch" - class:active={bottomPane === "variables"} - onclick={() => (bottomPane = "variables")}>Variables</button> - <button - class="switch" - class:active={bottomPane === "headers"} - onclick={() => (bottomPane = "headers")}>Headers</button> - </div> - <div class="bottom"> - {#if bottomPane === "variables"} + <div + bind:this={centerEl} + class="center" + style="--graphiql-left-width: {leftWidth}%;" + > + <div bind:this={leftEl} class="left" style="--graphiql-bottom-height: {bottomHeight}%;"> + <div class="query"> <Editor - language="json" - onChange={onVariablesChange} - {theme} - value={session.active?.variables ?? "{}"}/> - {:else} - <HeadersEditor - onChange={onHeadersChange} + language="graphql" + onChange={onQueryChange} + schema={schema.sdl} {theme} - value={session.active?.headers ?? "{}"}/> - {/if} + value={session.active?.query ?? ""}/> + </div> + <div class="switcher"> + <button + class="switch" + class:active={bottomPane === "variables"} + onclick={() => (bottomPane = "variables")}>Variables</button> + <button + class="switch" + class:active={bottomPane === "headers"} + onclick={() => (bottomPane = "headers")}>Headers</button> + </div> + <Splitter + onDrag={onBottomDrag} + onDragStart={onBottomDragStart} + onKeyAdjust={onBottomKeyAdjust} + orientation="vertical"/> + <div class="bottom"> + {#if bottomPane === "variables"} + <Editor + language="json" + onChange={onVariablesChange} + {theme} + value={session.active?.variables ?? "{}"}/> + {:else} + <HeadersEditor + onChange={onHeadersChange} + {theme} + value={session.active?.headers ?? "{}"}/> + {/if} + </div> + </div> + <Splitter + onDrag={onLeftDrag} + onDragStart={onLeftDragStart} + onKeyAdjust={onLeftKeyAdjust} + orientation="horizontal"/> + <div class="right"> + <ResultViewer + footer={resultFooter} + streamIntervals={session.active?.streamIntervals ?? []} + {theme} + timing={session.active?.timing ?? null} + value={session.active?.result ?? ""}/> </div> - </div> - <div class="right"> - <ResultViewer footer={resultFooter} {theme} value={session.active?.result ?? ""}/> </div> {#if docsOpen && schema.schema} + <Splitter + onDrag={onDocsDrag} + onDragStart={onDocsDragStart} + onKeyAdjust={onDocsKeyAdjust} + orientation="horizontal"/> <DocExplorer schema={schema.schema}/> {/if} </div> diff --git a/source/library/components/HistoryPanel.svelte b/source/library/components/HistoryPanel.svelte index b7f5c4c..01f397a 100644 --- a/source/library/components/HistoryPanel.svelte +++ b/source/library/components/HistoryPanel.svelte @@ -3,13 +3,29 @@ type Props = { entries: HistoryEntry[]; + notice?: string | null; onClear: () => void; + onDismissNotice?: () => void; + onExport?: () => void; onFavorite: (id: string) => void; + onImport?: (file: File) => void; onLoad: (id: string, inNewTab: boolean) => void; onRemove: (id: string) => void; }; - let { entries, onClear, onFavorite, onLoad, onRemove }: Props = $props(); + let { + entries, + notice = null, + onClear, + onDismissNotice, + onExport, + onFavorite, + onImport, + onLoad, + onRemove + }: Props = $props(); + + let fileInput = $state<HTMLInputElement | null>(null); const sorted = $derived([...entries].sort((a, b) => { if (a.favorite !== b.favorite) @@ -33,6 +49,20 @@ onLoad(id, event.shiftKey); } } + + function onImportClick() { + fileInput?.click(); + } + + function onFileChange(event: Event) { + const input = event.currentTarget as HTMLInputElement; + const file = input.files?.[0]; + + if (file) + onImport?.(file); + + input.value = ""; + } </script> <style lang="scss"> @@ -50,6 +80,7 @@ align-items: center; border-bottom: 1px solid var(--graphiql-border, #333); display: flex; + gap: 0.5rem; justify-content: space-between; padding: 0.5rem 0.75rem; } @@ -59,7 +90,12 @@ font-weight: 600; } - .clear { + .actions { + display: flex; + gap: 0.5rem; + } + + .action { background: none; border: none; color: var(--graphiql-muted, #858585); @@ -73,6 +109,36 @@ } } + .hidden-input { + display: none; + } + + .notice { + align-items: center; + background: var(--graphiql-bg, #1e1e1e); + border-bottom: 1px solid var(--graphiql-border, #333); + color: var(--graphiql-fg, #d4d4d4); + display: flex; + font-size: 0.75rem; + gap: 0.5rem; + justify-content: space-between; + padding: 0.375rem 0.75rem; + } + + .notice-dismiss { + background: none; + border: none; + color: var(--graphiql-muted, #858585); + cursor: pointer; + font-size: 0.875rem; + line-height: 1; + padding: 0 0.25rem; + + &:hover { + color: var(--graphiql-fg, #d4d4d4); + } + } + .list { display: grid; gap: 0.125rem; @@ -147,10 +213,38 @@ </style> <div class="panel"> - <div class="header"> - <span class="title">History</span> - {#if entries.length > 0} - <button class="clear" onclick={onClear} type="button">Clear</button> + <div> + <div class="header"> + <span class="title">History</span> + <div class="actions"> + {#if onExport} + <button class="action" onclick={onExport} type="button">Export</button> + {/if} + {#if onImport} + <button class="action" onclick={onImportClick} type="button">Import</button> + <input + accept="application/json" + bind:this={fileInput} + class="hidden-input" + onchange={onFileChange} + type="file"/> + {/if} + {#if entries.length > 0} + <button class="action" onclick={onClear} type="button">Clear</button> + {/if} + </div> + </div> + {#if notice} + <div class="notice"> + <span>{notice}</span> + {#if onDismissNotice} + <button + aria-label="Dismiss notice" + class="notice-dismiss" + onclick={onDismissNotice} + type="button">×</button> + {/if} + </div> {/if} </div> <div class="list"> diff --git a/source/library/components/ResultViewer.svelte b/source/library/components/ResultViewer.svelte index 083828d..d277ec8 100644 --- a/source/library/components/ResultViewer.svelte +++ b/source/library/components/ResultViewer.svelte @@ -2,14 +2,50 @@ import Editor from "./Editor.svelte"; import type { Extension } from "@codemirror/state"; import type { Snippet } from "svelte"; + import type { TabTiming } from "../state/session.svelte.ts"; type Props = { footer?: Snippet<[{ result: string }]>; + streamIntervals?: number[]; theme?: Extension; + timing?: TabTiming | null; value: string; }; - let { footer, theme, value }: Props = $props(); + let { + footer, + streamIntervals = [], + theme, + timing = null, + value + }: Props = $props(); + + function median(values: number[]): number { + if (values.length === 0) + return 0; + + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + + return sorted.length % 2 === 0 ? + (sorted[mid - 1] + sorted[mid]) / 2 : + sorted[mid]; + } + + let metadata = $derived.by(() => { + if (!timing) + return null; + + if (streamIntervals.length > 0) { + const medianMs = Math.round(median(streamIntervals)); + const messages = streamIntervals.length + 1; + return `→ ${messages} messages · median ${medianMs}ms`; + } + + const totalMs = Math.round(timing.endMs - timing.startMs); + const firstByteMs = Math.round(timing.firstByteMs - timing.startMs); + return `→ ${totalMs}ms · first byte ${firstByteMs}ms`; + }); function noop(_v: string) {} </script> @@ -17,7 +53,7 @@ <style lang="scss"> .result { display: grid; - grid-template-rows: auto 1fr auto; + grid-template-rows: auto 1fr auto auto; height: 100%; min-height: 0; } @@ -30,6 +66,14 @@ text-transform: uppercase; } + .metadata { + background: var(--graphiql-panel, #252526); + border-top: 1px solid var(--graphiql-border, #333); + color: var(--graphiql-muted, #858585); + font-size: 0.75rem; + padding: 0.25rem 0.75rem; + } + .footer { background: var(--graphiql-panel, #252526); border-top: 1px solid var(--graphiql-border, #333); @@ -41,6 +85,9 @@ <div class="result"> <div class="label">Response</div> <Editor language="json" onChange={noop} readOnly {theme} {value}/> + {#if metadata} + <div class="metadata">{metadata}</div> + {/if} {#if footer} <div class="footer">{@render footer({ result: value })}</div> {/if} diff --git a/source/library/components/Splitter.svelte b/source/library/components/Splitter.svelte new file mode 100644 index 0000000..f4138f2 --- /dev/null +++ b/source/library/components/Splitter.svelte @@ -0,0 +1,123 @@ +<script lang="ts"> + type Props = { + onDrag: (dx: number, dy: number) => void; + onDragEnd?: () => void; + onDragStart?: () => void; + onKeyAdjust?: (delta: number) => void; + orientation: "horizontal" | "vertical"; + }; + + let { onDrag, onDragEnd, onDragStart, onKeyAdjust, orientation }: Props = $props(); + + let dragging = $state(false); + let startX = $state(0); + let startY = $state(0); + + function onPointerDown(event: PointerEvent) { + event.preventDefault(); + dragging = true; + startX = event.clientX; + startY = event.clientY; + event.currentTarget instanceof Element && + event.currentTarget.setPointerCapture(event.pointerId); + onDragStart?.(); + } + + function onPointerMove(event: PointerEvent) { + if (!dragging) + return; + + onDrag(event.clientX - startX, event.clientY - startY); + } + + function onPointerUp(event: PointerEvent) { + if (!dragging) + return; + + dragging = false; + event.currentTarget instanceof Element && + event.currentTarget.releasePointerCapture(event.pointerId); + onDragEnd?.(); + } + + function onKeydown(event: KeyboardEvent) { + if (!onKeyAdjust) + return; + + if (orientation === "horizontal") { + if (event.key === "ArrowLeft") { + event.preventDefault(); + onKeyAdjust(-16); + } else if (event.key === "ArrowRight") { + event.preventDefault(); + onKeyAdjust(16); + } + } else { + if (event.key === "ArrowUp") { + event.preventDefault(); + onKeyAdjust(-16); + } else if (event.key === "ArrowDown") { + event.preventDefault(); + onKeyAdjust(16); + } + } + } +</script> + +<style lang="scss"> + .splitter { + align-items: center; + background: transparent; + display: flex; + justify-content: center; + position: relative; + touch-action: none; + user-select: none; + + &:hover::after, + &.dragging::after { + background: var(--graphiql-accent, #0e639c); + } + + &::after { + background: var(--graphiql-border, #333); + content: ""; + position: absolute; + transition: background 120ms ease; + } + + &.horizontal { + cursor: col-resize; + height: 100%; + width: 6px; + + &::after { + height: 100%; + width: 1px; + } + } + + &.vertical { + cursor: row-resize; + height: 6px; + width: 100%; + + &::after { + height: 1px; + width: 100%; + } + } + } +</style> + +<div + aria-orientation={orientation} + class="splitter {orientation}" + class:dragging + onkeydown={onKeydown} + onpointerdown={onPointerDown} + onpointermove={onPointerMove} + onpointerup={onPointerUp} + role="separator" + tabindex="0" +></div> diff --git a/source/library/components/Toolbar.svelte b/source/library/components/Toolbar.svelte index 8c75668..9882b7d 100644 --- a/source/library/components/Toolbar.svelte +++ b/source/library/components/Toolbar.svelte @@ -8,6 +8,7 @@ docsOpen?: boolean; extras?: Snippet; historyOpen?: boolean; + onFormat?: () => void; onRun: () => void; onSelectOperation?: (name: string | null) => void; onToggleDocs?: () => void; @@ -24,6 +25,7 @@ docsOpen = false, extras, historyOpen = false, + onFormat, onRun, onSelectOperation, onToggleDocs, @@ -128,6 +130,9 @@ {/each} </select> {/if} + {#if onFormat} + <button class="toggle" {disabled} onclick={onFormat} type="button">Format</button> + {/if} {#if extras}{@render extras()}{/if} <span class="hint">⌘/Ctrl + Enter</span> {#if schemaLoading} diff --git a/source/library/fetcher/apq.ts b/source/library/fetcher/apq.ts new file mode 100644 index 0000000..7f9f3c1 --- /dev/null +++ b/source/library/fetcher/apq.ts @@ -0,0 +1,101 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { buildHeaders, postJson } from "./http-body.ts"; +import type { Fetcher, FetcherOptions } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export type ApqOptions = FetcherOptions & { + disable?: boolean; +}; + +export function createApqFetcher(options: ApqOptions): Fetcher { + const cache = new Map<string, string>(); + const fetchImpl = options.fetch ?? globalThis.fetch; + + return async (req) => { + const headers = buildHeaders(options, req); + const sha256Hash = await getHash(cache, req.query); + + const extensions = { + persistedQuery: { + sha256Hash, + version: 1 + } + }; + + if (options.disable === true) { + return await postJson(fetchImpl, options.url, headers, { + extensions, + operationName: req.operationName, + query: req.query, + variables: req.variables + }); + } + + const firstResponse = await postJson(fetchImpl, options.url, headers, { + extensions, + operationName: req.operationName, + variables: req.variables + }); + + if (!isPersistedQueryNotFound(firstResponse)) + return firstResponse; + + return await postJson(fetchImpl, options.url, headers, { + extensions, + operationName: req.operationName, + query: req.query, + variables: req.variables + }); + }; +} + +/*** INTERNAL ----------------------------------------- ***/ + +async function getHash(cache: Map<string, string>, query: string): Promise<string> { + const cached = cache.get(query); + + if (cached !== undefined) + return cached; + + const hash = await sha256Hex(query); + cache.set(query, hash); + return hash; +} + +function isPersistedQueryNotFound(response: Record<string, unknown>): boolean { + const errors = response.errors; + + if (!Array.isArray(errors)) + return false; + + for (const entry of errors) { + if (entry === null || typeof entry !== "object") + continue; + + const err = entry as Record<string, unknown>; + + if (err.message === "PersistedQueryNotFound") + return true; + + const ext = err.extensions; + + if (ext !== null && typeof ext === "object") { + const code = (ext as Record<string, unknown>).code; + + if (code === "PERSISTED_QUERY_NOT_FOUND") + return true; + } + } + + return false; +} + +async function sha256Hex(input: string): Promise<string> { + const buf = await globalThis.crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)); + return Array.from(new Uint8Array(buf)).map((b) => b.toString(16).padStart(2, "0")).join(""); +} diff --git a/source/library/fetcher/http-body.ts b/source/library/fetcher/http-body.ts new file mode 100644 index 0000000..b079d47 --- /dev/null +++ b/source/library/fetcher/http-body.ts @@ -0,0 +1,31 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { FetcherOptions, FetcherRequest } from "./types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +export function buildHeaders(options: FetcherOptions, req: FetcherRequest): Record<string, string> { + return { + "Content-Type": "application/json", + ...options.headers, + ...req.headers + }; +} + +export async function postJson( + fetchImpl: typeof globalThis.fetch, + url: string, + headers: Record<string, string>, + body: unknown +): Promise<Record<string, unknown>> { + const response = await fetchImpl(url, { + body: JSON.stringify(body), + headers, + method: "POST" + }); + + return await response.json(); +} diff --git a/source/library/fetcher/http.ts b/source/library/fetcher/http.ts index 3138226..64322f2 100644 --- a/source/library/fetcher/http.ts +++ b/source/library/fetcher/http.ts @@ -3,6 +3,7 @@ /*** UTILITY ------------------------------------------ ***/ +import { buildHeaders, postJson } from "./http-body.ts"; import type { Fetcher, FetcherOptions } from "./types.ts"; /*** EXPORT ------------------------------------------- ***/ @@ -11,20 +12,10 @@ export function createHttpFetcher(options: FetcherOptions): Fetcher { const fetchImpl = options.fetch ?? globalThis.fetch; return async (req) => { - const response = await fetchImpl(options.url, { - body: JSON.stringify({ - operationName: req.operationName, - query: req.query, - variables: req.variables - }), - headers: { - "Content-Type": "application/json", - ...options.headers, - ...req.headers - }, - method: "POST" + return await postJson(fetchImpl, options.url, buildHeaders(options, req), { + operationName: req.operationName, + query: req.query, + variables: req.variables }); - - return await response.json(); }; } diff --git a/source/library/graphql/format.ts b/source/library/graphql/format.ts new file mode 100644 index 0000000..905e25b --- /dev/null +++ b/source/library/graphql/format.ts @@ -0,0 +1,22 @@ + + + + +/*** IMPORT ------------------------------------------- ***/ + +import { parse, print } from "graphql"; + +/*** EXPORT ------------------------------------------- ***/ + +export function format(query: string): string { + const trimmed = query.trim(); + + if (!trimmed) + return query; + + try { + return print(parse(query)); + } catch { + return query; + } +} diff --git a/source/library/index.ts b/source/library/index.ts index 2bec6e4..d68955b 100644 --- a/source/library/index.ts +++ b/source/library/index.ts @@ -3,16 +3,20 @@ /*** EXPORT ------------------------------------------- ***/ +export { createApqFetcher } from "./fetcher/apq.ts"; export { createHttpFetcher } from "./fetcher/http.ts"; export { createLocalStorage, createMemoryStorage } from "./state/storage.ts"; export { createSseFetcher } from "./fetcher/sse.ts"; export { createWsFetcher } from "./fetcher/websocket.ts"; -export { default as GraphiQL } from "./GraphiQL.svelte"; +export { format } from "./graphql/format.ts"; export { HistoryStore } from "./state/history.svelte.ts"; export { lightTheme } from "./themes/light.ts"; +export { matchShortcut } from "./state/keyboard.ts"; export { SchemaStore } from "./state/schema.svelte.ts"; export { SessionStore } from "./state/session.svelte.ts"; +export { validateSessionExport } from "./state/session-io.ts"; +export type { ApqOptions } from "./fetcher/apq.ts"; export type { Extension } from "@codemirror/state"; export type { @@ -23,13 +27,20 @@ export type { } from "./fetcher/types.ts"; export type { HistoryEntry, HistoryInput } from "./state/history.svelte.ts"; +export type { + ImportResult, + SessionExport, + TabExport +} from "./state/session-io.ts"; export type { OperationInfo } from "./graphql/operations.ts"; +export type { ShortcutAction } from "./state/keyboard.ts"; export type { SseFetcherOptions } from "./fetcher/sse.ts"; export type { Storage } from "./state/storage.ts"; export type { RunOptions, SubscriptionMode, Tab, - TabSeed + TabSeed, + TabTiming } from "./state/session.svelte.ts"; export type { WsFetcherOptions } from "./fetcher/websocket.ts"; diff --git a/source/library/state/keyboard.ts b/source/library/state/keyboard.ts new file mode 100644 index 0000000..5f07b2c --- /dev/null +++ b/source/library/state/keyboard.ts @@ -0,0 +1,50 @@ + + + + +/*** EXPORT ------------------------------------------- ***/ + +export type ShortcutAction = + | { type: "closeTab" } + | { type: "format" } + | { type: "newTab" } + | { type: "nextTab" } + | { type: "prevTab" } + | { type: "run" }; + +export function matchShortcut(event: KeyboardEvent): ShortcutAction | null { + const meta = event.metaKey || event.ctrlKey; + + if (!meta) + return null; + + if (event.key === "Enter") { + if (event.shiftKey) + return { type: "newTab" }; + + if (!event.altKey) + return { type: "run" }; + + return null; + } + + if (event.shiftKey && !event.altKey) { + const key = event.key.toLowerCase(); + + if (key === "w") + return { type: "closeTab" }; + + if (key === "f") + return { type: "format" }; + } + + if (event.altKey && !event.shiftKey) { + if (event.key === "ArrowRight") + return { type: "nextTab" }; + + if (event.key === "ArrowLeft") + return { type: "prevTab" }; + } + + return null; +} diff --git a/source/library/state/session-io.ts b/source/library/state/session-io.ts new file mode 100644 index 0000000..a5e2ea9 --- /dev/null +++ b/source/library/state/session-io.ts @@ -0,0 +1,127 @@ + + + + +/*** UTILITY ------------------------------------------ ***/ + +import type { Tab } from "./session.svelte.ts"; + +const MAX_STRING_BYTES = 1024 * 1024; +const MAX_TABS = 50; + +function isObject(value: unknown): value is Record<string, unknown> { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function stringTooLong(value: string): boolean { + return value.length > MAX_STRING_BYTES; +} + +function validateTab(raw: unknown, index: number): TabExport | string { + if (!isObject(raw)) + return `tabs[${index}]: not an object`; + + if (typeof raw.headers !== "string") + return `tabs[${index}].headers: not a string`; + + if (typeof raw.query !== "string") + return `tabs[${index}].query: not a string`; + + if (typeof raw.title !== "string") + return `tabs[${index}].title: not a string`; + + if (typeof raw.variables !== "string") + return `tabs[${index}].variables: not a string`; + + if (raw.operationName !== null && typeof raw.operationName !== "string") + return `tabs[${index}].operationName: not a string or null`; + + if (stringTooLong(raw.headers)) + return `tabs[${index}].headers: exceeds 1 MB`; + + if (stringTooLong(raw.query)) + return `tabs[${index}].query: exceeds 1 MB`; + + if (stringTooLong(raw.title)) + return `tabs[${index}].title: exceeds 1 MB`; + + if (stringTooLong(raw.variables)) + return `tabs[${index}].variables: exceeds 1 MB`; + + if (typeof raw.operationName === "string" && stringTooLong(raw.operationName)) + return `tabs[${index}].operationName: exceeds 1 MB`; + + return { + headers: raw.headers, + operationName: raw.operationName, + query: raw.query, + title: raw.title, + variables: raw.variables + }; +} + +/*** EXPORT ------------------------------------------- ***/ + +export type TabExport = { + headers: string; + operationName: string | null; + query: string; + title: string; + variables: string; +}; + +export type SessionExport = { + exportedAt: string; + tabs: TabExport[]; + version: 1; +}; + +export type ImportResult = { + added: number; + errors: string[]; + skipped: number; +}; + +export function tabToExport(tab: Tab): TabExport { + return { + headers: tab.headers, + operationName: tab.operationName, + query: tab.query, + title: tab.title, + variables: tab.variables + }; +} + +export function validateSessionExport(data: unknown): SessionExport | { error: string } { + if (!isObject(data)) + return { error: "not an object" }; + + if (data.version !== 1) + return { error: `unsupported version: ${String(data.version)}` }; + + if (typeof data.exportedAt !== "string") + return { error: "exportedAt: not a string" }; + + if (!Array.isArray(data.tabs)) + return { error: "tabs: not an array" }; + + if (data.tabs.length > MAX_TABS) + return { error: `tabs: exceeds ${MAX_TABS}` }; + + const tabs: TabExport[] = []; + + for (let i = 0; i < data.tabs.length; i++) { + const result = validateTab(data.tabs[i], i); + + if (typeof result === "string") + return { error: result }; + + tabs.push(result); + } + + return { + exportedAt: data.exportedAt, + tabs, + version: 1 + }; +} diff --git a/source/library/state/session.svelte.ts b/source/library/state/session.svelte.ts index 9345d29..76777e5 100644 --- a/source/library/state/session.svelte.ts +++ b/source/library/state/session.svelte.ts @@ -4,11 +4,18 @@ /*** UTILITY ------------------------------------------ ***/ import type { Fetcher, FetcherResult } from "../fetcher/types.ts"; +import { format } from "../graphql/format.ts"; import { deriveTitle, parseOperations, type OperationInfo } from "../graphql/operations.ts"; +import { + tabToExport, + type ImportResult, + type SessionExport, + type TabExport +} from "./session-io.ts"; import type { Storage } from "./storage.ts"; const STORAGE_KEY = "session"; @@ -26,6 +33,12 @@ type Snapshot = { /*** EXPORT ------------------------------------------- ***/ +export type TabTiming = { + endMs: number; + firstByteMs: number; + startMs: number; +}; + export type Tab = { headers: string; id: string; @@ -33,6 +46,8 @@ export type Tab = { operations: OperationInfo[]; query: string; result: string; + streamIntervals: number[]; + timing: TabTiming | null; title: string; titleDirty: boolean; variables: string; @@ -103,6 +118,85 @@ export class SessionStore { this.activeId = this.tabs[Math.max(0, idx - 1)].id; } + exportAll(): SessionExport { + return { + exportedAt: new Date().toISOString(), + tabs: this.tabs.map(tabToExport), + version: 1 + }; + } + + exportTab(id: string): TabExport | null { + const tab = this.tabs.find((t) => t.id === id); + + if (!tab) + return null; + + return tabToExport(tab); + } + + formatActive() { + const tab = this.active; + + if (!tab) + return; + + const next = format(tab.query); + + if (next === tab.query) + return; + + tab.query = next; + tab.operations = parseOperations(next); + + if (!tab.titleDirty) + tab.title = deriveTitle(next, tab.operations); + } + + importTabs(data: SessionExport, opts: { mode: "append" | "replace" }): ImportResult { + const errors: string[] = []; + const capped = data.tabs.slice(0, 50); + const skipped = data.tabs.length - capped.length; + + if (opts.mode === "replace") { + this.tabs = []; + + for (const t of capped) + this.tabs.push(this.#seeded(t)); + + if (this.tabs.length === 0) { + const fresh = this.#blank(); + this.tabs = [fresh]; + this.activeId = fresh.id; + } else { + this.activeId = this.tabs[0].id; + } + + return { added: capped.length, errors, skipped }; + } + + for (const t of capped) + this.tabs.push(this.#seeded(t)); + + if (capped.length > 0) + this.activeId = this.tabs[this.tabs.length - 1].id; + + return { added: capped.length, errors, skipped }; + } + + nextTab() { + if (this.tabs.length <= 1) + return; + + const idx = this.tabs.findIndex((t) => t.id === this.activeId); + + if (idx === -1) + return; + + const nextIdx = (idx + 1) % this.tabs.length; + this.activeId = this.tabs[nextIdx].id; + } + persist() { this.#storage.set<Snapshot>(STORAGE_KEY, { activeId: this.activeId, @@ -110,6 +204,19 @@ export class SessionStore { }); } + prevTab() { + if (this.tabs.length <= 1) + return; + + const idx = this.tabs.findIndex((t) => t.id === this.activeId); + + if (idx === -1) + return; + + const prevIdx = (idx - 1 + this.tabs.length) % this.tabs.length; + this.activeId = this.tabs[prevIdx].id; + } + renameActive(title: string) { if (!this.active) return; @@ -137,6 +244,10 @@ export class SessionStore { const mode = options.subscriptionMode ?? "append"; const signal = options.signal; + tab.streamIntervals = []; + const startMs = performance.now(); + tab.timing = { endMs: startMs, firstByteMs: startMs, startMs }; + try { const variables = tab.variables.trim() ? JSON.parse(tab.variables) : {}; const headers = tab.headers.trim() ? JSON.parse(tab.headers) : {}; @@ -151,6 +262,8 @@ export class SessionStore { if (isAsyncIterable<FetcherResult>(result)) { tab.result = ""; const iterator = result[Symbol.asyncIterator](); + let firstByteRecorded = false; + let previousMs = startMs; try { while (true) { @@ -164,6 +277,23 @@ export class SessionStore { if (step.done) break; + const now = performance.now(); + + if (!firstByteRecorded) { + firstByteRecorded = true; + previousMs = now; + tab.timing = { ...tab.timing, endMs: now, firstByteMs: now }; + } else { + const delta = now - previousMs; + previousMs = now; + + if (tab.streamIntervals.length >= 500) + tab.streamIntervals.shift(); + + tab.streamIntervals = [...tab.streamIntervals, delta]; + tab.timing = { ...tab.timing, endMs: now }; + } + const payload = JSON.stringify(step.value, null, 2); if (mode === "append") { @@ -181,10 +311,14 @@ export class SessionStore { return true; } + const firstByteMs = performance.now(); + tab.timing = { ...tab.timing, firstByteMs }; tab.result = JSON.stringify(result, null, 2); + tab.timing = { ...tab.timing, endMs: performance.now() }; return true; } catch(err) { tab.result = JSON.stringify({ error: String(err) }, null, 2); + tab.timing = { ...tab.timing, endMs: performance.now() }; return false; } } @@ -201,6 +335,8 @@ export class SessionStore { tab.operationName = seed.operationName ?? null; tab.query = query; tab.result = ""; + tab.streamIntervals = []; + tab.timing = null; tab.variables = seed.variables ?? "{}"; if (seed.title && !tab.titleDirty) @@ -251,6 +387,8 @@ export class SessionStore { operations: [], query: "", result: "", + streamIntervals: [], + timing: null, title: "untitled", titleDirty: false, variables: "{}" @@ -265,6 +403,8 @@ export class SessionStore { operations: raw.operations ?? parseOperations(raw.query ?? ""), query: raw.query ?? "", result: raw.result ?? "", + streamIntervals: [], + timing: null, title: raw.title ?? "untitled", titleDirty: raw.titleDirty ?? raw.title !== "untitled", variables: raw.variables ?? "{}" @@ -283,6 +423,8 @@ export class SessionStore { operations, query, result: "", + streamIntervals: [], + timing: null, title, titleDirty: Boolean(seed.title), variables: seed.variables ?? "{}" |