From ca16dcf93922d63dd1783dc471b2ac0c61f8d11f Mon Sep 17 00:00:00 2001 From: "netop://ウィビ" Date: Sat, 11 Apr 2026 14:19:10 -0700 Subject: initial commit --- src/common.ts | 47 ++++++++ src/gql.ts | 188 ++++++++++++++++++++++++++++++ src/graphiql/markup.ts | 309 +++++++++++++++++++++++++++++++++++++++++++++++++ src/graphiql/render.ts | 214 ++++++++++++++++++++++++++++++++++ src/http.ts | 121 +++++++++++++++++++ src/import.ts | 56 +++++++++ src/utility/types.ts | 48 ++++++++ 7 files changed, 983 insertions(+) create mode 100755 src/common.ts create mode 100644 src/gql.ts create mode 100755 src/graphiql/markup.ts create mode 100755 src/graphiql/render.ts create mode 100755 src/http.ts create mode 100644 src/import.ts create mode 100755 src/utility/types.ts (limited to 'src') diff --git a/src/common.ts b/src/common.ts new file mode 100755 index 0000000..328e1bd --- /dev/null +++ b/src/common.ts @@ -0,0 +1,47 @@ + + + +/// import + +import { graphql } from "npm:graphql@16.6.0"; +import type { ExecutionResult } from "npm:graphql@16.6.0"; + +/// util + +import type { GQLOptions, GQLRequest, GraphQLParams } from "./utility/types.ts"; + + + +/// export + +export async function runHttpQuery< + Req extends GQLRequest = GQLRequest, + Context = { request?: Req }>( + params: GraphQLParams, + options: GQLOptions, + context?: Context | any): Promise { + /** + * Execute a GraphQL query + * @param {GraphQLParams} params + * @param {GQLOptions} options + * @param context GraphQL context to use inside resolvers + * + * @example + * ```ts + * const { errors, data } = await runHttpQuery({ query: `{ hello }` }, { schema }}, context) + * ``` + */ + + const contextValue = options.context && context?.request ? + await options.context?.(context?.request) : + context; + const source = params.query! || params.mutation!; + + return await graphql({ + source, + ...options, + contextValue, + operationName: params.operationName, + variableValues: params.variables + }); +} diff --git a/src/gql.ts b/src/gql.ts new file mode 100644 index 0000000..6ccf9cd --- /dev/null +++ b/src/gql.ts @@ -0,0 +1,188 @@ + + + +/// import + +import { parse } from "npm:graphql@16.6.0"; +import type { DefinitionNode, DocumentNode, Location } from "npm:graphql@16.6.0"; + +/// util + +const docCache = new Map(); // a map docString -> graphql document +const fragmentSourceMap = new Map>(); // a map fragmentName -> [normalized source] + +let allowLegacyFragmentVariables = false; +let printFragmentWarnings = true; + + + +/// export + +export function disableExperimentalFragmentVariables() { + allowLegacyFragmentVariables = false; +} + +export function disableFragmentWarnings() { + printFragmentWarnings = false; +} + +export function enableExperimentalFragmentVariables() { + allowLegacyFragmentVariables = true; +} + +export function gql(literals: string | readonly string[], ...args: any[]) { + /** + * Create a GraphQL AST from template literal + * @param literals + * @param args + * + * @example + * ```ts + * import { buildASTSchema, graphql } from "https://deno.land/x/graphql_deno@v15.0.0/mod.ts" + * import { gql } from "https://deno.land/x/graphql_tag/mod.ts" + * + * const typeDefs = gql` + * type Query { + * hello: String + * } + * ` + * + * const query = `{ hello }` + * + * const resolvers = { hello: () => "world" } + * + * console.log(await graphql(buildASTSchema(typeDefs), query, resolvers)) + * ``` + */ + + if (typeof literals === "string") + literals = [literals]; + + let result = literals[0]; + + args.forEach((arg, i) => { + if (arg && arg.kind === "Document") + result += arg.loc.source.body; + else + result += arg; + + result += literals[i + 1]; + }); + + return parseDocument(result); +} + +export function resetCaches() { + docCache.clear(); + fragmentSourceMap.clear(); +} + + + +/// helper + +function normalize(string: string) { + // Strip insignificant whitespace + // Note that this could do a lot more, such as reorder fields etc. + return string.replace(/[\s,]+/g, " ").trim(); +} + +function cacheKeyFromLoc(loc: Location) { + return normalize(loc.source.body.substring(loc.start, loc.end)); +} + +function processFragments(ast: DocumentNode) { + // Take a unstripped parsed document (query/mutation or even fragment), and + // check all fragment definitions, checking for name->source uniqueness. + // We also want to make sure only unique fragments exist in the document. + + const definitions: DefinitionNode[] = []; + const seenKeys = new Set(); + + ast.definitions.forEach((fragmentDefinition) => { + if (fragmentDefinition.kind === "FragmentDefinition") { + const fragmentName = fragmentDefinition.name.value; + const sourceKey = cacheKeyFromLoc(fragmentDefinition.loc!); + + // We know something about this fragment + let sourceKeySet = fragmentSourceMap.get(fragmentName)!; + + if (sourceKeySet && !sourceKeySet.has(sourceKey)) { + // this is a problem because the app developer is trying to register another fragment with + // the same name as one previously registered. So, we tell them about it. + if (printFragmentWarnings) { + console.warn( + `Warning: fragment with name ${fragmentName} already exists.\n` + + "gql enforces all fragment names across your application to be unique; read more about\n" + + "this in the docs: http://dev.apollodata.com/core/fragments.html#unique-names" + ); + } + } 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>(doc.definitions); + + 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); + }); + }); + + 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, + // check that all "new" fragments inside the documents are consistent with + // existing fragments of the same name + stripLoc(processFragments(parsed)) + ); + } + + return docCache.get(cacheKey)!; +} + + + +/// via https://github.com/deno-libs/graphql_tag diff --git a/src/graphiql/markup.ts b/src/graphiql/markup.ts new file mode 100755 index 0000000..c000f92 --- /dev/null +++ b/src/graphiql/markup.ts @@ -0,0 +1,309 @@ + + + +/// export + +export const getLoadingMarkup = () => ({ + container: ` + + +
+ + +
Loading + GraphQL Playground +
+
+ `, + script: ` + const loadingWrapper = document.getElementById("loading-wrapper"); + + if (loadingWrapper) + loadingWrapper.classList.add("fadeOut"); + ` +}); diff --git a/src/graphiql/render.ts b/src/graphiql/render.ts new file mode 100755 index 0000000..b4af05b --- /dev/null +++ b/src/graphiql/render.ts @@ -0,0 +1,214 @@ + + + +/// import + +import { filterXSS } from "npm:xss@1.0.14"; + +/// util + +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 ` + + ${typeof faviconUrl === "string" ? `` : ""} + ${faviconUrl === undefined ? `` : ""} + + `; +} + +const renderConfig = (config: unknown) => { + return filterXSS(`
${JSON.stringify(config)}
`, { + whiteList: { div: ["id"] } + }); +}; + + + +/// export + +export interface MiddlewareOptions { + codeTheme?: EditorColours; + config?: any; + endpoint?: string; + env?: any; + 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: any; +} + +export interface RenderPageOptions extends MiddlewareOptions { + cdnUrl?: string; + env?: any; + faviconUrl?: string | null; + title?: string; + version?: string; +} + +export interface Tab { + endpoint: string; + headers?: { [key: string]: string }; + name?: string; + query: string; + responses?: string[]; + variables?: string; +} + +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 ` + + + + + + + ${extendedOptions.title || "GraphQL Playground"} + ${extendedOptions.env === "react" || extendedOptions.env === "electron" ? "" : getCdnMarkup(extendedOptions)} + + + + + + ${loading.container} + ${renderConfig(extendedOptions)} +
+ + + + + `; +} diff --git a/src/http.ts b/src/http.ts new file mode 100755 index 0000000..c8bfa0f --- /dev/null +++ b/src/http.ts @@ -0,0 +1,121 @@ + + + +/// util + +import { runHttpQuery } from "./common.ts"; + +import type { GQLOptions, GQLRequest, GraphQLParams } from "./utility/types.ts"; + + + +/// export + +export function GraphQLHTTP< + Req extends GQLRequest = GQLRequest, + Ctx extends { request: Req } = { request: Req }>({ + playgroundOptions = {}, + headers = {}, + ...options + }: GQLOptions) { + /** + * Create a new GraphQL HTTP middleware with schema, context etc + * @param {GQLOptions} options + * + * @example + * ```ts + * const graphql = await GraphQLHTTP({ schema }) + * + * for await (const req of s) graphql(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; + + 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(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/src/import.ts b/src/import.ts new file mode 100644 index 0000000..ffc2c68 --- /dev/null +++ b/src/import.ts @@ -0,0 +1,56 @@ + + + +/// import + +import { dirname, join } from "https://deno.land/std/path/mod.ts"; +import { gql } from "./gql.ts"; + +import type { DocumentNode } from "npm:graphql@16.6.0"; + +/// util + +const { cwd, readFileSync } = Deno; +const importRegex = /^#\s(import)\s.*(.graphql")/gm; +const fileRegex = /\w*(.graphql)/g; + + + +/// export + +export function importQL(path: string): DocumentNode | Record { + try { + const decoder = new TextDecoder("utf-8"); + const file = readFileSync(join(cwd(), String(path))); + const imports = decoder.decode(file).match(importRegex) || []; + let parsedFile = decoder.decode(file); + + /// `import` statements in the supplied schema file + /// are parsed to dynamically bring in linked files + + imports.map(imp => { + const matchedFilename: null | Array = imp.match(fileRegex); + + if (!matchedFilename || !matchedFilename.length || matchedFilename.length < 1) + return; + + const filename = matchedFilename[0]; + const importedFileDecoder = new TextDecoder("utf-8"); + const importedFile = readFileSync(join(cwd(), dirname(String(path)), filename)); + const decodedFile = importedFileDecoder.decode(importedFile); + + parsedFile = parsedFile.replace(imp, decodedFile); + }); + + return gql` + ${parsedFile} + `; + } catch(parseError) { + console.error(new Error(`error parsing file [${String(path)}]`), parseError); + return {}; + } +} + + + +/// fork of https://github.com/crewdevio/importql diff --git a/src/utility/types.ts b/src/utility/types.ts new file mode 100755 index 0000000..c96910c --- /dev/null +++ b/src/utility/types.ts @@ -0,0 +1,48 @@ + + + +/// import + +import type { GraphQLArgs, GraphQLSchema } from "npm:graphql@16.6.0"; + +/// util + +import type { RenderPageOptions } from "../graphiql/render.ts"; + +interface MutationParams { + mutation: string; + operationName?: string; + query?: never; + variables?: Record; +} + +interface QueryParams { + mutation?: never; + operationName?: string; + query: string; + variables?: Record; +} + + + +/// export + +export interface GQLOptions extends Omit { + context?: (val: Req) => Context | Promise; + /// GraphQL playground + graphiql?: boolean; + /// Custom headers for responses + headers?: HeadersInit; + /// Custom options for GraphQL Playground + playgroundOptions?: Omit; + schema: GraphQLSchema; +} + +export type GraphQLParams = QueryParams | MutationParams; + +export type GQLRequest = { + headers: Headers; + json: () => Promise; + method: string; + url: string; +}; -- cgit v1.2.3