mirror of
https://github.com/unraid/api.git
synced 2026-05-05 14:41:54 -05:00
refactor(project): add better typing support
This commit is contained in:
@@ -56,3 +56,6 @@ typings/
|
||||
|
||||
# Temp dir for tests
|
||||
test/__temp__/*
|
||||
|
||||
# Built files
|
||||
dist
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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');
|
||||
Generated
+1605
-485
File diff suppressed because it is too large
Load Diff
+8
-4
@@ -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": [
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './resolvers';
|
||||
export * from './type-defs';
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
});
|
||||
+3
-3
@@ -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 {
|
||||
+5
-5
@@ -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!
|
||||
}
|
||||
+2
-1
@@ -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 {
|
||||
+2
-2
@@ -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
|
||||
+2
-2
@@ -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 {
|
||||
+2
-2
@@ -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 {
|
||||
+2
-2
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
type Info {
|
||||
baseboard: Baseboard @func(module: "info/get-baseboard")
|
||||
baseboard: Baseboard @func(module: "getBaseboard")
|
||||
}
|
||||
|
||||
type Baseboard {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
type Info {
|
||||
cpu: InfoCpu @func(module: "info/get-cpu")
|
||||
cpu: InfoCpu @func(module: "getCpu")
|
||||
}
|
||||
|
||||
type InfoCpu {
|
||||
+1
@@ -1,4 +1,5 @@
|
||||
type Info {
|
||||
# @todo finish this
|
||||
devices: Devices @func(module: "info/get-devices")
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
type Info {
|
||||
system: System @func(module: "info/get-system")
|
||||
system: System @func(module: "getSystem")
|
||||
}
|
||||
|
||||
type System {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
type Info {
|
||||
versions: Versions @func(module: "info/get-versions")
|
||||
versions: Versions @func(module: "getSoftwareVersions")
|
||||
}
|
||||
|
||||
type Versions {
|
||||
+2
-2
@@ -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
|
||||
+3
-1
@@ -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")
|
||||
}
|
||||
+2
-1
@@ -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
-1
@@ -1,6 +1,6 @@
|
||||
type Query {
|
||||
"""Network Shares"""
|
||||
shares: [Share] @func(module: "get-shares")
|
||||
shares: [Share] @func(module: "getShares")
|
||||
}
|
||||
|
||||
type ShareSubscription {
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
type Query {
|
||||
unassignedDevices: [UnassignedDevice] @func(module: "get-unassigned-devices")
|
||||
unassignedDevices: [UnassignedDevice] @func(module: "getUnassignedDevices")
|
||||
}
|
||||
|
||||
type UnassignedDevicesSubscription {
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
type Query {
|
||||
"""Current user account"""
|
||||
me: Me @func(module: "get-me")
|
||||
me: Me @func(module: "getMe")
|
||||
}
|
||||
|
||||
# type Mutation {
|
||||
+4
-4
@@ -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
-1
@@ -1,5 +1,5 @@
|
||||
type Query {
|
||||
vars: Vars @func(module: "get-vars")
|
||||
vars: Vars @func(module: "getVars")
|
||||
}
|
||||
|
||||
type VarsSubscription {
|
||||
+2
-2
@@ -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
@@ -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"""
|
||||
@@ -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
@@ -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
@@ -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;
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user