Huge News!Announcing our $40M Series B led by Abstract Ventures.Learn More
Socket
Sign inDemoInstall
Socket

@graphitation/supermassive

Package Overview
Dependencies
Maintainers
5
Versions
111
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@graphitation/supermassive

_[Pack more performance into smaller space](https://en.wikipedia.org/wiki/Supermassive_black_hole)_

  • 3.0.0-alpha.3
  • Source
  • npm
  • Socket score

Version published
Weekly downloads
130
decreased by-70.45%
Maintainers
5
Weekly downloads
 
Created
Source

@graphitation/supermassive

Pack more performance into smaller space

Supermassive is a lightweight schema-less GraphQL executor and query build-time compiler.

What

Supermassive's goals are to provide a solution with the following optimizations for when all GraphQL operations (queries, mutations, subscriptions) needed by the user-experiences are statically known at build-time:

  • Bundle size of production targets needs to be as small as possible.
  • Performance is favored over runtime validation.

Such is the case in our scenarios, where our schema lives entirely in the client application and some of these applications have very little to no GraphQL needs [yet] other than to suffice the needs of a single or few components.

Why

Running a GraphQL executor can be an expensive exercise. The JavaScript community has the good fortune of having the official reference implementation of the GraphQL specification being implemented in JavaScript, namely graphql-js. Inevitably this means that most general purpose GraphQL libraries in the JavaScript ecosystem end up wrapping it or otherwise rely on it. However, graphql-js' goal is specifically to be an all-encompassing implementation used for reference needs, not to be an optimized solution for specific use-cases.

How

Consider a GraphQL schema. It is typically a sizeable chunk of data, both in terms of type/field definition metadata as well as associated field-resolvers and their code dependencies. Statically knowing all GraphQL operations allows us to reduce the bundle size to a minimum by tree-shaking all the definition metadata not required by any of the given operations. A JavaScript code bundler can in turn ensure only that code which is needed by the remaining field-resolvers is included in the production bundle. This means that the process is entirely dependency driven by needs expressed by the user-experiences, rather than requiring blunt manual configuration.

Similarly, the GraphQL operations themselves, described using e.g. GraphQL SDL or graphql-js AST, can incur quite some overhead as operations and number of operations grow. Eliminating these from the bundles can save size as well as runtime processing.

Implementation details and plans

Current

Currently, supermassive expects inlined types in normal GraphQL documents that are sent to it. It doesn't require having a schema, only the query document and resolver functions. We achieve this by running a pre-processing step on queries using the schema, in a same stage where graphql tags would normally be extracted and pre-parsed. Schema is often a very big part of the bundle and memory volume, so this drastically improves that and removes the need of creating a schema in runtime, which can also be very costly.

In this initial phase, we will achieve the goal of tree-shaking the schema definitions. We do this by inlining required metadata into the documents that describe the operations, after which they can be executed with the need of the entire schema. This means overall bundle size will be decreased when only a subset of the schema is actually used, which pays off significantly when a host application introduces its first component(s) leveraging GraphQL.

Possible future 1 - Relay IR

Current implementation has some bundle size / memory cost because GraphQL AST format isn't super optimized and adding types to it makes it even worse. However there is an already type annotated AST format that is more compact - Relay IR. As Relay anyway needs IR to do it's store operations, this wouldn't incur additional bundle cost to include them. Relay IR Printer would need to be modified to include more type information, but Relay IR is otherwise already typed and has all type information we need.

Relay IR is more efficient because it allows aliasing common elements of the document, like types or selections, thus reducing the total document size.

Possible future 2 - pre-normalized executor

In a scenario where executor is running close to the client (sometimes even in same process or at least in same browser), it might be worth exploring removing some of the requirements imposed by the usual GraphQL transport - for example serialization. Not only GraphQL executors do the JSON serialization, but also they return the data that is optimized for transport and that matches the query tree. This means clients need to perform often expensive normazilation. As traffic and message size might be less important in same process / same browser scenarios, it might be worthwhile exploring return pre-normalized data from supermassive. This offers massive speedups for some clients like Apollo (see benchmarks).

Possible future 3 - Tree-shaking based on documents

Current implementation is more efficient in terms of bundles than one requiring full schema, but resolvers are also not always needed. By going through fields being selected in the documents, resolvers can be split or tree-shook to only load ones that are required for certain frontend bundle.

Consider a GraphQL operation like the following:

query CurrentUserNameQuery {
  me {
    name
  }
}

This would lead to the following [conceptual] tree-shaking after compilation of the field-resolver map:

 import { getUser } from "user-service";
-import { getUserPresence } from "presence-service";

 const resolvers = {
   Query: {
     me: async (_source, _args, context) => getUser(context.currentUserId),
   },
   User: {
     name: (source) => source.name,
-    presence: async (source) => getUserPresence(source.id),
   },
 };

Possible future 4 - GraphQL-to-JS

We can expand on the previous phase by ahead-of-time compiling the resolution of the operations, their field-resolvers, and invocation thereof into JavaScript code. This essentially does away with any need for AST of the operation during execution. This means execution will be faster as no more generic lookups and checks need to be performed.

Consider the GraphQL operation shown in the previous phase, typical generic execution (as described in the specification) would look something like the following recursive pseudo-code:

function visitSelectionSet(parentType, selectionSet, parentSource) {
  const result = {};
  for (const selection of selectionSet.selections) {
    switch (selection.kind) {
      case "Field": {
        const type = getType(selection.type.name);
        if (isScalarType(type)) {
          result[selection.name] = parentType.invokeFieldResolver(
            selection.name,
            parentSource,
          );
        } else if (isObjectType(type)) {
          const source = parentType.invokeFieldResolver(
            selection.name,
            parentSource,
          );
          result[selection.name] = visitSelectionSet(
            type,
            selection.selectionSet,
            source,
          );
        } else {
          // ...
        }
      }
      // ...
    }
  }
  return result;
}

function execute(query, rootSource = {}) {
  return visitSelectionSet(getType("Query"), query.selectionSet, rootSource);
}

execute(
  parse(`
    query CurrentUserNameQuery {
      me {
        name
      }
    }
  `),
);

Whereas a compiled version of the specific operation would look something like the following:

function CurrentUserNameQuery(rootSource = {}) {
  const meSource = QueryType.fieldResolvers["me"](rootSource);
  const result = {
    me: {
      name: UserType.fieldResolvers["name"](meSource),
    },
  };
  return result;
}

CurrentUserNameQuery();

Possible future 5 - persisted queries

we can make it possible to replace the operations at runtime using a simple identifier, thus allowing GraphQL clients to execute their operations using these identifiers that they obtain through a concept known as "persisted queries". This means that GraphQL clients that do not require graphql-js AST themselves to operate, such as Relay, will be able to greatly reduce the size of the User-Experience bundles by entirely eliminating the document in favour of a short identifier.

Again, considering the above GraphQL operation, a React component needing that data would include the GraphQL document in its bundle and look something like the following:

function CurrentUser() {
  const data = useQuery({
    document: `
      query CurrentUserNameQuery {
        me {
          name
        }
      }
    `,
  });
  return <div>User: {data.me.name}</div>;
}

However, now that we can compile the operation to code ahead-of-time, and no longer need the operation AST during execution, we can eliminate the document entirely and compile the component to refer to the compiled version of the document instead:

function CurrentUser() {
  const data = useQuery({
    persistedDocumentId: "CurrentUserNameQuery",
  });
  return <div>User: {data.me.name}</div>;
}

Usage

There are 3 main parts of Supermassive - the executor, query annotator and implicit resolver extractor. Executor is the part that actually runs the queries. It takes resolvers object instead of schema and annotated documents instead of normal documents. Query annotator processes query to include type information inside them. It can be ran as part of query extraction stage in Relay Compiler or eg in @graphitation/graphql-js-tag. Implicit resolver extractor writes out resolvers for types that are only implicitly defined in GraphQL SDL, like Unions or Input Objects. It generates typescript file with extracted object that can be merged with the rest of the resolvers.

Executor

Two functions are provided - executeWithSchema and executeWithoutSchema. They attempt to match graphql-js's execute function parameters. executeWithSchema fully matches it and is meant for development or testing. It does the transform and resolver extraction in runtime. executeWithoutSchema relies on those being done during compile/bundling time.

interface CommonExecutionArgs {
  resolvers: Resolvers;
  rootValue?: unknown;
  contextValue?: unknown;
  variableValues?: Maybe<{ [variable: string]: unknown }>;
  operationName?: Maybe<string>;
  fieldResolver?: Maybe<FieldResolver<any, any>>;
  typeResolver?: Maybe<TypeResolver<any, any>>;
}
type ExecutionWithoutSchemaArgs = CommonExecutionArgs & {
  document: DocumentNode;
};

type ExecutionWithSchemaArgs = CommonExecutionArgs & {
  document: UntypedDocumentNode;
  typeDefs: UntypedDocumentNode;
};

function executeWithoutSchema(args: ExecutionWithoutSchemaArgs): PromiseOrValue<ExecutionResult>

function executeWithSchema(args: ExecutionWithSchemaArgs): PromiseOrValue<ExecutionResult>

Transform

Supermassive requires annotated GraphQL documents. See @graphitation/supermassive-ast.

Resolver extractor

Supermassive provides a bin command to extract implicit resolvers. See @graphitation/supermassive-extractors and @graphitation/cli.

supermassive extract-schema PATH_TO_TYPEDEFS.graphql

It generates __generated__/NAME_OF_TYPEDEFS.ts file, on top of which user provided resolvers can be merged when executing.

FAQs

Package last updated on 29 Aug 2023

Did you know?

Socket

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Install

Related posts

SocketSocket SOC 2 Logo

Product

  • Package Alerts
  • Integrations
  • Docs
  • Pricing
  • FAQ
  • Roadmap
  • Changelog

Packages

npm

Stay in touch

Get open source security insights delivered straight into your inbox.


  • Terms
  • Privacy
  • Security

Made with ⚡️ by Socket Inc