aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authornetop://ウィビ <paul@webb.page>2026-04-24 07:43:33 -0700
committernetop://ウィビ <paul@webb.page>2026-04-24 07:43:33 -0700
commit4c6194c4c2b5506f6d482347b0c13033ef17b5c7 (patch)
tree310b315b23487b9a44da94cd21a970f6cc95c831
downloadgq-4c6194c4c2b5506f6d482347b0c13033ef17b5c7.tar.gz
gq-4c6194c4c2b5506f6d482347b0c13033ef17b5c7.zip
initial commit
-rw-r--r--.gitignore3
-rw-r--r--README.md142
-rw-r--r--deno.json12
-rw-r--r--deno.lock96
-rwxr-xr-xentry.ts17
-rwxr-xr-xsource/common.ts49
-rw-r--r--source/gql.ts158
-rwxr-xr-xsource/graphiql/markup.ts309
-rwxr-xr-xsource/graphiql/render.ts218
-rwxr-xr-xsource/http.ts144
-rw-r--r--source/import.ts104
-rwxr-xr-xsource/utility/types.ts55
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;
+};