UNPKG

@graphile/persisted-operations

Version:

Persisted operations (aka persisted queries) support for PostGraphile's server

222 lines (221 loc) 9.57 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); const fs_1 = require("fs"); /** * This fallback hashFromPayload method is compatible with Apollo Client and * Relay. */ function defaultHashFromPayload(payload) { var _a, _b; return ( // https://github.com/apollographql/apollo-link-persisted-queries#protocol ((_b = (_a = payload === null || payload === void 0 ? void 0 : payload.extensions) === null || _a === void 0 ? void 0 : _a.persistedQuery) === null || _b === void 0 ? void 0 : _b.sha256Hash) || ( // https://relay.dev/docs/en/persisted-queries#network-layer-changes payload === null || // https://relay.dev/docs/en/persisted-queries#network-layer-changes payload === void 0 ? void 0 : // https://relay.dev/docs/en/persisted-queries#network-layer-changes payload.documentId)); } /** * Given a cache object, returns a persisted operation getter that looks up the * given hash in said cache object. */ function persistedOperationGetterForCache(cache) { return (key) => cache[key]; } function makeGetterForDirectory(directory) { // NOTE: it's generally a bad practice to do synchronous filesystem // operations in Node servers; however PostGraphile's hooks are synchronous // and we want to only load the files on demand, so we have to bite the // bullet. To mitigate the impact of this we cache the results, and we // periodically scan the folder to see what files it contains so that we // can reject requests to non-existent files to avoid DOS attacks having us // make synchronous requests to the filesystem. let files = []; /** * This function must never reject. */ async function scanDirectory() { try { files = (await fs_1.promises.readdir(directory)).filter((name) => name.endsWith(".graphql")); } catch (e) { console.error(`Error occurred whilst scanning '${directory}'`); console.error(e); } finally { // We don't know how long the scanning takes, so rather than setting an // interval, we wait 5 seconds between scans before kicking off the next // one. setTimeout(scanDirectory, 5000); } } scanDirectory(); const operationFromHash = new Map(); function getOperationFromHash(hash) { if (!/^[a-zA-Z0-9_-]+$/.test(hash)) { throw new Error("Invalid hash"); } let operation = operationFromHash.get(hash); if (!operation) { const filename = `${hash}.graphql`; if (!files.includes(filename)) { throw new Error(`Could not find file for hash '${hash}'`); } operation = fs_1.readFileSync(`${directory}/${filename}`, "utf8"); operationFromHash.set(hash, operation); } return operation; } return getOperationFromHash; } const directoryGetterByDirectory = new Map(); /** * Given a directory, get or make the persisted operations getter. */ function getterForDirectory(directory) { let getter = directoryGetterByDirectory.get(directory); if (!getter) { getter = makeGetterForDirectory(directory); directoryGetterByDirectory.set(directory, getter); } return getter; } /** * Extracts or creates a persisted operation getter function from the * PostGraphile options. */ function getterFromOptionsCore(options) { const optionsSpecified = Object.keys(options).filter((key) => [ "persistedOperationsGetter", "persistedOperationsDirectory", "persistedOperations", ].includes(key)); if (optionsSpecified.length > 1) { // If you'd like support for more than one of these options; send a PR! throw new Error(`'${optionsSpecified.join("' and '")}' were specified, at most one of these operations can be specified.`); } if (options.persistedOperationsGetter) { return options.persistedOperationsGetter; } else if (options.persistedOperations) { return persistedOperationGetterForCache(options.persistedOperations); } else if (options.persistedOperationsDirectory) { return getterForDirectory(options.persistedOperationsDirectory); } else { throw new Error("Server misconfiguration issue: persisted operations (operation allowlist) is in place, but the server has not been told how to fetch the allowed operations. Please provide one of the persisted operations configuration options."); } } // TODO: use an LRU? For users using lots of new options objects this will // cause a memory leak. But LRUs have a performance cost... Maybe switch to LRU // once the size has grown? const getterFromOptionsCache = new Map(); /** * Returns a cached getter for performance reasons. */ function getterFromOptions(options) { let getter = getterFromOptionsCache.get(options); if (!getter) { getter = getterFromOptionsCore(options); getterFromOptionsCache.set(options, getter); } return getter; } function shouldAllowUnpersistedOperation(options, request, payload) { const { allowUnpersistedOperation } = options; if (typeof allowUnpersistedOperation === "function") { return allowUnpersistedOperation(request, payload); } return !!allowUnpersistedOperation; } /** * Given a payload, this method returns the GraphQL operation document * (string), or null on failure. It **never throws**. */ function persistedOperationFromPayload(payload, options, allowUnpersistedOperation) { try { const hashFromPayload = options.hashFromPayload || defaultHashFromPayload; const hash = hashFromPayload(payload); if (typeof hash !== "string") { if (allowUnpersistedOperation && typeof (payload === null || payload === void 0 ? void 0 : payload.query) === "string") { return payload.query; } throw new Error("We could not find a persisted operation hash string in the request."); } const getter = getterFromOptions(options); return getter(hash); } catch (e) { console.error("Failed to get persisted operation from payload", payload, e); // We must not throw, instead just overwrite the query with null (the error // will be thrown elsewhere). return null; } } let parse = () => { throw new Error("graphql parse not initialised"); }; const PersistedQueriesPlugin = { init(_, { graphql }) { parse = graphql.parse; return null; }, ["cli:flags:add:webserver"](addFlag) { // Add CLI flag. We're adding our plugin name in square brackets to help // the user know where the options are coming from. addFlag("--persisted-operations-directory <fullpath>", "[@graphile/persisted-operations] The path to the directory in which we'd find the persisted query files (each named <hash>.graphql)"); addFlag("--allow-unpersisted-operations", "[@graphile/persisted-operations] Allow clients to send regular GraphQL queries (not just persisted operations); it's better to control this on a per-request basis in library mode instead."); // The ouput from one plugin is fed as the input into the next, so we must // remember to return the input. return addFlag; }, ["cli:library:options"](options, { config, cliOptions }) { // Take the CLI options and add them as PostGraphile options. const { persistedOperationsDirectory = undefined, allowUnpersistedOperations = undefined, } = { ...config["options"], ...cliOptions, }; return { ...options, persistedOperationsDirectory, allowUnpersistedOperation: allowUnpersistedOperations, }; }, "postgraphile:options"(options) { // In case there's a filesystem getter, this lets us get a head-start on // scanning the directory before the first request comes in. getterFromOptions(options); return options; }, // For regular HTTP requests "postgraphile:httpParamsList"(paramsList, { options, req }) { return paramsList.map((params) => { // ALWAYS OVERWRITE, even if invalid; the error will be thrown elsewhere. params.query = persistedOperationFromPayload(params, options, shouldAllowUnpersistedOperation(options, req, params)); return params; }); }, // For v0 websocket requests "postgraphile:ws:onOperation"(params, { message, options, socket }) { const req = socket["__postgraphileReq"]; // ALWAYS OVERWRITE, even if invalid; the error will be thrown elsewhere. params.query = persistedOperationFromPayload(message.payload, options, shouldAllowUnpersistedOperation(options, req, params)); return params; }, // For v1 websocket requests "postgraphile:ws:onSubscribe"(params, { context, message, options }) { // @ts-expect-error: __postgraphileReq exists on socket const req = context.extra.socket["__postgraphileReq"]; const payload = message.payload; const query = persistedOperationFromPayload(payload, options, shouldAllowUnpersistedOperation(options, req, payload)); params.document = query ? parse(query) : // ALWAYS OVERWRITE, even if invalid; the error will be thrown elsewhere. null; return params; }, }; module.exports = PersistedQueriesPlugin;