refactor(project): add better typing support

This commit is contained in:
Alexis Tyler
2020-01-20 12:22:01 +10:30
parent 26ec0f5bba
commit 2a1a4fadc3
50 changed files with 2591 additions and 1510 deletions
+3
View File
@@ -56,3 +56,6 @@ typings/
# Temp dir for tests
test/__temp__/*
# Built files
dist
-331
View File
@@ -1,331 +0,0 @@
/*
* Copyright 2019 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
module.exports = function (
$injector,
ApiManager,
AppError,
get,
gql,
log,
mergeGraphqlSchemas,
PluginError,
PluginManager,
resolvers,
typeDefs,
Users,
config
) {
const {mergeTypes} = mergeGraphqlSchemas;
const baseTypes = [gql`
scalar JSON
scalar Long
scalar UUID
directive @func(
module: String
data: JSON
query: JSON
result: String
extractFromResponse: String
) on FIELD_DEFINITION
directive @subscription(
channel: String!
) on FIELD_DEFINITION
type Welcome {
message: String!
}
type Query {
# This should always be available even for guest users
welcome: Welcome! @func(module: "get-welcome")
info: Info!
pluginModule(plugin: String!, module: String!, params: JSON, result: String): JSON @func(result: "json")
}
type Mutation {
login(username: String!, password: String!): String
shutdown: String
reboot: String
}
enum MutationType {
CREATED
UPDATED
DELETED
}
enum UpdateOnlyMutationType {
UPDATED
}
type PingSubscription {
mutation: MutationType!
node: String!
}
type InfoSubscription {
mutation: MutationType!
node: Info!
}
type PluginModuleSubscription {
mutation: MutationType!
node: JSON!
}
type Subscription {
ping: PingSubscription!
info: InfoSubscription!
pluginModule(plugin: String!, module: String!, params: JSON, result: String): PluginModuleSubscription!
}
`];
// Add test defs in dev mode
if (process.env.NODE_ENV === 'development') {
const testDefs = gql`
# Test query
input testQueryInput {
state: String!
optional: Boolean
}
type Query {
testQuery(id: String!, input: testQueryInput): JSON @func(module: "debug/return-context")
}
# Test mutation
input testMutationInput {
state: String!
}
type Mutation {
testMutation(id: String!, input: testMutationInput): JSON @func(module: "debug/get-context")
}
# Test subscription
type Subscription {
testSubscription: String!
}
`;
baseTypes.push(testDefs);
}
// Add debug defs to all envs apart from production
if (process.env.NODE_ENV !== 'production') {
const debugDefs = gql`
# Debug query
type Context {
query: JSON
params: JSON
data: JSON
user: JSON
}
type Query {
context: Context @func(module: "debug/get-context")
}
`;
baseTypes.push(debugDefs);
}
const types = mergeTypes([
...baseTypes,
typeDefs
]);
const {SchemaDirectiveVisitor} = $injector.resolve('graphql-tools');
/**
* Func directive
*
* @see https://github.com/smooth-code/graphql-directive/blob/master/README.md#directive-resolver-function-signature
*
* @param {object} obj
* The result returned from the resolver on the parent field, or, in the case of a top-level Query field,
* the rootValue passed from the server configuration.
* @param {object} directiveArgs
* An object with the arguments passed into the directive in the query or schema.
* For example, if the directive was called with `@dateFormat(format: "DD/MM/YYYY")`,
* the args object would be: `{ "format": "DD/MM/YYYY" }`.
* @param {object} context
* This is an object shared by all resolvers in a particular query,
* and is used to contain per-request state, including authentication information,
* dataloader instances, and anything else that should be taken into account when resolving the query.
* @param {object} info
* This argument should only be used in advanced cases,
* but it contains information about the execution state of the query,
* including the field name, path to the field from the root, and more.
*/
class FuncDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const {args} = this;
field.resolve = async function (source, directiveArgs, context, info) {
const path = $injector.resolve('path');
const paths = $injector.resolve('paths');
const {module: moduleName, result: resultType} = args;
const coreCwd = path.join(paths.get('core'), 'modules');
const {plugin: pluginName, module: pluginModuleName, result: pluginType, input, ...params} = directiveArgs;
const operationType = info.operation.operation;
const query = {
...directiveArgs.query,
...(operationType === 'query' ? input : {})
};
const data = {
...directiveArgs.data,
...(operationType === 'mutation' ? input : {})
};
let funcPath = path.join(coreCwd, moduleName + '.js');
// If we're looking for a plugin verify it's installed and active first
if (pluginName) {
if (!PluginManager.isInstalled(pluginName, pluginModuleName)) {
throw new PluginError('Plugin not installed.');
}
if (!PluginManager.isActive(pluginName, pluginModuleName)) {
throw new PluginError('Plugin disabled.');
}
const pluginModule = PluginManager.get(pluginName, pluginModuleName);
// Update plugin funcPath
funcPath = pluginModule.filePath;
}
// Create func locals
// If query @func(param_1, param_2, input: query?)
// If mutation @func(param_1, param_2, input: data)
const locals = {
context: {
query,
params,
data,
user: context.user
}
};
// Resolve func
let func;
try {
func = $injector.resolvePath(funcPath, locals);
} catch (error_) {
// Rethrow clean error message about module being missing
if (error_.code === 'MODULE_NOT_FOUND') {
throw new AppError(`Cannot find ${pluginName ? 'Plugin: "' + pluginName + '" ' : ''}Module: "${pluginName ? pluginModuleName : moduleName}"`);
}
// In production let's just throw an internal error
if (process.env.NODE_ENV === 'production') {
throw new AppError('Internal error occurred');
}
// Otherwise re-throw actual error
throw error_;
}
const pluginOrModule = pluginName ? 'Plugin:' : 'Module:';
const pluginOrModuleName = pluginModuleName || moduleName;
// Run function
let [error, result] = await Promise.resolve(func)
.then(result => [undefined, result])
.catch(error_ => {
// Ensure we aren't leaking anything in production
if (process.env.NODE_ENV === 'production') {
log.error(pluginOrModule, pluginOrModuleName, 'Error:', error_.message);
return [new Error(error_.message)];
}
const logger = log[error_.status && error_.status >= 400 ? 'error' : 'warn'];
logger(pluginOrModule, pluginOrModuleName, 'Error:', error_);
return [error_];
});
// Bail if we can't get the method to run
if (error) {
return error;
}
// If the method's result is a function or promise run/resolve it
result = await Promise.resolve(result).then(result => typeof result === 'function' ? result() : result);
// Get wanted result type or fallback to json
result = result[pluginType || resultType || 'json'];
// Allow fields to be extracted
if (directiveArgs.extractFromResponse) {
result = get(result, directiveArgs.extractFromResponse);
}
log.debug(pluginOrModule, pluginOrModuleName, 'Result:', result);
return result;
};
}
}
const {makeExecutableSchema} = $injector.resolve('graphql-tools');
const schema = makeExecutableSchema({
typeDefs: types,
resolvers,
schemaDirectives: {
func: FuncDirective
}
});
const ensureApiKey = apiKey => {
if (!apiKey) {
throw new AppError('Missing apikey.');
}
if (!ApiManager.isValid(apiKey)) {
throw new AppError('Invalid apikey.');
}
};
const {debug} = config;
return {
introspection: debug,
playground: debug,
schema,
types,
resolvers,
subscriptions: {
onConnect: connectionParams => {
const apiKey = connectionParams['x-api-key'];
ensureApiKey(apiKey);
const user = Users.findOne({apiKey}) || {name: 'guest', apiKey, role: 'guest'};
log.info(`<ws> ${user.name} connected.`);
return {
user
};
},
onDisconnect: async (_, context) => {
const initialContext = await context.initPromise;
log.info(`<ws> ${initialContext.user.name} disconnected.`);
}
},
context: ({req, connection}) => {
if (connection) {
// Check connection for metadata
return {
...connection.context
};
}
const apiKey = req.headers['x-api-key'];
ensureApiKey(apiKey);
const user = Users.findOne({apiKey}) || {name: 'guest', apiKey, role: 'guest'};
return {
user
};
}
};
};
-276
View File
@@ -1,276 +0,0 @@
/*
* Copyright 2019 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
module.exports = function (
$injector,
GraphQLJSON,
GraphQLLong,
GraphQLUUID,
PluginManager,
pubsub,
log,
PluginError,
dee,
debugTimer,
bus,
setIntervalAsync
) {
// Only allows function to publish to pubsub when clients are online
// the reason we do this is otherwise pubsub will cause a memory leak
const canPublishToClients = () => {
return true;
};
const handleResult = async possibleResult => {
// Await resolved function if it returns one.
if (typeof possibleResult === 'function') {
const result = await possibleResult();
return result;
}
return possibleResult;
};
/**
* Run a module and update pubsub
*
* @param {String} channel
* @param {String} mutation
* @param {Object} options
* @param {String} [options.node]
* @param {String} [options.moduleToRun]
* @param {String} [options.filePath]
* @param {Number} [options.interval = 1000]
* @param {Number} [options.total = 1]
* @param {Object} [options.context = {}]
*/
const run = async (channel, mutation, {
node,
moduleToRun,
filePath,
context = {}
}) => {
if (!canPublishToClients()) {
return;
}
if (!moduleToRun && !filePath) {
pubsub.publish(channel, {
[channel]: {
mutation,
node
}
});
return;
}
try {
// Run module
const result = await new Promise(resolve => {
if (filePath) {
debugTimer(`run:${filePath}`);
const promise = $injector.resolvePath(filePath, {
context
});
return resolve(promise);
}
debugTimer(`run:${moduleToRun}`);
const promise = $injector.resolveModule(`module:${moduleToRun}`, {
context
});
return resolve(promise);
}).then(handleResult);
if (filePath) {
const [pluginName, moduleName] = channel.split('/');
log.debug('Plugin:', pluginName, 'Module:', moduleName, 'Result:', result);
} else {
log.debug('Module:', channel, 'Result:', result.json);
}
// Update pubsub channel
pubsub.publish(channel, {
[filePath ? 'pluginModule' : channel]: {
mutation,
node: result.json
}
});
} catch (error) {
// Ensure we aren't leaking anything in production
if (process.env.NODE_ENV === 'production') {
log.debug('Error:', error.message);
} else {
const logger = log[error.status && error.status >= 400 ? 'error' : 'warn'];
logger('Error:', error.message);
}
}
debugTimer(filePath ? `run:${filePath}` : `run:${moduleToRun}`);
};
// Send test message every 1 second for 10 seconds.
const startPing = async (interval = 1000, total = 10) => {
await run('ping', 'UPDATED', {
node: 'PONG!',
interval,
total
});
};
// Receive test messages.
// pubsub.subscribe('ping', (...rest) => {
// console.log(`CHANNEL: ping DATA: ${JSON.stringify(rest, null, 2)}`);
// });
// Update array values when disks change
bus.on('disks', async () => {
await run('array', 'UPDATED', {
moduleToRun: 'get-array',
context: {}
});
});
const createBasicSubscription = name => ({
subscribe: async () => {
return pubsub.asyncIterator(name);
}
});
// On Docker event update info with { apps: { installed, started } }
const updatePubsub = async () => {
if (!canPublishToClients()) {
return;
}
const {json} = await $injector.resolveModule('module:info/get-apps');
pubsub.publish('info', {
info: {
mutation: 'UPDATED',
node: {
...json
}
}
});
};
dee.on('start', updatePubsub);
dee.on('stop', updatePubsub);
dee.listen();
// Republish bus events to pubsub when clients connect
// We need to filter to only the endpoint that're currently connected
// bus.on('*', (...args) => {
// if (!canPublishToClients()) {
// return;
// }
// const {
// [args.length - 1]: last,
// ...rest
// } = args;
// pubsub.publish(...Object.values(rest));
// });
// This needs to be fixed to run from events
setIntervalAsync(async () => {
if (!canPublishToClients()) {
return;
}
await run('services', 'UPDATED', {
moduleToRun: 'get-services'
});
}, 1000);
return {
Query: {
info: () => ({}),
vms: () => ({})
},
Subscription: {
apikeys: {
// Not sure how we're going to secure this
...createBasicSubscription('apikeys')
},
array: {
...createBasicSubscription('array')
},
devices: {
...createBasicSubscription('devices')
},
dockerContainers: {
...createBasicSubscription('docker/containers')
},
dockerNetworks: {
...createBasicSubscription('docker/networks')
},
info: {
...createBasicSubscription('info')
},
ping: {
subscribe: () => {
startPing();
return pubsub.asyncIterator('ping');
}
},
services: {
...createBasicSubscription('services')
},
shares: {
...createBasicSubscription('shares')
},
unassignedDevices: {
...createBasicSubscription('devices/unassigned')
},
users: {
...createBasicSubscription('users')
},
vars: {
...createBasicSubscription('vars')
},
vms: {
...createBasicSubscription('vms/domains')
},
pluginModule: {
subscribe: async (_, directiveArgs) => {
const {plugin: pluginName, module: pluginModuleName} = directiveArgs;
const name = `${pluginName}/${pluginModuleName}`;
// Verify plugin is installed and active
if (!PluginManager.isInstalled(pluginName, pluginModuleName)) {
throw new PluginError('Plugin not installed.');
}
if (!PluginManager.isActive(pluginName, pluginModuleName)) {
throw new PluginError('Plugin disabled.');
}
// It's up to the plugin to publish new data as needed
// so we'll just return the Iterator
return pubsub.asyncIterator(name);
}
}
},
JSON: GraphQLJSON,
Long: GraphQLLong,
UUID: GraphQLUUID,
UserAccount: {
__resolveType(obj) {
// Only a user has a password field, the current user aka "me" doesn't.
if (obj.password) {
return 'User';
}
return 'Me';
}
}
};
};
-14
View File
@@ -1,14 +0,0 @@
/*
* Copyright 2019 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
module.exports = function (path, mergeGraphqlSchemas) {
const {join} = path;
const {fileLoader, mergeTypes} = mergeGraphqlSchemas;
const files = fileLoader(join(__dirname, './types/**/*.graphql'));
return mergeTypes(files, {
all: true
});
};
@@ -1,31 +0,0 @@
type Query {
parityHistory: [ParityCheck] @func(module: "get-parity-history")
}
type Mutation {
"""Start parity check"""
startParityCheck(correct: Boolean): JSON @func(module: "array/update-parity-check", data: { state: "start" })
"""Pause parity check"""
pauseParityCheck: JSON @func(module: "array/update-parity-check", data: { state: "pause" })
"""Resume parity check"""
resumeParityCheck: JSON @func(module: "array/update-parity-check", data: { state: "resume" })
"""Cancel parity check"""
cancelParityCheck: JSON @func(module: "array/update-parity-check", data: { state: "cancel" })
}
type ParityHistorySubscription {
mutation: MutationType!
node: ParityCheck!
}
type Subscription {
parityHistory: ParityHistorySubscription
}
type ParityCheck {
date: String!
duration: Int!
speed: String!
status: String!
errors: String!
}
@@ -1,4 +0,0 @@
type Info {
"""Machine ID"""
machineId: ID @func(module: "info/get-machine-id")
}
-90
View File
@@ -1,90 +0,0 @@
/*
* Copyright 2019 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
const path = require('path');
const am = require('am');
const camelCase = require('camelcase');
// eslint-disable-next-line import/no-extraneous-dependencies
const Injector = require('bolus');
// Set the working directory to this one.
process.chdir(__dirname);
// Create an $injector.
const $injector = new Injector();
// Register the imported modules with default names.
$injector.registerImports([
'net',
'http',
'apollo-server-express',
'apollo-server',
'express',
'graphql-subscriptions',
'graphql-tools',
'graphql',
'set-interval-async/dynamic',
'stoppable'
]);
// Register modules that need require and not import
$injector.registerRequires([
'graphql'
]);
// Register the imported modules with custom names.
$injector.registerImports({
dee: '@gridplus/docker-events',
get: 'lodash.get',
gql: 'graphql-tag',
graphqlDirective: 'graphql-directive',
GraphQLJSON: 'graphql-type-json',
GraphQLLong: 'graphql-type-long',
GraphQLUUID: 'graphql-type-uuid',
mergeGraphqlSchemas: 'merge-graphql-schemas'
});
$injector.registerValue('setIntervalAsync', $injector.resolve('set-interval-async/dynamic').setIntervalAsync);
// Register all of the single js files as modules.
$injector.registerPath([
'*.js',
'graphql/*.js'
], defaultName => camelCase(defaultName));
// Register graphql schema
$injector.registerPath([
'./graphql/schema/**/*.js'
], defaultName => camelCase(defaultName));
// Register core
$injector.registerPath(path.resolve(process.env.PATHS_CORE || path.join(__dirname, '../node_modules/@unraid/core')));
// Boot app
am(async () => {
const core = $injector.resolve('core');
// Load core
await core.load().catch(coreError => {
try {
// Handler non fatal errors
$injector.resolve('globalErrorHandler')(coreError);
} catch {
throw coreError;
}
});
// Load server
await core.loadServer('graphql-api');
}, error => {
// We should only end here if core has an issue loading
// Log last error
console.error(error);
// Kill application
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});
-30
View File
@@ -1,30 +0,0 @@
/*
* Copyright 2019 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
/**
* The Graphql pm2 metrics reporters.
*/
module.exports = function ($injector) {
const websocketClients = {
name: 'Websocket clients',
value: () => $injector.resolve('ws-clients').size
};
const memoryCaches = {
name: 'Memory caches',
value: () => {
const caches = $injector.resolve('caches');
const keys = caches.keys();
// Return amount of caches that exist
return keys.length;
}
};
return {
websocketClients,
memoryCaches
};
};
-199
View File
@@ -1,199 +0,0 @@
/*
* Copyright 2019 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
/**
* The Graphql server.
*/
module.exports = function ($injector, path, fs, net, express, config, log, getEndpoints, stoppable, http) {
const app = express();
const port = config.get('graphql-api-port');
const {ApolloServer} = $injector.resolve('apollo-server-express');
const graphql = $injector.resolvePath(path.join(__dirname, '/graphql'));
let machineId;
app.use(async (req, res, next) => {
// Only get the machine ID on first request
// We do this to avoid using async in the main server function
if (!machineId) {
// eslint-disable-next-line require-atomic-updates
machineId = await $injector.resolveModule('module:info/get-machine-id').then(result => result.json);
}
// Update header with machine ID
res.set('x-machine-id', machineId);
next();
});
// Mount graph endpoint
const graphApp = new ApolloServer(graphql);
graphApp.applyMiddleware({app});
// List all endpoints at start of server
app.get('/', (_, res) => {
return res.send(getEndpoints(app));
});
// Handle errors by logging them and returning a 500.
// eslint-disable-next-line no-unused-vars
app.use((error, _, res, __) => {
log.error(error);
if (error.stack) {
error.stackTrace = error.stack;
}
res.status(error.status || 500).send(error);
});
// Generate types and schema for core modules
// {
// const jsdocx = $injector.resolve('jsdoc-x');
// const path = $injector.resolve('path');
// const paths = $injector.resolve('paths');
// const moduleDir = path.join(paths.get('core'), '/modules/');
// console.info('----------------------------')
// console.info('Parsing core modules')
// const docs = jsdocx.parse(`${moduleDir}/**/*.js`)
// .then(docs => {
// console.log('%s', JSON.stringify(docs, null, 0))
// // const x = gql`
// // type Disk {
// // id: String!
// // }
// // `;
// })
// .catch(error => console.error(error.stack));
// console.info('----------------------------')
// }
// (() => {
// const documentedTypeDefs = docs
// .filter(doc => !doc.undocumented)
// .filter(doc => doc.kind === 'typedef')
// .filter(doc => !doc.type.names.find(name => name.startsWith('Array')));
// documentedTypeDefs.map(doc => {
// const props = doc.properties ? Object.values(doc.properties).map(prop => {
// const desc = prop.description ? ('"""' + prop.description + '"""') : '';
// const reservedWords = {
// boolean: 'Boolean',
// number: 'Number',
// string: 'String'
// };
// const propType = prop.type.names[0];
// const type = Object.keys(reservedWords).includes(propType) ? reservedWords[propType] : propType;
// if (doc.name === 'DeviceInfo') {
// console.log({ doc });
// }
// return `${desc}\n${prop.name}: ${prop.optional ? '[' : ''}${type || 'JSON'}${!prop.optional ? '!' : ']'}`;
// }) : [];
// const template = `
// type ${doc.name} {
// ${props.join('\n')}
// }
// `;
// return template;
// })
// .forEach(doc => console.info('%s', doc));
// })()
const httpServer = http.createServer(app);
const server = stoppable(httpServer);
const handleError = error => {
if (error.code !== 'EADDRINUSE') {
throw error;
}
if (!isNaN(parseInt(port, 10))) {
throw error;
}
server.close();
net.connect({
path: port
}, () => {
// Really in use: re-throw
throw error;
}).on('error', error => {
if (error.code !== 'ECONNREFUSED') {
log.error(error);
process.exitCode = 1;
}
// Not in use: delete it and re-listen
fs.unlinkSync(port);
setTimeout(() => {
server.listen(port);
}, 1000);
});
};
// Port is a UNIX socket file
if (isNaN(parseInt(port, 10))) {
server.on('listening', () => {
// In production this will let pm2 know we're ready
if (process.send) {
process.send('ready');
}
// Set permissions
return fs.chmodSync(port, 660);
});
server.on('error', handleError);
process.on('uncaughtException', error => {
// Skip EADDRINUSE as it's already handled above
if (error.code !== 'EADDRINUSE') {
throw error;
}
});
}
// Add graphql subscription handlers
graphApp.installSubscriptionHandlers(server);
// Return an object with a server and start/stop async methods.
return {
server,
async start() {
return server.listen(port, () => {
// Downgrade process user to owner of this file
return fs.stat(__filename, (error, stats) => {
if (error) {
throw error;
}
return process.setuid(stats.uid);
});
});
},
stop() {
// Stop the server from accepting new connections and close existing connections
return server.close(error => {
if (error) {
log.error(error);
// Exit with error (code 1)
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
}
const name = process.title;
const serverName = `@unraid/${name}`;
log.info(`Successfully stopped ${serverName}`);
// Gracefully exit
process.exitCode = 0;
});
}
};
};
-8
View File
@@ -1,8 +0,0 @@
/* eslint-disable import/no-unassigned-import */
/*
* Copyright 2019 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
// Boot app
require('./app');
+1605 -485
View File
File diff suppressed because it is too large Load Diff
+8 -4
View File
@@ -1,12 +1,13 @@
{
"name": "@unraid/graphql-api",
"version": "2.1.9",
"main": "index.js",
"main": "dist/index.js",
"repository": "git@github.com:unraid/graphql-api.git",
"author": "Alexis Tyler <xo@wvvw.me> (https://wvvw.me/)",
"license": "UNLICENSED",
"scripts": {
"build": "modclean --no-progress --run --path .",
"build": "tsc",
"clean": "modclean --no-progress --run --path .",
"commit": "npx git-cz",
"lint": "xo --verbose",
"lint:quiet": "xo --quiet",
@@ -26,7 +27,7 @@
],
"dependencies": {
"@gridplus/docker-events": "^1.0.0",
"@unraid/core": "github:unraid/core#master",
"@unraid/core": "github:unraid/core#feature/add-better-typing",
"accesscontrol": "^2.2.1",
"am": "^1.0.1",
"apollo-datasource-rest": "^0.6.11",
@@ -44,9 +45,11 @@
"lodash.get": "^4.4.2",
"merge-graphql-schemas": "^1.7.6",
"p-props": "^3.1.0",
"request-promise": "4.2.5",
"set-interval-async": "^1.0.30",
"stoppable": "^1.1.0",
"subscriptions-transport-ws": "^0.9.16"
"subscriptions-transport-ws": "^0.9.16",
"ts-node": "8.6.2"
},
"optionalDependencies": {},
"devDependencies": {
@@ -59,6 +62,7 @@
"modclean": "^3.0.0-beta.1",
"node-env-run": "^3.0.2",
"standard-version": "^7.0.1",
"typescript": "3.7.5",
"xo": "^0.25.3"
},
"bundledDependencies": [
+337
View File
@@ -0,0 +1,337 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import get from 'lodash.get';
// @ts-ignore
// import * as core from '../../../core/src/index';
import core from '@unraid/core';
import { makeExecutableSchema, SchemaDirectiveVisitor } from 'graphql-tools'
import { mergeTypes } from 'merge-graphql-schemas';
import gql from 'graphql-tag';
import { typeDefs, resolvers } from './schema';
import { increaseWsConectionCount, decreaseWsConectionCount } from '../ws';
const { apiManager, errors, log, states, config } = core;
const { AppError, FatalAppError } = errors;
const { usersState } = states;
const baseTypes = [gql`
scalar JSON
scalar Long
scalar UUID
directive @func(
module: String
data: JSON
query: JSON
result: String
extractFromResponse: String
) on FIELD_DEFINITION
directive @subscription(
channel: String!
) on FIELD_DEFINITION
type Welcome {
message: String!
}
type Query {
# This should always be available even for guest users
welcome: Welcome! @func(module: "getWelcome")
info: Info!
pluginModule(plugin: String!, module: String!, params: JSON, result: String): JSON @func(result: "json")
}
type Mutation {
login(username: String!, password: String!): String
shutdown: String
reboot: String
}
enum MutationType {
CREATED
UPDATED
DELETED
}
enum UpdateOnlyMutationType {
UPDATED
}
type PingSubscription {
mutation: MutationType!
node: String!
}
type InfoSubscription {
mutation: MutationType!
node: Info!
}
type PluginModuleSubscription {
mutation: MutationType!
node: JSON!
}
type Subscription {
ping: PingSubscription!
info: InfoSubscription!
pluginModule(plugin: String!, module: String!, params: JSON, result: String): PluginModuleSubscription!
}
`];
// Add test defs in dev mode
if (process.env.NODE_ENV === 'development') {
const testDefs = gql`
# Test query
input testQueryInput {
state: String!
optional: Boolean
}
type Query {
testQuery(id: String!, input: testQueryInput): JSON @func(module: "getContext")
}
# Test mutation
input testMutationInput {
state: String!
}
type Mutation {
testMutation(id: String!, input: testMutationInput): JSON @func(module: "getContext")
}
# Test subscription
type Subscription {
testSubscription: String!
}
`;
baseTypes.push(testDefs);
}
// Add debug defs to all envs apart from production
if (process.env.NODE_ENV !== 'production') {
const debugDefs = gql`
# Debug query
type Context {
query: JSON
params: JSON
data: JSON
user: JSON
}
type Query {
context: Context @func(module: "getContext")
}
`;
baseTypes.push(debugDefs);
}
const types = mergeTypes([
...baseTypes,
typeDefs
]);
/**
* Func directive
*
* @see https://github.com/smooth-code/graphql-directive/blob/master/README.md#directive-resolver-function-signature
*
* @param {object} obj
* The result returned from the resolver on the parent field, or, in the case of a top-level Query field,
* the rootValue passed from the server configuration.
* @param {object} directiveArgs
* An object with the arguments passed into the directive in the query or schema.
* For example, if the directive was called with `@dateFormat(format: "DD/MM/YYYY")`,
* the args object would be: `{ "format": "DD/MM/YYYY" }`.
* @param {object} context
* This is an object shared by all resolvers in a particular query,
* and is used to contain per-request state, including authentication information,
* dataloader instances, and anything else that should be taken into account when resolving the query.
* @param {object} info
* This argument should only be used in advanced cases,
* but it contains information about the execution state of the query,
* including the field name, path to the field from the root, and more.
*/
class FuncDirective extends SchemaDirectiveVisitor {
visitFieldDefinition(field) {
const {args} = this;
field.resolve = async function (source, directiveArgs, { user }, info) {
const {module: moduleName, result: resultType} = args;
const {plugin: pluginName, module: pluginModuleName, result: pluginType, input, ...params} = directiveArgs;
const operationType = info.operation.operation;
const query = {
...directiveArgs.query,
...(operationType === 'query' ? input : {})
};
const data = {
...directiveArgs.data,
...(operationType === 'mutation' ? input : {})
};
// let funcPath = path.join(coreCwd, moduleName + '.js');
// If we're looking for a plugin verify it's installed and active first
// if (pluginName) {
// if (!PluginManager.isInstalled(pluginName, pluginModuleName)) {
// throw new PluginError('Plugin not installed.');
// }
// if (!PluginManager.isActive(pluginName, pluginModuleName)) {
// throw new PluginError('Plugin disabled.');
// }
// const pluginModule = PluginManager.get(pluginName, pluginModuleName);
// // Update plugin funcPath
// funcPath = pluginModule.filePath;
// }
// Create func context
// If query @func(param_1, param_2, input: query?)
// If mutation @func(param_1, param_2, input: data)
const context = {
query,
params,
data,
user
};
// Resolve func
let func;
if (!Object.keys(core.modules).includes(moduleName)) {
throw new FatalAppError(`"${moduleName}" is not a valid core module.`);
}
try {
func = core.modules[moduleName]
} catch (error_) {
// Rethrow clean error message about module being missing
if (error_.code === 'MODULE_NOT_FOUND') {
throw new AppError(`Cannot find ${pluginName ? 'Plugin: "' + pluginName + '" ' : ''}Module: "${pluginName ? pluginModuleName : moduleName}"`);
}
// In production let's just throw an internal error
if (process.env.NODE_ENV === 'production') {
throw new AppError('Internal error occurred');
}
// Otherwise re-throw actual error
throw error_;
}
const pluginOrModule = pluginName ? 'Plugin:' : 'Module:';
const pluginOrModuleName = pluginModuleName || moduleName;
// Run function
let [error, result] = await Promise.resolve(func(context))
.then(result => [undefined, result])
.catch(error_ => {
// Ensure we aren't leaking anything in production
if (process.env.NODE_ENV === 'production') {
log.error(pluginOrModule, pluginOrModuleName, 'Error:', error_.message);
return [new Error(error_.message)];
}
const logger = log[error_.status && error_.status >= 400 ? 'error' : 'warn'];
logger(pluginOrModule, pluginOrModuleName, 'Error:', error_);
return [error_];
});
// Bail if we can't get the method to run
if (error) {
return error;
}
// Get wanted result type or fallback to json
result = result[pluginType || resultType || 'json'];
// Allow fields to be extracted
if (directiveArgs.extractFromResponse) {
result = get(result, directiveArgs.extractFromResponse);
}
log.debug(pluginOrModule, pluginOrModuleName, 'Result:', result);
return result;
};
}
}
const schema = makeExecutableSchema({
typeDefs: types,
resolvers,
schemaDirectives: {
func: FuncDirective
}
});
const ensureApiKey = (apiKeyToCheck: string) => {
// Check there is atleast one valid key
if (core.apiManager.getValidKeys().length !== 0) {
if (!apiKeyToCheck) {
throw new AppError('Missing API key.');
}
if (!apiManager.isValid(apiKeyToCheck)) {
throw new AppError('Invalid API key.');
}
} else {
if (process.env.NODE_ENV !== 'development') {
throw new AppError('No valid API keys active.');
}
}
};
const debug = config.get('debug') === true;
export const graphql = {
introspection: debug,
playground: debug,
schema,
types,
resolvers,
subscriptions: {
onConnect: connectionParams => {
const apiKey = connectionParams['x-api-key'];
ensureApiKey(apiKey);
const user = usersState.findOne({apiKey}) || { name: 'guest', apiKey, role: 'guest' };
log.info(`<ws> ${user.name} connected.`);
// Update ws connection count
increaseWsConectionCount();
return {
user
};
},
onDisconnect: async (_, websocketContext) => {
const initialContext = await websocketContext.initPromise;
log.info(`<ws> ${initialContext.user.name} disconnected.`);
// Update ws connection count
decreaseWsConectionCount();
}
},
context: ({req, connection}) => {
if (connection) {
// Check connection for metadata
return {
...connection.context
};
}
const apiKey = req.headers['x-api-key'];
ensureApiKey(apiKey);
const user = usersState.findOne({apiKey}) || {name: 'guest', apiKey, role: 'guest'};
return {
user
};
}
};
+2
View File
@@ -0,0 +1,2 @@
export * from './resolvers';
export * from './type-defs';
+170
View File
@@ -0,0 +1,170 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import core from '@unraid/core';
// @ts-ignore
// import * as core from '../../../../core/src/index';
import dee from '@gridplus/docker-events';
import { setIntervalAsync } from 'set-interval-async/dynamic';
import GraphQLJSON from 'graphql-type-json';
import GraphQLLong from 'graphql-type-long';
import GraphQLUUID from 'graphql-type-uuid';
import { run, canPublishToClients, updatePubsub } from '../../run';
const { pluginManager, pubsub, utils, log, bus, errors } = core;
const { PluginError } = errors;
// Send test message every 1 second for 10 seconds.
const startPing = async (interval = 1000, total = 10) => {
await run('ping', 'UPDATED', {
node: 'PONG!',
interval,
total
});
};
// Receive test messages.
// pubsub.subscribe('ping', (...rest) => {
// console.log(`CHANNEL: ping DATA: ${JSON.stringify(rest, null, 2)}`);
// });
// Update array values when disks change
bus.on('disks', async () => {
await run('array', 'UPDATED', {
moduleToRun: core.modules.getArray,
context: {}
});
});
const createBasicSubscription = name => ({
subscribe: async () => {
return pubsub.asyncIterator(name);
}
});
// On Docker event update info with { apps: { installed, started } }
const updatePubsubWithDockerEvent = async () => {
if (!canPublishToClients()) {
return;
}
const { json } = await core.modules.getApps();
updatePubsub('info', 'UPDATED', json);
};
dee.on('start', updatePubsubWithDockerEvent);
dee.on('stop', updatePubsubWithDockerEvent);
dee.listen();
// Republish bus events to pubsub when clients connect
// We need to filter to only the endpoint that're currently connected
// bus.on('*', (...args) => {
// if (!canPublishToClients()) {
// return;
// }
// const {
// [args.length - 1]: last,
// ...rest
// } = args;
// pubsub.publish(...Object.values(rest));
// });
// This needs to be fixed to run from events
setIntervalAsync(async () => {
if (!canPublishToClients()) {
return;
}
await run('services', 'UPDATED', {
moduleToRun: core.modules.getServices
});
}, 1000);
export const resolvers = {
Query: {
info: () => ({}),
vms: () => ({})
},
Subscription: {
apikeys: {
// Not sure how we're going to secure this
...createBasicSubscription('apikeys')
},
array: {
...createBasicSubscription('array')
},
devices: {
...createBasicSubscription('devices')
},
dockerContainers: {
...createBasicSubscription('docker/containers')
},
dockerNetworks: {
...createBasicSubscription('docker/networks')
},
info: {
...createBasicSubscription('info')
},
ping: {
subscribe: () => {
startPing();
return pubsub.asyncIterator('ping');
}
},
services: {
...createBasicSubscription('services')
},
shares: {
...createBasicSubscription('shares')
},
unassignedDevices: {
...createBasicSubscription('devices/unassigned')
},
users: {
...createBasicSubscription('users')
},
vars: {
...createBasicSubscription('vars')
},
vms: {
...createBasicSubscription('vms/domains')
},
pluginModule: {
subscribe: async (_, directiveArgs) => {
const {plugin: pluginName, module: pluginModuleName} = directiveArgs;
const name = `${pluginName}/${pluginModuleName}`;
// Verify plugin is installed and active
if (!pluginManager.isInstalled(pluginName, pluginModuleName)) {
throw new PluginError('Plugin not installed.');
}
if (!pluginManager.isActive(pluginName, pluginModuleName)) {
throw new PluginError('Plugin disabled.');
}
// It's up to the plugin to publish new data as needed
// so we'll just return the Iterator
return pubsub.asyncIterator(name);
}
}
},
JSON: GraphQLJSON,
Long: GraphQLLong,
UUID: GraphQLUUID,
UserAccount: {
__resolveType(obj) {
// Only a user has a password field, the current user aka "me" doesn't.
if (obj.password) {
return 'User';
}
return 'Me';
}
}
};
+13
View File
@@ -0,0 +1,13 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import { join } from 'path';
import { fileLoader, mergeTypes } from 'merge-graphql-schemas';
const files = fileLoader(join(__dirname, './types/**/*.graphql'));
export const typeDefs = mergeTypes(files, {
all: true
});
@@ -9,15 +9,15 @@ input updateApikeyInput {
type Query {
"""Get all apikeys"""
apiKeys: [ApiKey] @func(module: "get-apikeys")
apiKeys: [ApiKey] @func(module: "getApikeys")
}
type Mutation {
"""Get user apikey"""
getApiKey(name: String!, input: authenticateInput): ApiKey @func(module: "apikeys/name/get-apikey")
getApiKey(name: String!, input: authenticateInput): ApiKey @func(module: "getApikey")
"""Update apikey"""
updateApikey(name: String!, input: updateApikeyInput): ApiKey @func(module: "apikeys/name/update-apikey")
updateApikey(name: String!, input: updateApikeyInput): ApiKey @func(module: "updateApikey")
}
type ApikeysSubscription {
@@ -1,18 +1,18 @@
type Query {
"""An Unraid array consisting of 1 or 2 Parity disks and a number of Data disks."""
array: Array @func(module: "get-array")
array: Array @func(module: "getArray")
}
type Mutation {
"""Start array"""
startArray: Array @func(module: "array/update-array", data: { state: "start" })
startArray: Array @func(module: "updateArray", data: { state: "start" })
"""Stop array"""
stopArray: Array @func(module: "array/update-array", data: { state: "stop" })
stopArray: Array @func(module: "updateArray", data: { state: "stop" })
"""Add new disk to array"""
addDiskToArray(input: arrayDiskInput): Array @func(module: "array/add-disk")
addDiskToArray(input: arrayDiskInput): Array @func(module: "addDiskToArray")
"""Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error."""
removeDiskFromArray(input: arrayDiskInput): Array @func(module: "array/add-disk")
removeDiskFromArray(input: arrayDiskInput): Array @func(module: "removeDiskFromArray")
mountArrayDisk(id: ID!): Disk
unmountArrayDisk(id: ID!): Disk
@@ -0,0 +1,31 @@
type Query {
parityHistory: [ParityCheck] @func(module: "getParityHistory")
}
type Mutation {
"""Start parity check"""
startParityCheck(correct: Boolean): JSON @func(module: "updateParityCheck", data: { state: "start" })
"""Pause parity check"""
pauseParityCheck: JSON @func(module: "updateParityCheck", data: { state: "pause" })
"""Resume parity check"""
resumeParityCheck: JSON @func(module: "updateParityCheck", data: { state: "resume" })
"""Cancel parity check"""
cancelParityCheck: JSON @func(module: "updateParityCheck", data: { state: "cancel" })
}
type ParityHistorySubscription {
mutation: MutationType!
node: ParityCheck!
}
type Subscription {
parityHistory: ParityHistorySubscription
}
type ParityCheck {
date: String!
duration: Int!
speed: String!
status: String!
errors: String!
}
@@ -1,6 +1,7 @@
type Query {
# @todo fix this
device(id: ID!): Device @func(module: "devices/device/get-device")
devices: [Device]! @func(module: "get-devices")
devices: [Device]! @func(module: "getDevices")
}
type Device {
@@ -1,8 +1,8 @@
type Query {
"""Single disk"""
disk(id: ID!): Disk @func(module: "disks/id/get-disk")
disk(id: ID!): Disk @func(module: "getDisk")
"""Mulitiple disks"""
disks: [Disk]! @func(module: "get-disks")
disks: [Disk]! @func(module: "getDisks")
}
type Disk {
# /dev/sdb
@@ -1,8 +1,8 @@
type Query {
"""Docker container"""
dockerContainer(id: ID!): DockerContainer! @func(module: "docker/get-container")
dockerContainer(id: ID!): DockerContainer! @func(module: "getDockerContainer")
"""All Docker containers"""
dockerContainers(all: Boolean): [DockerContainer]! @func(module: "docker/get-containers")
dockerContainers(all: Boolean): [DockerContainer]! @func(module: "getDockerContainers")
}
type DockerContainerSubscription {
@@ -1,8 +1,8 @@
type Query {
"""Docker network"""
dockerNetwork(id: ID!): DockerNetwork! @func(module: "docker/get-network")
dockerNetwork(id: ID!): DockerNetwork! @func(module: "getDockerNetwork")
"""All Docker networks"""
dockerNetworks(all: Boolean): [DockerNetwork]! @func(module: "docker/get-networks")
dockerNetworks(all: Boolean): [DockerNetwork]! @func(module: "getDockerNetworks")
}
type DockerNetworkSubscription {
@@ -1,6 +1,6 @@
type Info {
"""Stats on docker containers"""
apps: InfoApps @func(module: "info/get-apps")
"""Count of docker containers"""
apps: InfoApps @func(module: "getAppCount")
}
type InfoApps {
@@ -1,5 +1,5 @@
type Info {
baseboard: Baseboard @func(module: "info/get-baseboard")
baseboard: Baseboard @func(module: "getBaseboard")
}
type Baseboard {
@@ -1,5 +1,5 @@
type Info {
cpu: InfoCpu @func(module: "info/get-cpu")
cpu: InfoCpu @func(module: "getCpu")
}
type InfoCpu {
@@ -1,4 +1,5 @@
type Info {
# @todo finish this
devices: Devices @func(module: "info/get-devices")
}
@@ -1,5 +1,5 @@
type Info {
display: Display @func(module: "info/get-display")
display: Display @func(module: "getDisplay")
}
type Display {
@@ -0,0 +1,4 @@
type Info {
"""Machine ID"""
machineId: ID @func(module: "getMachineId")
}
@@ -1,5 +1,5 @@
type Info {
memory: InfoMemory @func(module: "info/get-memory")
memory: InfoMemory @func(module: "getMemory")
}
type InfoMemory {
@@ -1,5 +1,5 @@
type Info {
os: Os @func(module: "info/get-os")
os: Os @func(module: "getOs")
}
type Os {
@@ -1,5 +1,5 @@
type Info {
system: System @func(module: "info/get-system")
system: System @func(module: "getSystem")
}
type System {
@@ -1,5 +1,5 @@
type Info {
versions: Versions @func(module: "info/get-versions")
versions: Versions @func(module: "getSoftwareVersions")
}
type Versions {
@@ -1,11 +1,11 @@
type Query {
"""Node plugins"""
plugins: [Plugin] @func(module: "get-plugins")
plugins: [Plugin] @func(module: "getPlugins")
}
type Mutation {
"""Install plugin via npm"""
addPlugin(name: String!, version: String): JSON @func(module: "add-plugin")
addPlugin(name: String!, version: String): JSON @func(module: "addPlugin")
"""Update plugin installed via npm"""
updatePlugin(name: String!, version: String): JSON
@@ -4,7 +4,7 @@ type Permissions {
}
type Query {
permissions: Permissions @func(module: "get-permissions")
permissions: Permissions @func(module: "getPermissions")
}
input addScopeInput {
@@ -21,8 +21,10 @@ input addScopeToApiKeyInput {
}
type Mutation {
# @todo finish adding this to core
"""Add a new permission scope"""
addScope(input: addScopeInput!): Scope @func(module: "add-scope")
# @todo finish adding this to core
"""Add a new permission scope to apiKey"""
addScopeToApiKey(input: addScopeToApiKeyInput!): Scope @func(module: "apikeys/name/add-scope")
}
@@ -1,6 +1,7 @@
type Query {
# @todo finish this
service(name: String!): Service @func(module: "services/name/get-service")
services: [Service] @func(module: "get-services")
services: [Service] @func(module: "getServices")
}
type ServiceSubscription {
@@ -1,6 +1,6 @@
type Query {
"""Network Shares"""
shares: [Share] @func(module: "get-shares")
shares: [Share] @func(module: "getShares")
}
type ShareSubscription {
@@ -1,5 +1,5 @@
type Query {
unassignedDevices: [UnassignedDevice] @func(module: "get-unassigned-devices")
unassignedDevices: [UnassignedDevice] @func(module: "getUnassignedDevices")
}
type UnassignedDevicesSubscription {
@@ -1,6 +1,6 @@
type Query {
"""Current user account"""
me: Me @func(module: "get-me")
me: Me @func(module: "getMe")
}
# type Mutation {
@@ -11,9 +11,9 @@ input usersInput {
type Query {
"""User account"""
user(id: ID!): User @func(module: "users/id/get-user")
user(id: ID!): User @func(module: "getUser")
"""User accounts"""
users(input: usersInput): [User!]! @func(module: "get-users", query: { slim: false })
users(input: usersInput): [User!]! @func(module: "getUsers", query: { slim: false })
}
input addUserInput {
@@ -28,9 +28,9 @@ input deleteUserInput {
type Mutation {
"""Add a new user"""
addUser(input: addUserInput!): User @func(module: "add-user")
addUser(input: addUserInput!): User @func(module: "addUser")
"""Delete a user"""
deleteUser(input: deleteUserInput!): User @func(module: "users/id/delete-user")
deleteUser(input: deleteUserInput!): User @func(module: "deleteUser")
}
type UserSubscription {
@@ -1,5 +1,5 @@
type Query {
vars: Vars @func(module: "get-vars")
vars: Vars @func(module: "getVars")
}
type VarsSubscription {
@@ -1,12 +1,12 @@
type Query {
"""Virtual machine"""
vm(name: String!): VmDomain! @func(module: "vms/domains/domain/get-domain")
vm(name: String!): VmDomain! @func(module: "getDomain")
"""Virtual machines"""
vms: Vms
}
type Vms {
domains: [VmDomain!] @func(module: "vms/get-domains")
domains: [VmDomain!] @func(module: "getDomains")
}
type VmDomainSubscription {
@@ -1,4 +1,5 @@
type Query {
# @todo finish this
"""Virtual network for vms"""
vmNetwork(name: String!): JSON @func(module: "vms/domains/network/get-network")
# """Virtual networks for vms"""
+23
View File
@@ -0,0 +1,23 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import am from 'am';
import core from '@unraid/core';
import { server } from './server';
// Boot app
am(async () => {
// Load server
await core.loadServer('graphql-api', server);
}, error => {
// We should only end here if core has an issue loading
// Log last error
console.error(error);
// Kill application
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
});
+92
View File
@@ -0,0 +1,92 @@
import { CoreResult } from '@unraid/core/interfaces';
import * as core from '@unraid/core'
import { getWsConectionCount } from './ws';
const { pubsub, utils, log } = core;
const { debugTimer } = utils;
// Only allows function to publish to pubsub when clients are online
// the reason we do this is otherwise pubsub will cause a memory leak
export const canPublishToClients = () => {
const connectionCount = getWsConectionCount();
return connectionCount >= 1;
};
/**
* Update pubsub.
*/
export const updatePubsub = (channel, mutation, node) => {
if (!canPublishToClients()) {
return;
}
pubsub.publish(channel, {
[channel]: {
mutation,
node
}
});
};
interface RunOptions {
node?: string
moduleToRun?: Function
interval?: number
total?: number
context?: any
}
/**
* Run a module.
*/
export const run = async (channel: string, mutation: string, options: RunOptions) => {
const {
node,
moduleToRun,
// moduleToRun,
// filePath,
context
} = options;
if (!canPublishToClients()) {
return;
}
if (!moduleToRun) {
return updatePubsub(channel, mutation, node);
}
try {
// Run module
const result: CoreResult = await new Promise(resolve => {
debugTimer(`run:${moduleToRun.name}`);
return resolve(moduleToRun(context));
});
// if (filePath) {
// const [pluginName, moduleName] = channel.split('/');
// log.debug('Plugin:', pluginName, 'Module:', moduleName, 'Result:', result);
// }
log.debug('Module:', moduleToRun.name, 'Result:', result.json);
// Update pubsub channel
pubsub.publish(channel, {
// [filePath ? 'pluginModule' : channel]: {
[channel]: {
mutation,
node: result.json
}
});
} catch (error) {
// Ensure we aren't leaking anything in production
if (process.env.NODE_ENV === 'production') {
log.debug('Error:', error.message);
} else {
const logger = log[error.status && error.status >= 400 ? 'error' : 'warn'];
logger('Error:', error.message);
}
}
debugTimer(`run:${moduleToRun.name}`);
};
+153
View File
@@ -0,0 +1,153 @@
/*!
* Copyright 2019-2020 Lime Technology Inc. All rights reserved.
* Written by: Alexis Tyler
*/
import fs from 'fs';
import net from 'net';
import stoppable from 'stoppable';
import express from 'express';
import http from 'http';
import { ApolloServer } from 'apollo-server-express';
import core from '@unraid/core';
import { graphql } from './graphql';
const { log, config, utils, modules } = core;
const { getEndpoints } = utils;
/**
* The Graphql server.
*/
// module.exports = function (config, log, getEndpoints, stoppable, http) {
const app = express();
const port = String(config.get('graphql-api-port'));
let machineId;
app.use(async (req, res, next) => {
// Only get the machine ID on first request
// We do this to avoid using async in the main server function
if (!machineId) {
// eslint-disable-next-line require-atomic-updates
machineId = await modules.getMachineId().then(result => result.json);
}
// Update header with machine ID
res.set('x-machine-id', machineId);
next();
});
// Mount graph endpoint
const graphApp = new ApolloServer(graphql);
graphApp.applyMiddleware({app});
// List all endpoints at start of server
app.get('/', (_, res) => {
return res.send(getEndpoints(app));
});
// Handle errors by logging them and returning a 500.
// eslint-disable-next-line no-unused-vars
app.use((error, _, res, __) => {
core.log.error(error);
if (error.stack) {
error.stackTrace = error.stack;
}
res.status(error.status || 500).send(error);
});
const httpServer = http.createServer(app);
const stoppableServer = stoppable(httpServer);
const handleError = error => {
if (error.code !== 'EADDRINUSE') {
throw error;
}
if (!isNaN(parseInt(port, 10))) {
throw error;
}
stoppableServer.close();
net.connect({
path: port
}, () => {
// Really in use: re-throw
throw error;
}).on('error', (error: NodeJS.ErrnoException) => {
if (error.code !== 'ECONNREFUSED') {
log.error(error);
process.exitCode = 1;
}
// Not in use: delete it and re-listen
fs.unlinkSync(port);
setTimeout(() => {
stoppableServer.listen(port);
}, 1000);
});
};
// Port is a UNIX socket file
if (isNaN(parseInt(port, 10))) {
stoppableServer.on('listening', () => {
// In production this will let pm2 know we're ready
if (process.send) {
process.send('ready');
}
// Set permissions
return fs.chmodSync(port, 660);
});
stoppableServer.on('error', handleError);
process.on('uncaughtException', (error: NodeJS.ErrnoException) => {
// Skip EADDRINUSE as it's already handled above
if (error.code !== 'EADDRINUSE') {
throw error;
}
});
}
// Add graphql subscription handlers
graphApp.installSubscriptionHandlers(stoppableServer);
// Return an object with a server and start/stop async methods.
export const server = {
server: stoppableServer,
async start() {
return stoppableServer.listen(port, () => {
// Downgrade process user to owner of this file
return fs.stat(__filename, (error, stats) => {
if (error) {
throw error;
}
return process.setuid(stats.uid);
});
});
},
stop() {
// Stop the server from accepting new connections and close existing connections
return stoppableServer.close(error => {
if (error) {
log.error(error);
// Exit with error (code 1)
// eslint-disable-next-line unicorn/no-process-exit
process.exit(1);
}
const name = process.title;
const serverName = `@unraid/${name}`;
log.info(`Successfully stopped ${serverName}`);
// Gracefully exit
process.exitCode = 0;
});
}
};
+30
View File
@@ -0,0 +1,30 @@
let connectionCount = 0;
/**
* Return current ws connection count.
*/
export const getWsConectionCount = () => connectionCount;
/**
* Set ws connection count to a specific number.
*/
export const setWsConectionCount = (count: number) => {
connectionCount = count;
return count;
};
/**
* Increase ws connection count by 1.
*/
export const increaseWsConectionCount = () => {
connectionCount++;
return connectionCount;
};
/**
* Decrease ws connection count by 1.
*/
export const decreaseWsConectionCount = () => {
connectionCount--;
return connectionCount;
};
+76
View File
@@ -0,0 +1,76 @@
{
"include": [
"src/**/*",
"index.d.ts"
],
"exclude": [
"node_modules"
],
"compilerOptions": {
/* Basic Options */
// "incremental": true, /* Enable incremental compilation */
"target": "es2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019' or 'ESNEXT'. */
"module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', or 'ESNext'. */
// "lib": [], /* Specify library files to be included in the compilation. */
"allowJs": true, /* Allow javascript files to be compiled. */
// "checkJs": true, /* Report errors in .js files. */
// "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */
// "declaration": true, /* Generates corresponding '.d.ts' file. */
// "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
// "sourceMap": true, /* Generates corresponding '.map' file. */
// "outFile": "./", /* Concatenate and emit output to single file. */
"outDir": "./dist", /* Redirect output structure to the directory. */
"rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
// "composite": true, /* Enable project compilation */
// "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
// "removeComments": true, /* Do not emit comments to output. */
// "noEmit": true, /* Do not emit outputs. */
// "importHelpers": true, /* Import emit helpers from 'tslib'. */
// "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
// "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
/* Strict Type-Checking Options */
"strict": true, /* Enable all strict type-checking options. */
"noImplicitAny": false, /* Raise error on expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* Enable strict null checks. */
// "strictFunctionTypes": true, /* Enable strict checking of function types. */
// "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
// "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
// "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
// "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
/* Additional Checks */
// "noUnusedLocals": true, /* Report errors on unused locals. */
// "noUnusedParameters": true, /* Report errors on unused parameters. */
// "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
// "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
/* Module Resolution Options */
// "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
// "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
// "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
// "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
// "typeRoots": [], /* List of folders to include type definitions from. */
// "types": [], /* Type declaration files to be included in compilation. */
// "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
"esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
// "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
/* Source Map Options */
// "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
// "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
/* Experimental Options */
// "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
// "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
/* Advanced Options */
"forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
},
"typeAcquisition": {
"enable": true
}
}