@graphile/persisted-operations
Version:
Persisted operations (aka persisted queries) support for PostGraphile's server
222 lines (221 loc) • 9.57 kB
JavaScript
;
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;