graphql-demo

Try it
Start your own copy of this example:
npm create @setu-ts my-app -- --example graphql-demo
cd my-app && npm install
npm start
The commands further down run inside a clone of the repository.

A runnable application exercising @setu-ts/graphql-plugin end to end, and an interop suite that drives it with real third-party GraphQL clients.

Scope. This is a private pnpm workspace app, not a published package. Its generated TypeScript paths resolve framework source from this checkout. Third-party GraphQL clients live in this app’s dependencies and never enter a framework package’s dependency graph.

Running it

pnpm --dir apps/graphql-demo run start          # http://localhost:4000/graphql — open in a browser for GraphiQL
pnpm --dir apps/graphql-demo run start 4100     # or pick a port

pnpm --dir apps/graphql-demo run interop        # the interop suite; exits non-zero on any failure

pnpm --dir apps/graphql-demo run interop boots its own application on an ephemeral port, so it needs no running server and leaves nothing behind.

What the demo serves

EndpointPurpose
POST/GET /graphqlQueries, mutations, batching, Automatic Persisted Queries
GET /graphql (text/html)GraphiQL
ws://…/graphql/wsSubscriptions over graphql-transport-ws
POST/GET /graphql/streamSubscriptions over GraphQL-over-SSE, distinct-connections mode

The schema is deliberately small but covers the awkward cases: a countdown subscription that terminates on its own, a bookAdded subscription pushed by a mutation on a different transport, and a boom field that throws an error carrying a fake connection string so masking is observable rather than asserted.

Why an interop suite exists

The package’s own tests drive our frame codec against our own state machine. That cannot tell us whether a conformant client agrees with us — and the protocol implementations here are hand-written from the specifications. So the suite uses the authorities instead:

  • graphql-ws is the reference implementation of the protocol the plugin implements.
  • @apollo/client’s persisted-query link defines the APQ handshake PUBLIC_API.md claims to interoperate with.

The cold-cache requirement

Each run boots a fresh application because the APQ cache must start cold. With a warm cache Apollo skips the miss → retry handshake entirely and every check still passes — a result indistinguishable from a real one. This was observed, not theorised: an early run reported a clean pass while both requests went out hash-only.

The persisted-query check therefore asserts the wire sequence rather than the returned data:

["hash-only", "document", "hash-only"]   cold cache — the handshake ran
["hash-only", "hash-only"]               warm cache — the assertion fails

Both shapes were produced and observed, so the guard is known to discriminate rather than assumed to.

What is covered in-repo instead

The package’s own real-socket e2e suite now covers the socket lifecycle, so those properties are gated by CI and do not depend on this app: two subscriptions multiplexed on one connection without cross-talk, completing one while the other keeps streaming, onConnect refusing a real client with 4403, and WebSocketRouteOptions.heartbeat proven against a live sweeper with a control connection on an ordinary route that MUST be swept. This app’s remaining unique value is interop with third-party clients, which is the part that cannot move in-repo.

Relationship to the gates

The app is a private pnpm workspace member. pnpm run check:apps runs its check and smoke scripts; the root formatter and linter also cover it. The interop suite remains a separate manual check: run pnpm --dir apps/graphql-demo run interop when changing the GraphQL transports. Release scripts publish framework packages, not this app.

Read the example deep dive for the concept and source walkthrough.

Read, in order

setu.config.ts

import { CachePlugin } from '@setu-ts/cache-plugin';
import { GraphqlPlugin } from '@setu-ts/graphql-plugin';
import { createApplication } from '@setu-ts/kernel';
import type { IKernelApplication } from '@setu-ts/kernel';
import { RuntimePlugin } from '@setu-ts/runtime';
import { WebSocketPlugin } from '@setu-ts/websocket-plugin';
import { createResolvers } from './src/modules/books/books.resolvers.ts';
import { typeDefs } from './src/modules/books/books.schema.ts';

/**
 * Builds the demo with every GraphQL transport and feature enabled, without starting it.
 *
 * Each call builds fresh resolver state and an empty persisted-query cache, so
 * the interop suite's persisted-query check starts cold.
 */
export function createApp(): IKernelApplication {
  return createApplication({
    plugins: [
      RuntimePlugin(),
      WebSocketPlugin(),
      // Holds the persisted-query map; without it APQ uses a bounded in-process cache.
      CachePlugin(),
      GraphqlPlugin({
        typeDefs,
        resolvers: createResolvers(),
        subscriptions: {
          websocket: { connectionInitWaitMs: 5000 },
          sse: { heartbeatMs: 10_000 },
        },
        apq: { ttlSeconds: 300 },
        maxBatchSize: 10,
        graphiql: true,
      }),
    ],
  });
}

src/modules/books/books.resolvers.ts

/**
 * Resolvers for the books schema, including a subscription's `subscribe`.
 */
import type { ResolverMap } from '@setu-ts/graphql-plugin';
import type { Book } from './books.schema.ts';

/**
 * The value an internal error carries, so a test can assert that masking
 * removed it rather than merely that some error arrived.
 */
export const INTERNAL_SECRET = 'postgres://admin:hunter2@db.internal:5432';

/** Builds a fresh resolver set over its own state, so each run starts clean. */
export function createResolvers(): ResolverMap {
  const books: Book[] = [
    { id: '1', title: 'The Left Hand of Darkness', author: 'Le Guin' },
    { id: '2', title: 'Piranesi', author: 'Clarke' },
  ];
  const listeners = new Set<(book: Book) => void>();

  const resolvers: ResolverMap = {
    Query: {
      hello: () => 'world',
      books: () => books,
      book: (_source, args) => books.find((b) => b.id === args.id) ?? null,
      boom: () => {
        throw new Error(INTERNAL_SECRET);
      },
    },
    Mutation: {
      addBook: (_source, args) => {
        const book: Book = {
          id: String(books.length + 1),
          title: String(args.title),
          author: String(args.author),
        };
        books.push(book);
        for (const listener of listeners) listener(book);
        return book;
      },
    },
    Subscription: {
      // A subscription field is `{ subscribe, resolve? }`; `subscribe` returns
      // the event source and graphql reads it from the field's own slot.
      countdown: {
        subscribe: (_source, args) =>
          (async function* () {
            for (let i = Number(args.from); i >= 0; i--) {
              yield { countdown: i };
              await new Promise((resolve) => setTimeout(resolve, 100));
            }
          })(),
      },
      // Pushed by the mutation, so one test can prove a write on the HTTP
      // transport reaches a subscriber on another transport.
      bookAdded: {
        subscribe: () => {
          const queue: Book[] = [];
          let deliver: ((book: Book) => void) | undefined;
          const listener = (book: Book) => {
            if (deliver) {
              deliver(book);
              deliver = undefined;
            } else {
              queue.push(book);
            }
          };
          listeners.add(listener);
          return {
            async *[Symbol.asyncIterator]() {
              try {
                while (true) {
                  const next =
                    queue.shift() ?? (await new Promise<Book>((resolve) => (deliver = resolve)));
                  yield { bookAdded: next };
                }
              } finally {
                listeners.delete(listener);
              }
            },
          };
        },
      },
    },
  };

  return resolvers;
}

src/modules/books/books.schema.ts

/** A book record as the demo stores it. */
export interface Book {
  id: string;
  title: string;
  author: string;
}

/** The SDL served by the demo. */
export const typeDefs = `
  type Book { id: ID!, title: String!, author: String! }

  type Query {
    hello: String!
    books: [Book!]!
    book(id: ID!): Book
    """Always throws, to show internal-error masking on every transport."""
    boom: String!
  }

  type Mutation {
    addBook(title: String!, author: String!): Book!
  }

  type Subscription {
    """Emits \`from\` down to 0, then completes."""
    countdown(from: Int!): Int!
    """Emits every book added by the addBook mutation, on any transport."""
    bookAdded: Book!
  }
`;

smoke.ts

import assert from 'node:assert/strict';
import { after, before, describe, it } from 'node:test';
import type { IKernelApplication } from '@setu-ts/kernel';
import { createApp } from './setu.config.ts';

describe('graphql-demo example', () => {
  let app: IKernelApplication;

  before(async () => {
    app = createApp();
    await app.start();
  });

  after(async () => {
    await app.stop();
  });

  it('answers a basic query', async () => {
    const response = await app.inject({
      method: 'POST',
      url: '/graphql',
      body: { query: '{ hello }' },
    });

    assert.equal(response.statusCode, 200);
    assert.deepEqual(response.json(), { data: { hello: 'world' } });
  });
});