diff options
| author | netop://ウィビ <paul@webb.page> | 2026-04-24 07:43:33 -0700 |
|---|---|---|
| committer | netop://ウィビ <paul@webb.page> | 2026-04-24 07:43:33 -0700 |
| commit | 4c6194c4c2b5506f6d482347b0c13033ef17b5c7 (patch) | |
| tree | 310b315b23487b9a44da94cd21a970f6cc95c831 | |
| download | gq-4c6194c4c2b5506f6d482347b0c13033ef17b5c7.tar.gz gq-4c6194c4c2b5506f6d482347b0c13033ef17b5c7.zip | |
initial commit
| -rw-r--r-- | .gitignore | 3 | ||||
| -rw-r--r-- | README.md | 142 | ||||
| -rw-r--r-- | deno.json | 12 | ||||
| -rw-r--r-- | deno.lock | 96 | ||||
| -rwxr-xr-x | entry.ts | 17 | ||||
| -rwxr-xr-x | source/common.ts | 49 | ||||
| -rw-r--r-- | source/gql.ts | 158 | ||||
| -rwxr-xr-x | source/graphiql/markup.ts | 309 | ||||
| -rwxr-xr-x | source/graphiql/render.ts | 218 | ||||
| -rwxr-xr-x | source/http.ts | 144 | ||||
| -rw-r--r-- | source/import.ts | 104 | ||||
| -rwxr-xr-x | source/utility/types.ts | 55 |
12 files changed, 1307 insertions, 0 deletions
diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5dde04d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# OS garbage +.DS_Store +Thumbs.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..51436ad --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# @eol/gq + +A batteries-included GraphQL toolkit for Deno, wrapping `graphql-js` and `@graphql-tools/schema` with the bits most APIs end up reaching for anyway: a Fetch-compatible HTTP handler, a cached `gql` tag, a `.graphql` file loader, and a cleaned-up GraphQL Playground. + +## Install + +Published on JSR as [`@eol/gq`](https://jsr.io/@eol/gq). + +```bash +deno add jsr:@eol/gq +``` + +Or import directly: + +```ts +import { executeSchema, gql, GraphQLHTTP } from "jsr:@eol/gq"; +``` + +## Quick start + +```ts +import { executeSchema, gql, GraphQLHTTP } from "@eol/gq"; + +const schema = executeSchema({ + resolvers: { + Query: { + hello: (_, { name }) => `hello, ${name ?? "world"}` + } + }, + typeDefs: gql` + type Query { + hello(name: String): String + } + ` +}); + +Deno.serve( + { port: 8000 }, + GraphQLHTTP({ graphiql: true, schema }) +); +``` + +Visit `http://localhost:8000` in a browser for the Playground, or `POST` a query to the same URL. + +## Loading schemas from `.graphql` files + +Use `importQL` to read an entry file and resolve its imports into a single SDL string. + +### Explicit imports + +```graphql +# schema/schema.graphql +# import "./post.graphql" +# import "./user.graphql" + +type Query { + me: User + posts: [Post!]! +} +``` + +```ts +import { executeSchema, gql, importQL } from "@eol/gq"; + +const typeDefs = gql(await importQL("schema/schema.graphql")); +const schema = executeSchema({ resolvers, typeDefs }); +``` + +### Dynamic imports + +Drop `# DYNAMIC_IMPORTS` in your entry file and every `.graphql` file found one level below `<cwd>/schema/` gets inlined at that marker: + +``` +schema/ + schema.graphql # contains: # DYNAMIC_IMPORTS + post/ + post.graphql + user/ + user.graphql +``` + +## API + +All symbols are re-exported from the package root (`@eol/gq`). + +### `GraphQLHTTP(options)` + +Returns a `(request) => Promise<Response>` handler. Pluggable into `Deno.serve`, [oak](https://jsr.io/@oak/oak), [hono](https://jsr.io/@hono/hono), or anything else Fetch-shaped. + +Options: + +| Option | Type | Description | +| ------------------- | ------------------------------------- | ---------------------------------------------------- | +| `schema` | `GraphQLSchema` | Required. Executable schema. | +| `context` | `(req) => Ctx \| Promise<Ctx>` | Builds the resolver context per request. | +| `graphiql` | `boolean` | Serve the Playground on `GET` + `Accept: text/html`. | +| `headers` | `HeadersInit` | Extra headers merged into every response. | +| `playgroundOptions` | `Omit<RenderPageOptions, "endpoint">` | Passthrough options for the Playground renderer. | + +### `executeSchema(config)` + +Re-export of `@graphql-tools/schema`’s `makeExecutableSchema`, renamed for brevity. + +### `gql` *(tagged template)* + +Parses a GraphQL string into a `DocumentNode`. Results are cached by normalized source, and embedded `DocumentNode` interpolations are inlined from their original source. + +Companion knobs: + +- `disableExperimentalFragmentVariables()` +- `disableFragmentWarnings()` +- `enableExperimentalFragmentVariables()` +- `resetCaches()` + +### `importQL(path)` + +Reads a `.graphql` file and resolves its imports. See [Loading schemas from `.graphql` files](#loading-schemas-from-graphql-files). + +### `runHttpQuery(params, options, request)` + +Low-level executor that `GraphQLHTTP` delegates to. Use it if you’re rolling your own transport but still want the context wiring. + +### Types + +`GQLOptions`, `GQLRequest`, `GraphQLParams`, `GraphQLHandler`, plus the Playground types (`RenderPageOptions`, `MiddlewareOptions`, `ISettings`, `EditorColours`, `Tab`, `Theme`, `CursorShape`, `IntrospectionResult`). + +## Features + +- Import `*.graphql` files — explicit and dynamic — via `importQL`. +- GraphiQL/Playground code cleaned up; SVGs redrawn so they actually make sense. +- Ships typed; passes `deno check entry.ts` with no fuss. +- Zero build step — it’s Deno, you just import it. + +## TODO + +- Add a runnable example. +- Replace React Playground with Svelte/SvelteKit. +- Take over the world (real world, metaverse, or [yggdrasil](https://yggdrasil-network.github.io), whichever comes first). + +## License + +MIT. diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..444cf59 --- /dev/null +++ b/deno.json @@ -0,0 +1,12 @@ +{ + "exports": "./entry.ts", + "imports": { + "@graphql-tools/schema": "npm:@graphql-tools/schema@^10.0.33", + "@std/path": "jsr:@std/path@^1.1.4", + "graphql": "npm:graphql@^16.13.2", + "xss": "npm:xss@^1.0.15" + }, + "license": "MIT", + "name": "@eol/gq", + "version": "0.1.0" +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..462c783 --- /dev/null +++ b/deno.lock @@ -0,0 +1,96 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/internal@^1.0.12": "1.0.13", + "jsr:@std/path@^1.1.4": "1.1.4", + "npm:@graphql-tools/schema@^10.0.33": "10.0.33_graphql@16.13.2", + "npm:graphql@^16.13.2": "16.13.2", + "npm:xss@^1.0.15": "1.0.15" + }, + "jsr": { + "@std/internal@1.0.13": { + "integrity": "2f9546691d4ac2d32859c82dff284aaeac980ddeca38430d07941e7e288725c0" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + } + }, + "npm": { + "@graphql-tools/merge@9.1.9_graphql@16.13.2": { + "integrity": "sha512-iHUWNjRHeQRYdgIMIuChThOwoKzA9vrzYeslgfBo5eUYEyHGZCoDPjAavssoYXLwstYt1dZj2J22jSzc2DrN0Q==", + "dependencies": [ + "@graphql-tools/utils", + "graphql", + "tslib" + ] + }, + "@graphql-tools/schema@10.0.33_graphql@16.13.2": { + "integrity": "sha512-O6P3RIftO0jafnSsFAqpjurUuUxJ43s/AdPVLQsBkI6y4Ic/tKm4C1Qm1KKQsCDTOxXPJClh/v3g7k7yLKCFBQ==", + "dependencies": [ + "@graphql-tools/merge", + "@graphql-tools/utils", + "graphql", + "tslib" + ] + }, + "@graphql-tools/utils@11.1.0_graphql@16.13.2": { + "integrity": "sha512-PtFVG4r8Z2LEBSaPYQMusBiB3o6kjLVJyjCLbnWem/SpSuM21v6LTmgpkXfYU1qpBV2UGsFyuEnSJInl8fR1Ag==", + "dependencies": [ + "@graphql-typed-document-node/core", + "@whatwg-node/promise-helpers", + "cross-inspect", + "graphql", + "tslib" + ] + }, + "@graphql-typed-document-node/core@3.2.0_graphql@16.13.2": { + "integrity": "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ==", + "dependencies": [ + "graphql" + ] + }, + "@whatwg-node/promise-helpers@1.3.2": { + "integrity": "sha512-Nst5JdK47VIl9UcGwtv2Rcgyn5lWtZ0/mhRQ4G8NN2isxpq2TO30iqHzmwoJycjWuyUfg3GFXqP/gFHXeV57IA==", + "dependencies": [ + "tslib" + ] + }, + "commander@2.20.3": { + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==" + }, + "cross-inspect@1.0.1": { + "integrity": "sha512-Pcw1JTvZLSJH83iiGWt6fRcT+BjZlCDRVwYLbUcHzv/CRpB7r0MlSrGbIyQvVSNyGnbt7G4AXuyCiDR3POvZ1A==", + "dependencies": [ + "tslib" + ] + }, + "cssfilter@0.0.10": { + "integrity": "sha512-FAaLDaplstoRsDR8XGYH51znUN0UY7nMc6Z9/fvE8EXGwvJE9hu7W2vHwx1+bd6gCYnln9nLbzxFTrcO9YQDZw==" + }, + "graphql@16.13.2": { + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "xss@1.0.15": { + "integrity": "sha512-FVdlVVC67WOIPvfOwhoMETV72f6GbW7aOabBC3WxN/oUdoEMDyLz4OgRv5/gck2ZeNqEQu+Tb0kloovXOfpYVg==", + "dependencies": [ + "commander", + "cssfilter" + ], + "bin": true + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/path@^1.1.4", + "npm:@graphql-tools/schema@^10.0.33", + "npm:graphql@^16.13.2", + "npm:xss@^1.0.15" + ] + } +} diff --git a/entry.ts b/entry.ts new file mode 100755 index 0000000..f564c5c --- /dev/null +++ b/entry.ts @@ -0,0 +1,17 @@ +/** + * `@eol/gq` — a batteries-included GraphQL module for Deno. + * + * Re-exports the pieces you typically reach for when building a GraphQL API: + * an HTTP handler, a schema builder, a `gql` tag, and a `.graphql` loader. + * + * @module + */ + +/*** EXPORT ------------------------------------------- ***/ + +export * from "./source/common.ts"; +export * from "./source/http.ts"; + +export { makeExecutableSchema as executeSchema } from "@graphql-tools/schema"; +export { gql } from "./source/gql.ts"; +export { importQL } from "./source/import.ts"; diff --git a/source/common.ts b/source/common.ts new file mode 100755 index 0000000..902bdd0 --- /dev/null +++ b/source/common.ts @@ -0,0 +1,49 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { graphql, type ExecutionResult } from "graphql"; + +/*** UTILITY ------------------------------------------ ***/ + +import type { + GQLOptions, + GQLRequest, + GraphQLParams +} from "./utility/types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Executes a GraphQL query or mutation against the provided schema. + * + * Resolves `options.context` (if given) with the incoming request, then + * forwards `query`/`mutation`, `operationName`, and `variables` to `graphql()`. + * + * @typeParam Req - Request shape; defaults to {@link GQLRequest}. + * @typeParam Ctx - Context value passed to resolvers; defaults to `{ request }`. + * @param params - Parsed GraphQL params (query or mutation, with optional vars). + * @param options - Executable schema plus optional context builder. + * @param request - The inbound HTTP request (used to build context). + * @returns The raw {@link ExecutionResult} from `graphql-js`. + */ +export async function runHttpQuery< + Req extends GQLRequest = GQLRequest, + Ctx = { request: Req }>( + params: GraphQLParams, + options: GQLOptions<Ctx, Req>, + request: Req): Promise<ExecutionResult> { + const contextValue = options.context ? + await options.context(request) : + { request } as Ctx; + const source = params.query! || params.mutation!; + + return await graphql({ + source, + ...options, + contextValue, + operationName: params.operationName, + variableValues: params.variables + }); +} diff --git a/source/gql.ts b/source/gql.ts new file mode 100644 index 0000000..f88d8ff --- /dev/null +++ b/source/gql.ts @@ -0,0 +1,158 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { + parse, + type DefinitionNode, + type DocumentNode, + type Location +} from "graphql"; + +/*** UTILITY ------------------------------------------ ***/ + +const docCache = new Map<string, DocumentNode>(); +const fragmentSourceMap = new Map<string, Set<string>>(); +let allowLegacyFragmentVariables = false; +let printFragmentWarnings = true; + +/*** EXPORT ------------------------------------------- ***/ + +/** Turns off support for legacy/experimental fragment variables (the default). */ +export function disableExperimentalFragmentVariables() { + allowLegacyFragmentVariables = false; +} + +/** Silences `console.warn` calls when duplicate fragment names are detected. */ +export function disableFragmentWarnings() { + printFragmentWarnings = false; +} + +/** Opts into parsing experimental fragment variables via `graphql-js`. */ +export function enableExperimentalFragmentVariables() { + allowLegacyFragmentVariables = true; +} + +/** + * Tagged-template literal that parses a GraphQL string into a `DocumentNode`. + * + * Parsed documents are cached by normalized source, so repeated calls with the + * same query return the same AST. Embedded `DocumentNode` interpolations are + * inlined from their original source. + * + * @example + * ```ts + * const Hello = gql`type Query { hello: String }`; + * ``` + */ +export function gql(literals: string | readonly string[], ...args: unknown[]): DocumentNode { + if (typeof literals === "string") + literals = [literals]; + + let result = literals[0]; + + args.forEach((arg, i) => { + if (arg && typeof arg === "object" && "kind" in arg && arg.kind === "Document") + result += (arg as DocumentNode).loc?.source.body ?? ""; + else + result += arg; + + result += literals[i + 1]; + }); + + return parseDocument(result); +} + +/** Clears the internal document and fragment caches used by {@link gql}. */ +export function resetCaches() { + docCache.clear(); + fragmentSourceMap.clear(); +} + +/*** HELPER ------------------------------------------- ***/ + +function normalize(string: string) { + return string.replace(/[\s,]+/g, " ").trim(); +} + +function cacheKeyFromLoc(loc: Location) { + return normalize(loc.source.body.substring(loc.start, loc.end)); +} + +function processFragments(ast: DocumentNode) { + const definitions: DefinitionNode[] = []; + const seenKeys = new Set<string>(); + + ast.definitions.forEach((fragmentDefinition) => { + if (fragmentDefinition.kind === "FragmentDefinition") { + const fragmentName = fragmentDefinition.name.value; + const sourceKey = cacheKeyFromLoc(fragmentDefinition.loc!); + let sourceKeySet = fragmentSourceMap.get(fragmentName)!; + + if (sourceKeySet && !sourceKeySet.has(sourceKey)) { + if (printFragmentWarnings) + console.warn(`Warning: fragment with name ${fragmentName} already exists.`); + } else if (!sourceKeySet) { + fragmentSourceMap.set(fragmentName, (sourceKeySet = new Set())); + } + + sourceKeySet.add(sourceKey); + + if (!seenKeys.has(sourceKey)) { + seenKeys.add(sourceKey); + definitions.push(fragmentDefinition); + } + } else { + definitions.push(fragmentDefinition); + } + }) + + return { + ...ast, + definitions + }; +} + +function stripLoc(doc: DocumentNode) { + const workSet = new Set<Record<string, unknown>>(doc.definitions as unknown as Record<string, unknown>[]); + + workSet.forEach((node) => { + if (node.loc) + delete node.loc; + + Object.keys(node).forEach((key) => { + const value = node[key]; + + if (value && typeof value === "object") + workSet.add(value as Record<string, unknown>); + }); + }); + + const loc = doc.loc as { endToken: unknown; startToken: unknown; }; + + if (loc) { + delete loc.startToken; + delete loc.endToken; + } + + return doc; +} + +function parseDocument(source: string) { + const cacheKey = normalize(source); + + if (!docCache.has(cacheKey)) { + const parsed = parse(source, { allowLegacyFragmentVariables }); + + if (!parsed || parsed.kind !== "Document") + throw new Error("Not a valid GraphQL document."); + + docCache.set( + cacheKey, + stripLoc(processFragments(parsed)) + ); + } + + return docCache.get(cacheKey)!; +} diff --git a/source/graphiql/markup.ts b/source/graphiql/markup.ts new file mode 100755 index 0000000..6bacbf3 --- /dev/null +++ b/source/graphiql/markup.ts @@ -0,0 +1,309 @@ + + + +/*** EXPORT ------------------------------------------- ***/ + +export const getLoadingMarkup = () => ({ + container: ` + <style> + @keyframes fadeIn { + from { + opacity: 0; + transform: translateY(-10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes fadeOut { + from { + opacity: 1; + transform: translateY(0); + } + + to { + opacity: 0; + transform: translateY(-10px); + } + } + + .fadeOut { + animation: fadeOut 0.5s ease-out forwards; + } + + #loading-wrapper { + width: 100vw; height: 100vh; + + align-items: center; + display: flex; + flex-direction: column; + justify-content: center; + position: absolute; + } + + .logo { + width: 75px; height: 75px; + + animation: fadeIn 0.5s ease-out forwards; + margin-bottom: 20px; + opacity: 0; + } + + .text { + animation: fadeIn 0.5s ease-out forwards; + color: rgba(255, 255, 255, 0.6); + font-size: 32px; + font-weight: 200; + opacity: 0; + text-align: center; + } + + .text-inner { + font-weight: 400; + } + </style> + + <div id="loading-wrapper"> + <svg class="logo" viewBox="0 0 128 128" xmlns="http://www.w3.org/2000/svg"> + <title>GraphQL Playground Logo</title> + + <style> + @keyframes appearIn { + from { + opacity: 0; + transform: translateY(0px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + + @keyframes scaleIn { + from { + transform: scale(0); + } + + to { + transform: scale(1); + } + } + + @keyframes innerDrawIn { + 0% { + stroke-dashoffset: 70; + } + + 50% { + stroke-dashoffset: 140; + } + + 100% { + stroke-dashoffset: 210; + } + } + + @keyframes outerDrawIn { + 0% { + stroke-dashoffset: 76; + } + + 100% { + stroke-dashoffset: 152; + } + } + + .circle-top, + .circle-top-right, + .circle-bottom-right, + .circle-bottom, + .circle-bottom-left, + .circle-top-left { + fill: white; + } + + .circle-top { + animation: scaleIn 0.25s linear forwards 0.2222222222222222s; + transform: scale(0); + transform-origin: 64px 28px; + } + + .circle-top-right { + animation: scaleIn 0.25s linear forwards 0.4222222222222222s; + transform: scale(0); + transform-origin: 95.98500061035156px 46.510000228881836px; + } + + .circle-bottom-right { + animation: scaleIn 0.25s linear forwards 0.6222222222222222s; + transform: scale(0); + transform-origin: 95.97162628173828px 83.4900016784668px; + } + + .circle-bottom { + animation: scaleIn 0.25s linear forwards 0.8222222222222223s; + transform: scale(0); + transform-origin: 64px 101.97999572753906px; + } + + .circle-bottom-left { + animation: scaleIn 0.25s linear forwards 1.0222222222222221s; + transform: scale(0); + transform-origin: 32.03982162475586px 83.4900016784668px; + } + + .circle-top-left { + animation: scaleIn 0.25s linear forwards 1.2222222222222223s; + transform: scale(0); + transform-origin: 32.033552169799805px 46.510000228881836px; + } + + .octoline-top-right, + .octoline-right, + .octoline-bottom-right, + .octoline-bottom-left, + .octoline-left, + .octoline-top-left { + stroke: white; + stroke-linecap: round; + stroke-linejoin: round; + stroke-width: 4; + } + + .octoline-top-right { + animation: outerDrawIn 0.5s ease-out forwards 0.3333333333333333s, appearIn 0.1s ease-out forwards 0.3333333333333333s; + animation-iteration-count: 1, 1; + opacity: 0; + stroke-dasharray: 76; + } + + .octoline-right { + animation: outerDrawIn 0.5s ease-out forwards 0.5333333333333333s, appearIn 0.1s ease-out forwards 0.5333333333333333s; + animation-iteration-count: 1, 1; + opacity: 0; + stroke-dasharray: 76; + } + + .octoline-bottom-right { + animation: outerDrawIn 0.5s ease-out forwards 0.7333333333333334s, appearIn 0.1s ease-out forwards 0.7333333333333334s; + animation-iteration-count: 1, 1; + opacity: 0; + stroke-dasharray: 76; + } + + .octoline-bottom-left { + animation: outerDrawIn 0.5s ease-out forwards 0.9333333333333333s, appearIn 0.1s ease-out forwards 0.9333333333333333s; + animation-iteration-count: 1, 1; + opacity: 0; + stroke-dasharray: 76; + } + + .octoline-left { + animation: outerDrawIn 0.5s ease-out forwards 1.1333333333333333s, appearIn 0.1s ease-out forwards 1.1333333333333333s; + animation-iteration-count: 1, 1; + opacity: 0; + stroke-dasharray: 76; + } + + .octoline-top-left { + animation: outerDrawIn 0.5s ease-out forwards 1.3333333333333333s, appearIn 0.1s ease-out forwards 1.3333333333333333s; + animation-iteration-count: 1, 1; + opacity: 0; + stroke-dasharray: 76; + } + + .triangle-bottom, + .triangle-left, + .triangle-right { + stroke: white; + stroke-linecap: round; + stroke-width: 4; + } + + .triangle-bottom { + animation: innerDrawIn 1s ease-in-out forwards 1.3666666666666667s, appearIn 0.1s linear forwards 1.3666666666666667s; + animation-iteration-count: infinite, 1; + opacity: 0; + stroke-dasharray: 70; + } + + .triangle-left { + animation: innerDrawIn 1s ease-in-out forwards 1.5333333333333332s, appearIn 0.1s linear forwards 1.5333333333333332s; + animation-iteration-count: infinite, 1; + opacity: 0; + stroke-dasharray: 70; + } + + .triangle-right { + animation: innerDrawIn 1s ease-in-out forwards 1.7000000000000002s, appearIn 0.1s linear forwards 1.7000000000000002s; + animation-iteration-count: infinite, 1; + opacity: 0; + stroke-dasharray: 70; + } + </style> + + <defs> + <linearGradient id="linearGradient" x1="4.86%" x2="96.21%" y1="0%" y2="99.66%"> + <stop stop-color="#e00082" stop-opacity="0.8" offset="0%"></stop> + <stop stop-color="#e00082" offset="100%"></stop> + </linearGradient> + </defs> + + <g> + <rect id="gradient" width="127.96" height="127.96" y="1" fill="url(#linearGradient)" rx="4"></rect> + <path id="border" fill="#e00082" fill-rule="nonzero" d="M4.7 2.84c-1.58 0-2.86 1.28-2.86 2.85v116.57c0 1.57 1.28 2.84 2.85 2.84h116.57c1.57 0 2.84-1.26 2.84-2.83V5.67c0-1.55-1.26-2.83-2.83-2.83H4.67zM4.7 0h116.58c3.14 0 5.68 2.55 5.68 5.7v116.58c0 3.14-2.54 5.68-5.68 5.68H4.68c-3.13 0-5.68-2.54-5.68-5.68V5.68C-1 2.56 1.55 0 4.7 0z"></path> + + <path + class="circle-top" x="64" y="28" + d="M64 36c-4.42 0-8-3.58-8-8s3.58-8 8-8 8 3.58 8 8-3.58 8-8 8" + style="transform: translate(100px, 100px);"></path> + <path + class="circle-top-right" x="95.98500061035156" y="46.510000228881836" + d="M89.04 50.52c-2.2-3.84-.9-8.73 2.94-10.96 3.83-2.2 8.72-.9 10.95 2.94 2.2 3.84.9 8.73-2.94 10.96-3.85 2.2-8.76.9-10.97-2.94" + style="transform: translate(100px, 100px);"></path> + <path + class="circle-bottom-right" x="95.97162628173828" y="83.4900016784668" + d="M102.9 87.5c-2.2 3.84-7.1 5.15-10.94 2.94-3.84-2.2-5.14-7.12-2.94-10.96 2.2-3.84 7.12-5.15 10.95-2.94 3.86 2.23 5.16 7.12 2.94 10.96" + style="transform: translate(100px, 100px);"></path> + <path + class="circle-bottom" x="64" y="101.97999572753906" + d="M64 110c-4.43 0-8-3.6-8-8.02 0-4.44 3.57-8.02 8-8.02s8 3.58 8 8.02c0 4.4-3.57 8.02-8 8.02" + style="transform: translate(100px, 100px);"></path> + <path + class="circle-bottom-left" x="32.03982162475586" y="83.4900016784668" + d="M25.1 87.5c-2.2-3.84-.9-8.73 2.93-10.96 3.83-2.2 8.72-.9 10.95 2.94 2.2 3.84.9 8.73-2.94 10.96-3.85 2.2-8.74.9-10.95-2.94" + style="transform: translate(100px, 100px);"></path> + <path + class="circle-top-left" x="32.033552169799805" y="46.510000228881836" + d="M38.96 50.52c-2.2 3.84-7.12 5.15-10.95 2.94-3.82-2.2-5.12-7.12-2.92-10.96 2.2-3.84 7.12-5.15 10.95-2.94 3.83 2.23 5.14 7.12 2.94 10.96" + style="transform: translate(100px, 100px);"></path> + + <path class="octoline-top-right" d="M63.55 27.5l32.9 19-32.9-19z"></path> + <path class="octoline-right" d="M96 46v38-38z"></path> + <path class="octoline-bottom-right" d="M96.45 84.5l-32.9 19 32.9-19z"></path> + <path class="octoline-bottom-left" d="M64.45 103.5l-32.9-19 32.9 19z"></path> + <path class="octoline-left" d="M32 84V46v38z"></path> + <path class="octoline-top-left" d="M31.55 46.5l32.9-19-32.9 19z"></path> + + <path class="triangle-bottom" d="M30 84h70"></path> + <path class="triangle-left" d="M65 26L30 87"></path> + <path class="triangle-right" d="M98 87L63 26"></path> + </g> + </svg> + + <div class="text">Loading + <span class="text-inner">GraphQL Playground</span> + </div> + </div> + `, + script: ` + const loadingWrapper = document.getElementById("loading-wrapper"); + + if (loadingWrapper) + loadingWrapper.classList.add("fadeOut"); + ` +}); diff --git a/source/graphiql/render.ts b/source/graphiql/render.ts new file mode 100755 index 0000000..a0e4dc2 --- /dev/null +++ b/source/graphiql/render.ts @@ -0,0 +1,218 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { filterXSS } from "xss"; + +/*** UTILITY ------------------------------------------ ***/ + +import { getLoadingMarkup } from "./markup.ts"; + +const CONFIG_ID = "playground-config"; +const loading = getLoadingMarkup(); + +const filter = (val: string) => { + return filterXSS(val, { + stripIgnoreTag: true, + stripIgnoreTagBody: ["script"], + whiteList: {} + }); +} + +const getCdnMarkup = ({ cdnUrl = "//cdn.jsdelivr.net/npm", faviconUrl, version }: { + cdnUrl?: string + faviconUrl?: string | null + version?: string +}) => { + const buildCDNUrl = (packageName: string, suffix: string) => + filter(`${cdnUrl}/${packageName}${version ? `@${version}` : ""}/${suffix}` || ""); + + return ` + <link rel="stylesheet" href="${buildCDNUrl("graphql-playground-react", "build/static/css/index.css")}"/> + ${typeof faviconUrl === "string" ? `<link rel="shortcut icon" href="${filter(faviconUrl || "")}" />` : ""} + ${faviconUrl === undefined ? `<link rel="shortcut icon" href="${buildCDNUrl("graphql-playground-react", "build/favicon.png")}" />` : ""} + <script src="${buildCDNUrl("graphql-playground-react", "build/static/js/middleware.js")}"></script> + `; +} + +const renderConfig = (config: unknown) => { + return filterXSS(`<div id="${CONFIG_ID}">${JSON.stringify(config)}</div>`, { + whiteList: { div: ["id"] } + }); +}; + +/*** EXPORT ------------------------------------------- ***/ + +export interface MiddlewareOptions { + codeTheme?: EditorColours; + config?: { [key: string]: unknown; }; + endpoint?: string; + env?: "electron" | "react"; + schema?: IntrospectionResult; + settings?: ISettings; + subscriptionEndpoint?: string; + tabs?: Tab[]; + workspaceName?: string; +} + +export type CursorShape = "line" | "block" | "underline"; +export type Theme = "dark" | "light"; + +export interface ISettings { + "editor.cursorShape": CursorShape; + "editor.fontFamily": string; + "editor.fontSize": number; + "editor.reuseHeaders": boolean; + "editor.theme": Theme; + "general.betaUpdates": boolean; + "request.credentials": string; + "request.globalHeaders": { [key: string]: string }; + "schema.polling.enable": boolean; + "schema.polling.endpointFilter": string; + "schema.polling.interval": number; + "tracing.hideTracingResponse": boolean; + "tracing.tracingSupported": boolean; +} + +export interface EditorColours { + atom: string; + attribute: string; + builtin: string; + comment: string; + cursorColor: string; + def: string; + editorBackground: string; + keyword: string; + leftDrawerBackground: string; + meta: string; + number: string; + property: string; + punctuation: string; + qualifier: string; + resultBackground: string; + rightDrawerBackground: string; + selection: string; + string: string; + string2: string; + variable: string; + ws: string; +} + +export interface IntrospectionResult { + __schema: { [key: string]: unknown; }; +} + +export interface RenderPageOptions extends MiddlewareOptions { + cdnUrl?: string; + env?: "electron" | "react"; + faviconUrl?: string | null; + title?: string; + version?: string; +} + +export interface Tab { + endpoint: string; + headers?: { [key: string]: string }; + name?: string; + query: string; + responses?: string[]; + variables?: string; +} + +/** + * Renders the GraphQL Playground HTML shell. + * + * Usually called indirectly via `GraphQLHTTP({ graphiql: true })`; invoke it + * directly if you need to embed the Playground in a custom route. + */ +export function renderPlaygroundPage(options: RenderPageOptions) { + const extendedOptions: + & Partial<{ + canSaveConfig: boolean + configString: string + }> + & RenderPageOptions = { + ...options, + canSaveConfig: false + }; + + if (options.config) + extendedOptions.configString = JSON.stringify(options.config, null, 2); + + if (!extendedOptions.endpoint && !extendedOptions.configString) + console.warn("WARNING: You did not provide an endpoint and do not have a .graphqlconfig. Make sure you have at least one of them."); + else if (extendedOptions.endpoint) + extendedOptions.endpoint = filter(extendedOptions.endpoint || ""); + + return ` + <!DOCTYPE html> + <html lang="en"> + <head> + <meta charset=utf-8/> + <meta name="viewport" content="user-scalable=no, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, minimal-ui"/> + <link href="https://brick.a.ssl.fastly.net/Open+Sans:300,400,600,700/Source+Code+Pro:400,700" rel="stylesheet"/> + <title>${extendedOptions.title || "GraphQL Playground"}</title> + ${extendedOptions.env === "react" || extendedOptions.env === "electron" ? "" : getCdnMarkup(extendedOptions)} + </head> + + <body> + <style> + html { + font-family: "Open Sans", sans-serif; + overflow: hidden; + } + + body { + background-color: #172a3a; + margin: 0; + } + + #${CONFIG_ID} { + display: none; + } + + .playgroundIn { + animation: playgroundIn 0.5s ease-out forwards; + } + + @keyframes playgroundIn { + from { + opacity: 0; + transform: translateY(10px); + } + + to { + opacity: 1; + transform: translateY(0); + } + } + </style> + + ${loading.container} + ${renderConfig(extendedOptions)} + <div id="root"/> + + <script> + window.addEventListener("load", () => { + ${loading.script} + + const root = document.getElementById("root"); + root.classList.add("playgroundIn"); + const configText = document.getElementById("${CONFIG_ID}").innerText; + + if (configText && configText.length) { + try { + GraphQLPlayground.init(root, JSON.parse(configText)); + } catch(_) { + console.error("could not find config"); + } + } else { + GraphQLPlayground.init(root); + } + }); + </script> + </body> + </html> + `; +} diff --git a/source/http.ts b/source/http.ts new file mode 100755 index 0000000..7d867a5 --- /dev/null +++ b/source/http.ts @@ -0,0 +1,144 @@ + + + +/*** UTILITY ------------------------------------------ ***/ + +import { runHttpQuery } from "./common.ts"; + +import type { + GQLOptions, + GQLRequest, + GraphQLParams +} from "./utility/types.ts"; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * A handler that accepts a `Request`-shaped object and returns a `Response`. + * + * Returned by {@link GraphQLHTTP}; plug it into `Deno.serve`, `serve()`, or + * any router that speaks the Fetch API. + */ +export type GraphQLHandler<Req extends GQLRequest = GQLRequest> = (request: Req) => Promise<Response>; + +/** + * Builds an HTTP handler that serves a GraphQL schema. + * + * Content negotiation: + * - `GET` with `Accept: text/html` and `graphiql: true` renders the Playground. + * - `GET` with `?query=...` executes the query. + * - `POST`/`PUT`/`PATCH` read JSON from the body. + * - Anything else returns 405; unacceptable `Accept` headers return 406. + * + * @param options - Schema, optional context builder, custom headers, and + * Playground toggles. See {@link GQLOptions}. + * @returns A {@link GraphQLHandler} ready to be mounted on any Fetch-compatible server. + * + * @example + * ```ts + * import { executeSchema, GraphQLHTTP, gql } from "@eol/gq"; + * + * const schema = executeSchema({ + * typeDefs: gql`type Query { hello: String }`, + * resolvers: { Query: { hello: () => "world" } } + * }); + * + * Deno.serve(GraphQLHTTP({ schema, graphiql: true })); + * ``` + */ +export function GraphQLHTTP< + Req extends GQLRequest = GQLRequest, + Ctx extends { request: Req } = { request: Req }>({ + playgroundOptions = {}, + headers = {}, + ...options + }: GQLOptions<Ctx, Req>): GraphQLHandler<Req> { + return async (request: Req) => { + const accept = request.headers.get("Accept") || ""; + + const typeList = ["application/json", "text/html", "text/plain", "*/*"] + .map(contentType => ({ + contentType, + index: accept.indexOf(contentType) + })) + .filter(({ index }) => index >= 0) + .sort((a, b) => a.index - b.index) + .map(({ contentType }) => contentType); + + if (accept && !typeList.length) { + return new Response("Not Acceptable", { + headers: new Headers(headers), + status: 406 + }); + } else if (!["GET", "PUT", "POST", "PATCH"].includes(request.method)) { + return new Response("Method Not Allowed", { + headers: new Headers(headers), + status: 405 + }); + } + + let params: Promise<GraphQLParams>; + + if (request.method === "GET") { + const urlQuery = request.url.substring(request.url.indexOf("?")); + const queryParams = new URLSearchParams(urlQuery); + + if (options.graphiql && typeList[0] === "text/html" && !queryParams.has("raw")) { + const { renderPlaygroundPage } = await import("./graphiql/render.ts"); + + const playground = renderPlaygroundPage({ + ...playgroundOptions, + endpoint: "/graphql" + }); + + return new Response(playground, { + headers: new Headers({ + "Content-Type": "text/html", + ...headers + }) + }); + } else if (typeList.length === 1 && typeList[0] === "text/html") { + return new Response("Not Acceptable", { + headers: new Headers(headers), + status: 406 + }); + } else if (queryParams.has("query")) { + params = Promise.resolve({ query: queryParams.get("query") } as GraphQLParams); + } else { + params = Promise.reject(new Error("No query given!")); + } + } else if (typeList.length === 1 && typeList[0] === "text/html") { + return new Response("Not Acceptable", { + headers: new Headers(headers), + status: 406 + }); + } else { + params = request.json(); + } + + try { + const result = await runHttpQuery<Req, Ctx>(await params, options, request); + let contentType = "text/plain"; + + if (!typeList.length || typeList.includes("application/json") || typeList.includes("*/*")) + contentType = "application/json"; + + return new Response(JSON.stringify(result, null, 2), { + headers: new Headers({ + "Content-Type": contentType, + ...headers + }), + status: 200 + }); + } catch(e) { + console.error(e); + + return new Response( + "Malformed Request " + (request.method === "GET" ? "Query" : "Body"), { + headers: new Headers(headers), + status: 400 + } + ); + } + } +} diff --git a/source/import.ts b/source/import.ts new file mode 100644 index 0000000..a9692bf --- /dev/null +++ b/source/import.ts @@ -0,0 +1,104 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import { dirname, join } from "@std/path"; + +/*** UTILITY ------------------------------------------ ***/ + +const { cwd, readFileSync } = Deno; +const DYNAMIC_IMPORT_REGEX = /^#\s(DYNAMIC_IMPORTS)/gm; +const ENCODING = "utf-8"; +const FILE_REGEX = /\w*(.graphql)/g; +const IMPORT_REGEX = /^#\s(import)\s.*(.graphql")/gm; + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Reads a `.graphql` file and resolves its imports into a single SDL string. + * + * Two mechanisms are supported, and both can appear in the same file: + * + * 1. **Explicit imports** — lines like `# import "./user.graphql"` are replaced + * inline with the contents of the referenced file, resolved relative to the + * entry file. + * 2. **Dynamic imports** — if the file contains `# DYNAMIC_IMPORTS`, every + * `.graphql` file found one level below `<cwd>/schema/` is inlined at that + * marker. Useful for feature-folder layouts. + * + * Errors are logged and an empty string is returned so boot doesn't crash. + * + * @param path - Path to the entry `.graphql` file, relative to `Deno.cwd()`. + * @returns The fully-expanded schema string. + */ +export async function importQL(path: string): Promise<string> { + const SCHEMA_DIRECTORY = join(cwd(), "schema"); + + try { + const decoder = new TextDecoder(ENCODING); + const file = readFileSync(join(cwd(), String(path))); + const imports = decoder.decode(file).match(IMPORT_REGEX) || []; + const shouldTryDynamicallyImporting = decoder.decode(file).match(DYNAMIC_IMPORT_REGEX) ? true : false; + let parsedFile = decoder.decode(file); + + /*** `import` statements in the supplied schema file + are parsed to dynamically bring in linked files. ***/ + + imports.map((imp: string) => { + const matchedFilename: null | Array<string> = imp.match(FILE_REGEX); + + if (!matchedFilename || !matchedFilename.length || matchedFilename.length < 1) + return; + + const filename = matchedFilename[0]; + const importedFileDecoder = new TextDecoder(ENCODING); + const importedFile = Deno.readFileSync(join(dirname(String(path)), filename)); + const decodedFile = importedFileDecoder.decode(importedFile); + + parsedFile = parsedFile.replace(imp, decodedFile); + }); + + /*** With dynamic importing, we just look inside the `program` + directory to find `.graphql` files and automatically bring + them in, if `# DYNAMIC_IMPORTS` exists in `schema.graphql`. ***/ + + if (shouldTryDynamicallyImporting) { + const graphqlFiles = []; + + for await (const dirEntry of Deno.readDir(SCHEMA_DIRECTORY)) { + const { isDirectory } = dirEntry; + + if (isDirectory) { + const DIR = join(SCHEMA_DIRECTORY, dirEntry.name); + + for await (const dirEntry of Deno.readDir(DIR)) { + const { isFile } = dirEntry; + + if (isFile && dirEntry.name.match(FILE_REGEX)) + graphqlFiles.push(join(DIR, dirEntry.name)); + } + } + } + + for (const file of graphqlFiles) { + const importedFileDecoder = new TextDecoder(ENCODING); + const importedFile = Deno.readFileSync(file); + const decodedFile = importedFileDecoder.decode(importedFile); + const insertPosition = parsedFile.indexOf("# DYNAMIC_IMPORTS"); + + if (insertPosition !== -1) { + parsedFile = + parsedFile.substring(0, insertPosition) + + decodedFile + + parsedFile.substring(insertPosition); + } + } + } + + return parsedFile; + } catch(parseError) { + console.error(new Error(`error parsing file [${String(path)}]`), parseError); + return ""; + } +} diff --git a/source/utility/types.ts b/source/utility/types.ts new file mode 100755 index 0000000..0d8b443 --- /dev/null +++ b/source/utility/types.ts @@ -0,0 +1,55 @@ + + + +/*** IMPORT ------------------------------------------- ***/ + +import type { GraphQLArgs, GraphQLSchema } from "graphql"; + +/*** UTILITY ------------------------------------------ ***/ + +import type { RenderPageOptions } from "../graphiql/render.ts"; + +interface MutationParams { + mutation: string; + operationName?: string; + query?: never; + variables?: Record<string, unknown>; +} + +interface QueryParams { + mutation?: never; + operationName?: string; + query: string; + variables?: Record<string, unknown>; +} + +/*** EXPORT ------------------------------------------- ***/ + +/** + * Configuration accepted by `GraphQLHTTP` and `runHttpQuery`. + * + * Extends `GraphQLArgs` from `graphql-js` minus `source` (supplied per request). + */ +export interface GQLOptions<Context, Req extends GQLRequest = GQLRequest> extends Omit<GraphQLArgs, "source"> { + /** Builds the context value passed to resolvers. Runs per request. */ + context?: (val: Req) => Context | Promise<Context>; + /** Serve the GraphQL Playground on `GET` + `Accept: text/html`. */ + graphiql?: boolean; + /** Extra headers merged into every response. */ + headers?: HeadersInit; + /** Passthrough options for the Playground renderer (minus `endpoint`). */ + playgroundOptions?: Omit<RenderPageOptions, "endpoint">; + /** The executable schema to query against. */ + schema: GraphQLSchema; +} + +/** A single GraphQL operation — either a `query` or a `mutation`, never both. */ +export type GraphQLParams = QueryParams | MutationParams; + +/** Minimal Fetch-shaped request accepted by the HTTP handler. */ +export type GQLRequest = { + headers: Headers; + json: () => Promise<GraphQLParams>; + method: string; + url: string; +}; |