Skip to content

Commit

Permalink
Refactor schema loading (#545)
Browse files Browse the repository at this point in the history
  • Loading branch information
dan-lee authored Jan 21, 2025
1 parent b83de36 commit 1a3204d
Show file tree
Hide file tree
Showing 19 changed files with 594 additions and 382 deletions.
6 changes: 0 additions & 6 deletions packages/zudoku/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,6 @@
"./plugins/custom-pages": "./src/lib/plugins/custom-pages/index.ts",
"./plugins/search-inkeep": "./src/lib/plugins/search-inkeep/index.ts",
"./plugins/api-catalog": "./src/lib/plugins/api-catalog/index.ts",
"./openapi-worker": "./src/lib/plugins/openapi-worker.ts",
"./components": "./src/lib/components/index.ts",
"./icons": "./src/lib/icons.ts",
"./vite": "./src/vite/plugin.ts",
Expand Down Expand Up @@ -103,10 +102,6 @@
"import": "./lib/zudoku.plugin-search-inkeep.js",
"types": "./dist/lib/plugins/search-inkeep/index.d.ts"
},
"./openapi-worker": {
"import": "./lib/zudoku.openapi-worker.js",
"types": "./dist/lib/plugins/openapi-worker.d.ts"
},
"./components": {
"import": "./lib/zudoku.components.js",
"types": "./dist/lib/components/index.d.ts"
Expand Down Expand Up @@ -146,7 +141,6 @@
"generate:icon-types": "tsx ./scripts/generate-icon-types.ts",
"build:standalone:vite": "vite build --mode standalone --config vite.standalone.config.ts",
"build:standalone:html": "cp ./src/app/standalone.html ./standalone/standalone.html && cp ./src/app/demo.html ./standalone/demo.html && cp ./src/app/demo-cdn.html ./standalone/index.html",
"hack:fix-worker-paths": "node ./scripts/hack-worker.mjs",
"clean": "tsc --build --clean",
"codegen": "graphql-codegen --config ./src/codegen.ts",
"test": "vitest run"
Expand Down
1 change: 0 additions & 1 deletion packages/zudoku/src/app/demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,6 @@ const config = {
type: "url",
input: apiUrl,
navigationId: "/",
inMemory: true,
}),
],
} satisfies ZudokuConfig;
Expand Down
1 change: 0 additions & 1 deletion packages/zudoku/src/app/standalone.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,6 @@ const config = {
type: "url",
input: apiUrl!,
navigationId: "/",
inMemory: true,
}),
],
} satisfies ZudokuConfig;
Expand Down
34 changes: 19 additions & 15 deletions packages/zudoku/src/lib/oas/graphql/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@ import {
} from "@sindresorhus/slugify";
import { GraphQLJSON, GraphQLJSONObject } from "graphql-type-json";
import { createYoga, type YogaServerOptions } from "graphql-yoga";
import { LRUCache } from "lru-cache";
import hashit from "object-hash";
import {
HttpMethods,
validate,
Expand Down Expand Up @@ -58,11 +56,10 @@ export const createOperationSlug = (
);
};

const cache = new LRUCache<string, OpenAPIDocument>({
ttl: 60 * 10 * 1000,
ttlAutopurge: true,
fetchMethod: (_key, _oldValue, { context }) => validate(context as string),
});
export type SchemaImports = Record<
string,
() => Promise<{ schema: OpenAPIDocument }>
>;

const builder = new SchemaBuilder<{
Scalars: {
Expand All @@ -71,6 +68,7 @@ const builder = new SchemaBuilder<{
};
Context: {
schema: OpenAPIDocument;
schemaImports?: SchemaImports;
};
}>({});

Expand Down Expand Up @@ -441,11 +439,6 @@ const Schema = builder.objectRef<OpenAPIDocument>("Schema").implement({
}),
});

const loadOpenAPISchema = async (input: NonNullable<unknown>) => {
const hash = hashit(input);
return await cache.forceFetch(hash, { context: input });
};

const SchemaSource = builder.enumType("SchemaType", {
values: ["url", "file", "raw"] as const,
});
Expand All @@ -459,10 +452,21 @@ builder.queryType({
input: t.arg({ type: JSONScalar, required: true }),
},
resolve: async (_, args, ctx) => {
const schema = await loadOpenAPISchema(args.input!);
// for easier access of the whole schema in children resolvers
ctx.schema = schema;
let schema: OpenAPIDocument;

if (args.type === "file" && typeof args.input === "string") {
const loadSchema = ctx.schemaImports?.[args.input];

if (!loadSchema) {
throw new Error(`No schema loader found for path: ${args.input}`);
}
const module = await loadSchema();
schema = module.schema;
} else {
schema = await validate(args.input as string);
}

ctx.schema = schema;
return schema;
},
}),
Expand Down
11 changes: 0 additions & 11 deletions packages/zudoku/src/lib/plugins/openapi-worker.ts

This file was deleted.

148 changes: 28 additions & 120 deletions packages/zudoku/src/lib/plugins/openapi/client/GraphQLClient.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,16 @@
import type { GraphQLError } from "graphql/error/index.js";
import { ulid } from "ulidx";
import { initializeWorker } from "zudoku/openapi-worker";
import { ZudokuError } from "../../../util/invariant.js";
import type { TypedDocumentString } from "../graphql/graphql.js";
import type { OpenApiPluginOptions } from "../index.js";
import type { LocalServer } from "./createServer.js";
import type { WorkerGraphQLMessage } from "./worker.js";

let localServerPromise: Promise<LocalServer> | undefined;
let worker: SharedWorker | undefined;

type GraphQLResponse<TResult> = {
errors?: GraphQLError[];
data: TResult;
};

const resolveVariables = async (variables?: unknown) => {
if (!variables) return;

if (
typeof variables === "object" &&
"type" in variables &&
variables.type === "file" &&
"input" in variables &&
typeof variables.input === "function"
) {
variables.input = await variables.input();
}
};

const throwIfError = (response: GraphQLResponse<unknown>) => {
if (!response.errors?.[0]) return;

Expand All @@ -39,119 +21,45 @@ const throwIfError = (response: GraphQLResponse<unknown>) => {
};

export class GraphQLClient {
readonly #mode: "remote" | "in-memory" | "worker";
#pendingRequests = new Map<string, (value: any) => void>();
#port: MessagePort | undefined;
constructor(private readonly config: OpenApiPluginOptions) {}

constructor(private config: OpenApiPluginOptions) {
if (config.server) {
this.#mode = "remote";
} else if (config.inMemory || typeof SharedWorker === "undefined") {
this.#mode = "in-memory";
} else {
this.#mode = "worker";
#getLocalServer = async () => {
if (!localServerPromise) {
localServerPromise = import("./createServer.js").then((m) =>
m.createServer(this.config),
);
}
}
return localServerPromise;
};

#initializeLocalServer = () =>
import("./createServer.js").then((m) => m.createServer());
#executeFetch = async (init: RequestInit): Promise<Response> => {
if (this.config.server) {
return fetch(this.config.server, init);
}

const localServer = await this.#getLocalServer();
return localServer.fetch("http://localhost/graphql", init);
};

fetch = async <TResult, TVariables>(
query: TypedDocumentString<TResult, TVariables>,
...[variables]: TVariables extends Record<string, never> ? [] : [TVariables]
) => {
): Promise<TResult> => {
const operationName = query.match(/query (\w+)/)?.[1];

await resolveVariables(variables);

const body = JSON.stringify({ query, variables, operationName });

switch (this.#mode) {
case "remote": {
const response = await fetch(this.config.server!, {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
});

if (!response.ok) {
throw new Error("Network response was not ok");
}

const result = (await response.json()) as GraphQLResponse<TResult>;
throwIfError(result);

return result.data;
}

case "in-memory": {
if (!localServerPromise) {
localServerPromise = this.#initializeLocalServer();
}

const localServer = await localServerPromise;
if (!localServer) throw new Error("Local server not initialized");
const response = await this.#executeFetch({
method: "POST",
body: JSON.stringify({ query, variables, operationName }),
headers: { "Content-Type": "application/json" },
});

const response = await localServer.fetch(
new Request("http://localhost/graphql", {
method: "POST",
body,
headers: { "Content-Type": "application/json" },
}),
);

if (!response.ok) {
throw new Error("Network response was not ok");
}

const result = (await response.json()) as GraphQLResponse<TResult>;
throwIfError(result);

return result.data;
}

case "worker": {
if (!worker) {
worker = initializeWorker();
}

if (!this.#port) {
const channel = new MessageChannel();

worker.port.postMessage({ port: channel.port2 }, [channel.port2]);

this.#port = channel.port1;

this.#port.onmessage = (e: MessageEvent<WorkerGraphQLMessage>) => {
const { id, body } = e.data;
const resolve = this.#pendingRequests.get(id);
if (resolve) {
const result = JSON.parse(body);
resolve(result);
this.#pendingRequests.delete(id);
} else {
// eslint-disable-next-line no-console
console.error(`No pending request found for id: ${id}`);
}
};

this.#port.start();
}

const id = ulid();

const resultPromise = new Promise<GraphQLResponse<TResult>>(
(resolve) => {
this.#pendingRequests.set(id, resolve);
this.#port!.postMessage({ id, body } as WorkerGraphQLMessage);
},
);
if (!response.ok) {
throw new Error("Network response was not ok");
}

const result = await resultPromise;
throwIfError(result);
const result = (await response.json()) as GraphQLResponse<TResult>;
throwIfError(result);

return result.data;
}
}
return result.data;
};
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { useLogger } from "@envelop/core";
import { createGraphQLServer } from "../../../oas/graphql/index.js";
import type { OpenApiPluginOptions } from "../index.js";

const map = new Map<string, number>();

/**
* Creates the GraphQL server
*/
export const createServer = () =>
export const createServer = (config: OpenApiPluginOptions) =>
createGraphQLServer({
context: {
schemaImports: config.schemaImports,
},
plugins: [
// eslint-disable-next-line react-hooks/rules-of-hooks
useLogger({
Expand All @@ -22,7 +26,7 @@ export const createServer = () =>
if (start) {
// eslint-disable-next-line no-console
console.log(
`${args.operationName} query took ${performance.now() - start}ms`,
`[zudoku:debug] ${args.operationName} query took ${performance.now() - start}ms`,
);
map.delete(`${startEvent}-${args.operationName}`);
}
Expand Down
19 changes: 2 additions & 17 deletions packages/zudoku/src/lib/plugins/openapi/client/useCreateQuery.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import hashit from "object-hash";
import { useContext, useMemo } from "react";
import { useContext } from "react";
import type { TypedDocumentString } from "../graphql/graphql.js";
import { GraphQLContext } from "./GraphQLContext.js";

Expand All @@ -12,22 +11,8 @@ export const useCreateQuery = <TResult, TVariables>(
throw new Error("useGraphQL must be used within a GraphQLProvider");
}

const hash = useMemo(() => {
if (
typeof variables[0] === "object" &&
variables[0] != null &&
"input" in variables[0] &&
typeof variables[0].input === "function"
) {
// This is a pre-hashed name to ensure that the query key is consistent across server and client
return variables[0].input.name;
}

return hashit(variables[0] ?? {});
}, [variables]);

return {
queryFn: () => graphQLClient.fetch(query, ...variables),
queryKey: [query, hash],
queryKey: [query, variables[0]],
} as const;
};
Loading

0 comments on commit 1a3204d

Please sign in to comment.