gitlab-acebase
Version:
AceBase realtime database server (webserver endpoint to allow remote connections)
396 lines • 17.8 kB
JavaScript
import { ColorStyle, DebugLogger, SimpleEventEmitter } from 'acebase-core';
import { AceBaseServerConfig } from './settings/index.js';
import { createApp, createRouter } from './shared/http.js';
import { addWebsocketServer } from './websocket/index.js';
import { AceBase } from 'acebase';
import { createServer } from 'http';
import { createServer as createSecureServer } from 'https';
import oAuth2Providers from './oauth-providers/index.js';
import { PathBasedRules } from './rules.js';
import addConnectionMiddleware from './middleware/connection.js';
import addCorsMiddleware from './middleware/cors.js';
import addAuthenticionRoutes from './routes/auth.js';
import setupAuthentication from './auth.js';
import addDataRoutes from './routes/data.js';
import addWebManagerRoutes from './routes/webmanager.js';
import addMetadataRoutes from './routes/meta.js';
import add404Middleware from './middleware/404.js';
import addCacheMiddleware from './middleware/cache.js';
import { DatabaseLog } from './logger.js';
// type PrivateLocalSettings = AceBaseLocalSettings & { storage: PrivateStorageSettings };
export class AceBaseServerNotReadyError extends Error {
constructor() { super('Server is not ready yet'); }
}
export class AceBaseExternalServerError extends Error {
constructor() { super('This method is not available with an external server'); }
}
export class AceBaseServer extends SimpleEventEmitter {
get isReady() { return this._ready; }
/**
* Wait for the server to be ready to accept incoming connections
* @param callback (optional) callback function that is called when ready. You can also use the returned promise
* @returns returns a promise that resolves when ready
*/
async ready(callback) {
if (!this._ready) {
await this.once('ready');
}
callback?.();
}
/**
* Gets the url the server is running at
*/
get url() {
return `http${this.config.https.enabled ? 's' : ''}://${this.config.host}:${this.config.port}/${this.config.rootPath}`;
}
constructor(dbname, options) {
super();
this._ready = false;
this.authProviders = {};
this.config = new AceBaseServerConfig(options);
this.debug = new DebugLogger(this.config.logLevel, `[${dbname}]`.colorize(ColorStyle.green));
if (this.config.auth.enabled && !this.config.https.enabled) {
this.debug.warn(`WARNING: Authentication is enabled, but the server is not using https. Any password and other data transmitted may be intercepted!`.colorize(ColorStyle.red));
}
else if (!this.config.https.enabled) {
this.debug.warn(`WARNING: Server is not using https, any data transmitted may be intercepted!`.colorize(ColorStyle.red));
}
if (!this.config.auth.enabled) {
this.debug.warn(`WARNING: Authentication is disabled, *anyone* can do *anything* with your data!`.colorize(ColorStyle.red));
}
// Open database(s)
const dbOptions = {
logLevel: this.config.logLevel,
info: 'realtime database server',
storage: {
path: this.config.path,
removeVoidProperties: true,
ipc: this.config.ipc,
...this.config.storage, // Allow overriding storage settings - allows using other db backends (typed, but undocumented)
},
transactions: this.config.transactions,
sponsor: this.config.sponsor,
logColors: this.config.logColors,
};
this.db = new AceBase(dbname, dbOptions);
const otherDbsPath = `${this.config.path}/${this.db.name}.acebase`;
const authDb = (() => {
if (!this.config.auth.enabled) {
return null;
}
switch (this.config.auth.separateDb) {
case true: return new AceBase('auth', { logLevel: dbOptions.logLevel, storage: { path: otherDbsPath, removeVoidProperties: true, info: `${dbname} auth database` } });
case 'v2': /*NOT TESTED YET*/ return new AceBase(dbname, { logLevel: dbOptions.logLevel, storage: { type: 'auth', path: this.config.path, removeVoidProperties: true, info: `${dbname} auth database` } });
default: return this.db;
}
})();
// Create Express app
this.app = createApp({ trustProxy: true, maxPayloadSize: this.config.maxPayloadSize });
this.router = createRouter();
this.app.use(`/${this.config.rootPath}`, this.router);
// Initialize and start server
this.init({ authDb });
}
async init(env) {
const config = this.config;
const db = this.db;
const authDb = env.authDb;
// Wait for databases to be ready to use
await Promise.all([
db.ready(),
authDb?.ready(),
]);
// Create http server
this.config.server?.on('request', this.app);
const server = this.config.server || (config.https.enabled ? createSecureServer(config.https, this.app) : createServer(this.app));
const clients = new Map();
const securityRef = authDb ? authDb === db ? db.ref('__auth__/security') : authDb.ref('security') : null;
const authRef = authDb ? authDb === db ? db.ref('__auth__/accounts') : authDb.ref('accounts') : null;
const logRef = authDb ? authDb === db ? db.ref('__log__') : authDb.ref('log') : null;
const logger = new DatabaseLog(logRef);
// Setup rules
const rulesFilePath = `${this.config.path}/${this.db.name}.acebase/rules.json`;
const rules = new PathBasedRules(rulesFilePath, config.auth.defaultAccessRule, { db, debug: this.debug, authEnabled: this.config.auth.enabled });
this.setRule = (rulePath, ruleType, callback) => {
return rules.add(rulePath, ruleType, callback);
};
const routeEnv = {
config: this.config,
server,
db: db,
authDb,
app: this.app,
router: this.router,
rootPath: this.config.rootPath,
debug: this.debug,
securityRef,
authRef,
log: logger,
tokenSalt: null,
clients,
authCache: null,
authProviders: this.authProviders,
rules,
instance: this,
};
// Add connection middleware
const killConnections = addConnectionMiddleware(routeEnv);
// Add CORS middleware
addCorsMiddleware(routeEnv);
// Add cache middleware
addCacheMiddleware(routeEnv);
if (config.auth.enabled) {
// Setup auth database
await setupAuthentication(routeEnv);
// Add auth endpoints
const { resetPassword, verifyEmailAddress } = addAuthenticionRoutes(routeEnv);
this.resetPassword = resetPassword;
this.verifyEmailAddress = verifyEmailAddress;
}
// Add metadata endpoints
addMetadataRoutes(routeEnv);
// If environment is development, add API docs
if (process.env.NODE_ENV && process.env.NODE_ENV.trim() === 'development') {
this.debug.warn('DEVELOPMENT MODE: adding API docs endpoint at /docs');
(await import('./routes/docs.js')).addRoute(routeEnv);
(await import('./middleware/swagger.js')).addMiddleware(routeEnv);
}
// Add data endpoints
addDataRoutes(routeEnv);
// Add webmanager endpoints
addWebManagerRoutes(routeEnv);
// Allow adding custom routes
this.extend = (method, ext_path, handler) => {
const route = `/ext/${db.name}/${ext_path}`;
this.debug.log(`Extending server: `, method, route);
this.router[method.toLowerCase()](route, handler);
};
// Create websocket server
addWebsocketServer(routeEnv);
// Run init callback to allow user code to call `server.extend`, `server.router.[method]`, `server.setRule` etc before the server starts listening
await this.config.init?.(this);
// If we own the server, add 404 handler
if (!this.config.server) {
add404Middleware(routeEnv);
}
// Setup pause and resume methods
let paused = false;
this.pause = async () => {
if (this.config.server) {
throw new AceBaseExternalServerError();
}
if (paused) {
throw new Error('Server is already paused');
}
server.close();
this.debug.warn(`Paused "${db.name}" database server at ${this.url}`);
this.emit('pause');
paused = true;
};
this.resume = async () => {
if (this.config.server) {
throw new AceBaseExternalServerError();
}
if (!paused) {
throw new Error('Server is not paused');
}
return new Promise(resolve => {
server.listen(config.port, config.host, () => {
this.debug.warn(`Resumed "${db.name}" database server at ${this.url}`);
this.emit('resume');
paused = false;
resolve();
});
});
};
// Handle SIGINT and shutdown requests
const shutdown = async (request) => {
this.debug.warn('shutting down server');
routeEnv.rules.stop();
const getConnectionsCount = () => {
return new Promise((resolve, reject) => {
server.getConnections((err, connections) => {
if (err) {
reject(err);
}
else {
resolve(connections);
}
});
});
};
const connections = await getConnectionsCount();
this.debug.log(`Server has ${connections} connections`);
await new Promise((resolve) => {
// const interval = setInterval(async () => {
// const connections = await getConnectionsCount();
// this.debug.log(`Server still has ${connections} connections`);
// }, 5000);
// interval.unref();
server.close(err => {
if (err) {
this.debug.error(`server.close() error: ${err.message}`);
}
else {
this.debug.log(`server.close() success`);
}
resolve();
});
// If for some reason connection aren't broken in time - do proceed with shutdown sequence
const timeout = setTimeout(() => {
if (clients.size === 0) {
return;
}
this.debug.warn(`server.close() timed out, there are still open connections`);
killConnections();
}, 5000);
timeout.unref();
this.debug.log(`Closing ${clients.size} websocket connections`);
clients.forEach((client, id) => {
const socket = client.socket;
socket.once('disconnect', reason => {
this.debug.log(`Socket ${socket.id} disconnected: ${reason}`);
});
socket.disconnect(true);
});
});
this.debug.warn('closing database');
await db.close();
this.debug.warn('shutdown complete');
// Emit events to let the outside world know we shut down.
// This is especially important if this instance was running in a Node.js cluster: the process will
// not exit automatically after this shutdown because Node.js' IPC channel between worker and master is still open.
// By sending these events, the cluster manager can determine if it should (and when to) execute process.exit()
// process.emit('acebase-server-shutdown'); // Emit on process
process.emit('beforeExit', request.sigint ? 130 : 0); // Emit on process
try {
process.send && process.send('acebase-server-shutdown'); // Send to master process when running in a Node.js cluster
}
catch (err) {
// IPC Channel has apparently been closed already
}
this.emit('shutdown'); // Emit on AceBaseServer instance
};
this.shutdown = async () => {
if (this.config.server) {
throw new AceBaseExternalServerError();
}
await shutdown({ sigint: false });
};
if (this.config.server) {
// Offload shutdown control to an external server
server.on('close', function close() {
server.off('request', this.app);
server.off('close', close);
shutdown({ sigint: false });
});
const ready = () => {
this.debug.log(`"${db.name}" database server running at ${this.url}`);
this._ready = true;
this.emitOnce(`ready`);
server.off('listening', ready);
};
if (server.listening) {
ready();
}
else {
server.on('listening', ready);
}
}
else {
// Start listening
server.listen(config.port, config.host, () => {
// Ready!!
this.debug.log(`"${db.name}" database server running at ${this.url}`);
this._ready = true;
this.emitOnce(`ready`);
});
process.on('SIGINT', () => shutdown({ sigint: true }));
}
}
/**
* Reset a user's password. This can also be done using the auth/reset_password API endpoint
* @param clientIp ip address of the user
* @param code reset code that was sent to the user's email address
* @param newPassword new password chosen by the user
*/
resetPassword(clientIp, code, newPassword) {
throw new AceBaseServerNotReadyError();
}
/**
* Marks a user account's email address as validated. This can also be done using the auth/verify_email API endpoint
* @param clientIp ip address of the user
* @param code verification code sent to the user's email address
*/
verifyEmailAddress(clientIp, code) {
throw new AceBaseServerNotReadyError();
}
/**
* Shuts down the server. Stops listening for incoming connections, breaks current connections and closes the database.
* Is automatically executed when a "SIGINT" process event is received.
*
* Once the shutdown procedure is completed, it emits a "shutdown" event on the server instance, "acebase-server-shutdown" event on the `process`, and sends an 'acebase-server-shutdown' IPC message if Node.js clustering is used.
* These events can be handled by cluster managing code to `kill` or `exit` the process safely.
*/
shutdown() {
throw new AceBaseServerNotReadyError();
}
/**
* Temporarily stops the server from handling incoming connections, but keeps existing connections open
*/
pause() {
throw new AceBaseServerNotReadyError();
}
/**
* Resumes handling incoming connections
*/
resume() {
throw new AceBaseServerNotReadyError();
}
/**
* Extend the server API with your own custom functions. Your handler will be listening
* on path /ext/[db name]/[ext_path].
* @example
* // Server side:
* const _quotes = [...];
* server.extend('get', 'quotes/random', (req, res) => {
* let index = Math.round(Math.random() * _quotes.length);
* res.send(quotes[index]);
* })
* // Client side:
* client.callExtension('get', 'quotes/random')
* .then(quote => {
* console.log(`Got random quote: ${quote}`);
* })
* @param method http method to bind to
* @param ext_path path to bind to (appended to /ext/)
* @param handler your Express request handler callback
*/
extend(method, ext_path, handler) {
throw new AceBaseServerNotReadyError();
}
/**
* Configure an auth provider to allow users to sign in with Facebook, Google, etc
* @param providerName name of the third party OAuth provider. Eg: "Facebook", "Google", "spotify" etc
* @param settings API key & secret for the OAuth provider
* @returns Returns the created auth provider instance, which can be used to call non-user specific methods the provider might support. (example: the Spotify auth provider supports getClientAuthToken, which allows API calls to be made to the core (non-user) spotify service)
*/
configAuthProvider(providerName, settings) {
if (!this.config.auth.enabled) {
throw new Error(`Authentication is not enabled`);
}
try {
const AuthProvider = oAuth2Providers[providerName];
const provider = new AuthProvider(settings);
this.authProviders[providerName] = provider;
return provider;
}
catch (err) {
throw new Error(`Failed to configure provider ${providerName}: ${err.message}`);
}
}
setRule(paths, types, callback) {
throw new AceBaseServerNotReadyError();
}
}
//# sourceMappingURL=server.js.map