@soundworks/core
Advanced tools
Comparing version 4.0.0-alpha.5 to 4.0.0-alpha.6
{ | ||
"name": "@soundworks/core", | ||
"version": "4.0.0-alpha.5", | ||
"version": "4.0.0-alpha.6", | ||
"description": "Open-source creative coding framework for distributed applications based on Web technologies", | ||
@@ -46,4 +46,3 @@ "authors": [ | ||
"scripts": { | ||
"doc": "npm run jsdoc", | ||
"jsdoc": "rm -Rf docs && jsdoc -c .jsdoc.json --verbose && cp -R assets docs/", | ||
"doc": "rm -Rf docs && jsdoc -c .jsdoc.json --verbose && cp -R assets docs/", | ||
"lint": "npx eslint src", | ||
@@ -53,3 +52,3 @@ "test": "mocha", | ||
"types": "rm -Rf types && tsc", | ||
"version": "npm run toc && npm run doc && git add docs" | ||
"version": "npm run toc" | ||
}, | ||
@@ -62,2 +61,3 @@ "dependencies": { | ||
"compression": "^1.7.1", | ||
"cross-fetch": "^3.1.5", | ||
"express": "^4.18.1", | ||
@@ -72,3 +72,2 @@ "fast-deep-equal": "^3.1.3", | ||
"pem": "^1.14.6", | ||
"serve-static": "^1.15.0", | ||
"template-literal": "^1.0.4", | ||
@@ -75,0 +74,0 @@ "uuid": "^9.0.0", |
@@ -201,2 +201,8 @@ import { isBrowser, isPlainObject } from '@ircam/sc-utils'; | ||
/** | ||
* Token of the client if connected through HTTP authentication. | ||
* @private | ||
*/ | ||
this.token = null; | ||
/** @private */ | ||
@@ -244,5 +250,6 @@ this._onStatusChangeCallbacks = new Set(); | ||
// wait for handshake response before starting stateManager and pluginManager | ||
this.socket.addListener(CLIENT_HANDSHAKE_RESPONSE, async ({ id, uuid }) => { | ||
this.socket.addListener(CLIENT_HANDSHAKE_RESPONSE, async ({ id, uuid, token }) => { | ||
this.id = id; | ||
this.uuid = uuid; | ||
this.token = token; | ||
@@ -249,0 +256,0 @@ resolve(); |
import { isBrowser } from '@ircam/sc-utils'; | ||
import fetch from 'cross-fetch'; | ||
import WebSocket from 'isomorphic-ws'; | ||
@@ -113,4 +114,8 @@ | ||
const queryParams = `role=${role}&key=${key}`; | ||
let queryParams = `role=${role}&key=${key}`; | ||
if (config.token) { | ||
queryParams += `&token=${config.token}`; | ||
} | ||
// ---------------------------------------------------------- | ||
@@ -150,8 +155,3 @@ // init string socket | ||
ws.addEventListener('error', e => { | ||
if (e.type === 'error' && e.error.code === 'ECONNREFUSED') { | ||
if (!connectionRefusedLogged) { | ||
logger.log('[soundworks.Socket] Connection refused, waiting for the server to start'); | ||
connectionRefusedLogged = true; | ||
} | ||
if (e.type === 'error') { | ||
if (ws.terminate) { | ||
@@ -163,3 +163,11 @@ ws.terminate(); | ||
setTimeout(trySocket, 1000); | ||
// for node clients, retry connection | ||
if (e.error && e.error.code === 'ECONNREFUSED') { | ||
if (!connectionRefusedLogged) { | ||
logger.log('[soundworks.Socket] Connection refused, waiting for the server to start'); | ||
connectionRefusedLogged = true; | ||
} | ||
setTimeout(trySocket, 1000); | ||
} | ||
} | ||
@@ -166,0 +174,0 @@ }); |
@@ -141,3 +141,2 @@ import { isPlainObject } from '@ircam/sc-utils'; | ||
}); | ||
} | ||
@@ -154,3 +153,3 @@ } | ||
this.detach = () => { | ||
throw new Error(`[stateManager] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`); | ||
throw new Error(`[SharedState] State "${this.schemaName} (${this.id})" already detached, cannot detach twice`); | ||
}; | ||
@@ -275,6 +274,5 @@ } | ||
for (let name in updates) { | ||
// throw early (client-side and not only server-side) if parameter is undefined | ||
if (!this._parameters.has(name)) { | ||
throw new ReferenceError(`[SharedState] State "${this.schemaName}": cannot set value of undefined parameter "${name}"`); | ||
} | ||
// try to coerce value early, so that eventual errors are triggered early | ||
// on the node requesting the update | ||
const _ = this._parameters.coerceValue(name, updates[name]); | ||
@@ -318,3 +316,4 @@ // @note: general idea... | ||
/** | ||
* Get the value of a paramter of the state. | ||
* Get the value of a parameter of the state. If the parameter is of `any` type, | ||
* a deep copy is returned. | ||
* | ||
@@ -326,3 +325,3 @@ * @param {string} name - Name of the param. | ||
* @example | ||
* const value = state.get('name'); | ||
* const value = state.get('paramName'); | ||
*/ | ||
@@ -334,4 +333,23 @@ get(name) { | ||
/** | ||
* Get all the key / value pairs of the state. | ||
* Similar to `get` but returns a reference to the underlying value in case of | ||
* `any` type. May be usefull if the underlying value is big (e.g. sensors | ||
* recordings, etc.) and deep cloning expensive. Be aware that if changes are | ||
* made on the returned object, the state of your application will become | ||
* inconsistent. | ||
* | ||
* @param {string} name - Name of the param. | ||
* @throws Throws if `name` does not correspond to an existing field | ||
* of the state. | ||
* @return {mixed} | ||
* @example | ||
* const value = state.getUnsafe('paramName'); | ||
*/ | ||
getUnsafe(name) { | ||
return this._parameters.getUnsafe(name); | ||
} | ||
/** | ||
* Get all the key / value pairs of the state. If a parameter is of `any` | ||
* type, a deep copy is made. | ||
* | ||
* @return {object} | ||
@@ -346,2 +364,19 @@ * @example | ||
/** | ||
* Get all the key / value pairs of the state. If a parameter is of `any` | ||
* type, a deep copy is made. | ||
* Similar to `getValues` but returns a reference to the underlying value in | ||
* case of `any` type. May be usefull if the underlying value is big (e.g. | ||
* sensors recordings, etc.) and deep cloning expensive. Be aware that if | ||
* changes are made on the returned object, the state of your application will | ||
* become inconsistent. | ||
* | ||
* @return {object} | ||
* @example | ||
* const values = state.getValues(); | ||
*/ | ||
getValuesUnsafe() { | ||
return this._parameters.getValuesUnsafe(); | ||
} | ||
/** | ||
* Get the schema from which the state has been created. | ||
@@ -433,3 +468,3 @@ * | ||
* | ||
* @param {client.SharedState~onUpdateCallback|client.SharedState~onUpdateCallback} callback | ||
* @param {client.SharedState~onUpdateCallback|server.SharedState~onUpdateCallback} callback | ||
* Callback to execute when an update is applied on the state. | ||
@@ -469,4 +504,4 @@ * @param {Boolean} [executeListener=false] - Execute the callback immediately | ||
* | ||
* @param {Function} callback - callback to execute when detaching from the state. | ||
* wether the client as called `detach`, or the state has been deleted by its | ||
* @param {Function} callback - Callback to execute when detaching from the state. | ||
* Whether the client as called `detach`, or the state has been deleted by its | ||
* creator. | ||
@@ -483,3 +518,3 @@ */ | ||
* | ||
* @param {Function} callback - callback to execute when the state is deleted. | ||
* @param {Function} callback - Callback to execute when the state is deleted. | ||
*/ | ||
@@ -486,0 +521,0 @@ onDelete(callback) { |
@@ -0,2 +1,4 @@ | ||
import { isString, isFunction } from '@ircam/sc-utils'; | ||
import SharedState from './BaseSharedState.js'; | ||
import SharedStateCollection from './BaseSharedStateCollection.js'; | ||
import { | ||
@@ -34,3 +36,3 @@ CREATE_REQUEST, | ||
this._statesById = new Map(); | ||
this._observeListeners = new Set(); | ||
this._observeListeners = new Map(); // Map <callback, filterSchemaName> | ||
this._cachedSchemas = new Map(); | ||
@@ -92,7 +94,11 @@ this._observeRequestCallbacks = new Map(); | ||
// we don't call another callback that may have been registered earlier. | ||
const callback = this._observeRequestCallbacks.get(reqId); | ||
const [callback, filterSchemaName] = this._observeRequestCallbacks.get(reqId); | ||
this._observeRequestCallbacks.delete(reqId); | ||
const promises = list.map(([schemaName, stateId, nodeId]) => { | ||
return callback(schemaName, stateId, nodeId); | ||
if (filterSchemaName === '*' || filterSchemaName === schemaName) { | ||
return callback(schemaName, stateId, nodeId); | ||
} else { | ||
return Promise.resolve(); | ||
} | ||
}); | ||
@@ -104,3 +110,3 @@ | ||
this._observeListeners.delete(callback); | ||
// no more listeners, we can stop receiving notification from the server | ||
if (this._observeListeners.size === 0) { | ||
@@ -110,8 +116,11 @@ this.client.transport.emit(UNOBSERVE_NOTIFICATION); | ||
}; | ||
resolveRequest(reqId, unsubscribe); | ||
}); | ||
this.client.transport.addListener(OBSERVE_NOTIFICATION, (...list) => { | ||
list.forEach(([schemaName, stateId, nodeId]) => { | ||
this._observeListeners.forEach(callback => callback(schemaName, stateId, nodeId)); | ||
this.client.transport.addListener(OBSERVE_NOTIFICATION, (schemaName, stateId, nodeId) => { | ||
this._observeListeners.forEach((filterSchemaName, callback) => { | ||
if (filterSchemaName === '*' || filterSchemaName === schemaName) { | ||
callback(schemaName, stateId, nodeId); | ||
} | ||
}); | ||
@@ -177,8 +186,12 @@ }); | ||
* | ||
* @todo Optionnal schema name | ||
* Note that the states that are created by the same node are not propagated through | ||
* the observe callback. | ||
* | ||
* @param {String} [schemaName] - optionnal schema name to filter the observed | ||
* states. | ||
* @param {server.StateManager~ObserveCallback|client.StateManager~ObserveCallback} | ||
* callback - Function to be called when a new state is created on the network. | ||
* @returns {Promise<Function>} - Return a Promise that resolves when the callback | ||
* as been executed for the first time. The promise value is a function which | ||
* allows to stop observing the network. | ||
* @returns {Promise<Function>} - Returns a Promise that resolves when the given | ||
* callback as been executed on each existing states. The promise value is a | ||
* function which allows to stop observing the states on the network. | ||
* @example | ||
@@ -192,12 +205,53 @@ * client.stateManager.observe(async (schemaName, stateId, nodeId) => { | ||
*/ | ||
async observe(callback) { | ||
this._observeListeners.add(callback); | ||
// store function | ||
// note: all filtering is done only on client-side as it is really more simple to | ||
// handle this way and the network overhead is very low for observe notifications: | ||
// i.e. schemaName, stateId, nodeId | ||
async observe(...args) { | ||
let filterSchemaName; | ||
let callback; | ||
if (args.length === 1) { | ||
filterSchemaName = '*'; | ||
callback = args[0]; | ||
if (!isFunction(callback)) { | ||
throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`); | ||
} | ||
} else if (args.length === 2) { | ||
filterSchemaName = args[0]; | ||
callback = args[1]; | ||
if (!isString(filterSchemaName) || !isFunction(callback)) { | ||
throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`); | ||
} | ||
} else { | ||
throw new Error(`[stateManager] Invalid arguments, valid signatures are "stateManager.observe(callback)" or "stateManager.observe(schemaName, callback)"`); | ||
} | ||
// resend request to get updated list of states | ||
return new Promise((resolve, reject) => { | ||
const reqId = storeRequestPromise(resolve, reject); | ||
// store the callback to be executed on the response | ||
this._observeRequestCallbacks.set(reqId, callback); | ||
// store the callback for execution on the response. the returned Promise | ||
// is fullfiled once callback has been executed with each existing states | ||
this._observeRequestCallbacks.set(reqId, [callback, filterSchemaName]); | ||
// store the callback for execution on subsequent notifications | ||
this._observeListeners.set(callback, filterSchemaName); | ||
this.client.transport.emit(OBSERVE_REQUEST, reqId); | ||
}); | ||
} | ||
/** | ||
* Returns a collection of all the states created from the schema name. Except | ||
* the ones created by the current node. | ||
* | ||
* @param {string} schemaName - Name of the schema. | ||
* @returns {server.SharedStateCollection|client.SharedStateCollection} | ||
*/ | ||
async getCollection(schemaName) { | ||
const collection = new SharedStateCollection(this, schemaName); | ||
await collection._init(); | ||
return collection; | ||
} | ||
} | ||
@@ -204,0 +258,0 @@ |
@@ -20,3 +20,3 @@ import cloneDeep from 'lodash.clonedeep'; | ||
if (typeof value !== 'boolean') { | ||
throw new TypeError(`[SharedState] Invalid value for boolean param "${name}": ${value}`); | ||
throw new TypeError(`[SharedState] Invalid value "${value}" for boolean parameter "${name}"`); | ||
} | ||
@@ -34,3 +34,3 @@ | ||
if (typeof value !== 'string') { | ||
throw new TypeError(`[SharedState] Invalid value for string param "${name}": ${value}`); | ||
throw new TypeError(`[SharedState] Invalid value "${value}" for string parameter "${name}"`); | ||
} | ||
@@ -68,3 +68,3 @@ | ||
if (!(typeof value === 'number' && Math.floor(value) === value)) { | ||
throw new TypeError(`[SharedState] Invalid value for integer param "${name}": ${value}`); | ||
throw new TypeError(`[SharedState] Invalid value "${value}" for integer parameter "${name}"`); | ||
} | ||
@@ -102,3 +102,3 @@ | ||
if (typeof value !== 'number' || value !== value) { // reject NaN | ||
throw new TypeError(`[SharedState] Invalid value for float param "${name}": ${value}`); | ||
throw new TypeError(`[SharedState] Invalid value "${value}" for float parameter "${name}"`); | ||
} | ||
@@ -116,3 +116,3 @@ | ||
if (def.list.indexOf(value) === -1) { | ||
throw new TypeError(`[SharedState] Invalid value for enum param "${name}": ${value}`); | ||
throw new TypeError(`[SharedState] Invalid value "${value}" for enum parameter "${name}"`); | ||
} | ||
@@ -249,3 +249,4 @@ | ||
/** | ||
* Return values of all parameters as a flat object. | ||
* Return values of all parameters as a flat object. If a parameter is of `any` | ||
* type, a deep copy is made. | ||
* | ||
@@ -265,2 +266,21 @@ * @return {object} | ||
/** | ||
* Return values of all parameters as a flat object. Similar to `getValues` but | ||
* returns a reference to the underlying value in case of `any` type. May be | ||
* usefull if the underlying value is big (e.g. sensors recordings, etc.) and | ||
* deep cloning expensive. Be aware that if changes are made on the returned | ||
* object, the state of your application will become inconsistent. | ||
* | ||
* @return {object} | ||
*/ | ||
getValuesUnsafe() { | ||
let values = {}; | ||
for (let name in this._values) { | ||
values[name] = this.getUnsafe(name); | ||
} | ||
return values; | ||
} | ||
/** | ||
* Return the value of the given parameter. If the parameter is of `any` type, | ||
@@ -287,13 +307,27 @@ * a deep copy is returned. | ||
/** | ||
* Set the value of a parameter. If the value of the parameter is updated | ||
* (aka if previous value is different from new value) all registered | ||
* callbacks are registered. | ||
* Similar to `get` but returns a reference to the underlying value in case of | ||
* `any` type. May be usefull if the underlying value is big (e.g. sensors | ||
* recordings, etc.) and deep cloning expensive. Be aware that if changes are | ||
* made on the returned object, the state of your application will become | ||
* inconsistent. | ||
* | ||
* @param {string} name - Name of the parameter. | ||
* @return {Mixed} - Value of the parameter. | ||
*/ | ||
getUnsafe(name) { | ||
if (!this.has(name)) { | ||
throw new ReferenceError(`[SharedState] Cannot get value of undefined parameter "${name}"`); | ||
} | ||
return this._values[name]; | ||
} | ||
/** | ||
* Check that the value is valid according to the schema and return it coerced | ||
* to the schema definition | ||
* | ||
* @param {String} name - Name of the parameter. | ||
* @param {Mixed} value - Value of the parameter. | ||
* @param {boolean} [forcePropagation=false] - if true, propagate value even | ||
* if the value has not changed. | ||
* @return {Array} - [new value, updated flag]. | ||
*/ | ||
set(name, value) { | ||
coerceValue(name, value) { | ||
if (!this.has(name)) { | ||
@@ -304,3 +338,2 @@ throw new ReferenceError(`[SharedState] Cannot set value of undefined parameter "${name}"`); | ||
const def = this._schema[name]; | ||
const { coerceFunction } = types[def.type]; | ||
@@ -312,5 +345,20 @@ if (value === null && def.nullable === false) { | ||
} else { | ||
const { coerceFunction } = types[def.type]; | ||
value = coerceFunction(name, def, value); | ||
} | ||
return value; | ||
} | ||
/** | ||
* Set the value of a parameter. If the value of the parameter is updated | ||
* (aka if previous value is different from new value) all registered | ||
* callbacks are registered. | ||
* | ||
* @param {string} name - Name of the parameter. | ||
* @param {Mixed} value - Value of the parameter. | ||
* @return {Array} - [new value, updated flag]. | ||
*/ | ||
set(name, value) { | ||
value = this.coerceValue(name, value); | ||
const currentValue = this._values[name]; | ||
@@ -320,5 +368,4 @@ const updated = !equal(currentValue, value); | ||
// we store a deep copy of the object as we don't want the client to be able | ||
// to modify our underlying data, which leads unexpected behavior where the | ||
// to modify our underlying data, which leads to unexpected behavior where the | ||
// deep equal check to returns true, and therefore the update is not triggered. | ||
// | ||
// @see tests/common.state-manager.spec.js | ||
@@ -325,0 +372,0 @@ // 'should copy stored value for "any" type to have a predictable behavior' |
@@ -46,2 +46,8 @@ import { idGenerator } from '@ircam/sc-utils'; | ||
this.socket = socket; | ||
/** | ||
* Is set in server._onSocketConnection | ||
* @private | ||
*/ | ||
this.token = null; | ||
} | ||
@@ -48,0 +54,0 @@ } |
@@ -8,3 +8,4 @@ import fs from 'node:fs'; | ||
import { isPlainObject } from '@ircam/sc-utils'; | ||
import { getTime } from '@ircam/sc-gettime'; | ||
import { isPlainObject, idGenerator } from '@ircam/sc-utils'; | ||
import chalk from 'chalk'; | ||
@@ -20,2 +21,3 @@ import compression from 'compression'; | ||
import auditSchema from './audit-schema.js'; | ||
import { encryptData, decryptData } from './crypto.js'; | ||
import Client from './Client.js'; | ||
@@ -34,2 +36,3 @@ import ContextManager from './ContextManager.js'; | ||
let _dbNamespaces = new Set(); | ||
@@ -79,2 +82,4 @@ | ||
const TOKEN_VALID_DURATION = 20; // sec | ||
// set terminal title | ||
@@ -163,4 +168,4 @@ /** @private */ | ||
/** | ||
* Given config object merged with the following defaults: | ||
* ``` | ||
* @description Given config object merged with the following defaults: | ||
* @example | ||
* { | ||
@@ -186,3 +191,2 @@ * env: { | ||
* } | ||
* ``` | ||
*/ | ||
@@ -245,4 +249,6 @@ this.config = merge({}, DEFAULT_CONFIG, config); | ||
*/ | ||
this.router = express.Router(); | ||
// compression (must be set before serve-static) | ||
// @note: we use express() instead of express.Router() because all 404 and | ||
// error stuff is handled by default | ||
this.router = express(); | ||
// compression (must be set before express.static()) | ||
this.router.use(compression()); | ||
@@ -323,2 +329,6 @@ | ||
this._auditState = null; | ||
/** @private */ | ||
this._pendingConnectionTokens = new Set(); | ||
/** @private */ | ||
this._trustedClients = new Set(); | ||
@@ -368,8 +378,21 @@ // register audit state schema | ||
if (this.config.env.auth) { | ||
const ids = idGenerator(); | ||
const soundworksAuth = (req, res, next) => { | ||
let role = null; | ||
const isProtected = this.config.env.auth.clients | ||
.map(type => req.path.endsWith(`/${type}`)) | ||
.reduce((acc, value) => acc || value, false); | ||
for (let [_role, config] of Object.entries(this.config.app.clients)) { | ||
if (req.path === config.route) { | ||
role = _role; | ||
} | ||
} | ||
// route that are not client entry points just pass through the middleware | ||
if (role === null) { | ||
next(); | ||
return; | ||
} | ||
const isProtected = this.isProtected(role); | ||
if (isProtected) { | ||
@@ -385,6 +408,22 @@ // authentication middleware | ||
// -> access granted... | ||
// generate token for web socket to check connections | ||
const id = ids.next().value; | ||
const ip = req.ip; | ||
const time = getTime(); | ||
const token = { id, ip, time }; | ||
const encryptedToken = encryptData(token); | ||
this._pendingConnectionTokens.add(encryptedToken); | ||
setTimeout(() => { | ||
this._pendingConnectionTokens.delete(encryptedToken); | ||
}, TOKEN_VALID_DURATION * 1000); | ||
// pass to the response object to be send to the client | ||
res.swToken = encryptedToken; | ||
return next(); | ||
} | ||
// -> access denied... | ||
// show login / password modal | ||
res.writeHead(401, { | ||
@@ -394,5 +433,6 @@ 'WWW-Authenticate':'Basic', | ||
}); | ||
res.end('Authentication required.'); | ||
} else { | ||
// route not protected | ||
// route is not protected | ||
return next(); | ||
@@ -607,3 +647,3 @@ } | ||
this.config.env.websockets, | ||
(role, socket) => this._onSocketConnection(role, socket), | ||
(...args) => this._onSocketConnection(...args), | ||
); | ||
@@ -727,2 +767,4 @@ | ||
this.config.app.clients[role].route = route; | ||
// define template filename: `${role}.html` or `default.html` | ||
@@ -761,2 +803,8 @@ const { | ||
// if the client has gone through the connection middleware (add succedeed), | ||
// add the token to the data object | ||
if (res.swToken) { | ||
data.token = res.swToken; | ||
} | ||
// CORS / COOP / COEP headers for `crossOriginIsolated pages, | ||
@@ -776,2 +824,3 @@ // enables `sharedArrayBuffers` and high precision timers | ||
}; | ||
// http request | ||
@@ -788,8 +837,24 @@ router.get(route, soundworksClientHandler); | ||
*/ | ||
_onSocketConnection(role, socket) { | ||
_onSocketConnection(role, socket, connectionToken) { | ||
const client = new Client(role, socket); | ||
const roles = Object.keys(this.config.app.clients); | ||
// this has been validated | ||
if (this.isProtected(role) && this.isValidConnectionToken(connectionToken)) { | ||
const { ip } = decryptData(connectionToken); | ||
const newData = { | ||
ip: ip, | ||
id: client.id, | ||
}; | ||
const newToken = encryptData(newData); | ||
client.token = newToken; | ||
this._pendingConnectionTokens.delete(connectionToken); | ||
this._trustedClients.add(client); | ||
} | ||
socket.addListener('close', async () => { | ||
// do nothin if client type was invalid | ||
// do nothing if client role is invalid | ||
if (roles.includes(role)) { | ||
@@ -801,2 +866,6 @@ // decrement audit state counter | ||
// delete token | ||
if (this._trustedClients.has(client)) { | ||
this._trustedClients.delete(client); | ||
} | ||
// clean context manager, await before cleaning state manager | ||
@@ -854,4 +923,4 @@ await this.contextManager.removeClient(client); | ||
const { id, uuid } = client; | ||
socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid }); | ||
const { id, uuid, token } = client; | ||
socket.send(CLIENT_HANDSHAKE_RESPONSE, { id, uuid, token }); | ||
}); | ||
@@ -933,2 +1002,23 @@ } | ||
useDefaultApplicationTemplate() { | ||
const buildDirectory = path.join('.build', 'public'); | ||
const useMinifiedFile = {}; | ||
const roles = Object.keys(this.config.app.clients); | ||
roles.forEach(role => { | ||
if (this.config.env.type === 'production') { | ||
// check if minified file exists | ||
const minifiedFilePath = path.join(buildDirectory, `${role}.min.js`); | ||
if (fs.existsSync(minifiedFilePath)) { | ||
useMinifiedFile[role] = true; | ||
} else { | ||
console.log(chalk.yellow(` > Minified file not found for client "${role}", falling back to normal build file (use \`npm run build:production && npm start\` to use minified files)`)); | ||
useMinifiedFile[role] = false; | ||
} | ||
} else { | ||
useMinifiedFile[role] = false; | ||
} | ||
}); | ||
this._applicationTemplateOptions = { | ||
@@ -948,2 +1038,3 @@ templateEngine: { compile }, | ||
subpath: config.env.subpath, | ||
useMinifiedFile: useMinifiedFile[role], | ||
}, | ||
@@ -955,3 +1046,3 @@ }; | ||
this.router.use(express.static('public')); | ||
this.router.use('/build', express.static(path.join('.build', 'public'))); | ||
this.router.use('/build', express.static(buildDirectory)); | ||
} | ||
@@ -992,4 +1083,79 @@ | ||
} | ||
/** @private */ | ||
isProtected(role) { | ||
if (this.config.env.auth && Array.isArray(this.config.env.auth.clients)) { | ||
return this.config.env.auth.clients.includes(role); | ||
} | ||
return false; | ||
} | ||
/** @private */ | ||
isValidConnectionToken(token) { | ||
// token should be in pending token list | ||
if (!this._pendingConnectionTokens.has(token)) { | ||
return false; | ||
} | ||
// check the token is not too old | ||
const data = decryptData(token); | ||
const now = getTime(); | ||
// token is valid only for 30 seconds (this is arbitrary) | ||
if (now > data.time + TOKEN_VALID_DURATION) { | ||
// delete the token, is too old | ||
this._pendingConnectionTokens.delete(token); | ||
return false; | ||
} else { | ||
return true; | ||
} | ||
} | ||
/** | ||
* Check if the given client is trusted, i.e. config.env.type == 'production' | ||
* and the client is protected behind a password. | ||
* | ||
* @param {server.Client} client - Client to be tested | ||
* @returns {Boolean} | ||
*/ | ||
isTrustedClient(client) { | ||
if (this.config.env.type !== 'production') { | ||
return true; | ||
} else { | ||
return this._trustedClients.has(client); | ||
} | ||
} | ||
/** | ||
* Check if the token from a client is trusted, i.e. config.env.type == 'production' | ||
* and the client is protected behind a password. | ||
* | ||
* @param {Number} clientId - Id of the client | ||
* @param {Number} clientIp - Ip of the client | ||
* @param {String} token - Token to be tested | ||
* @returns {Boolean} | ||
*/ | ||
// for stateless interactions, e.g. POST files | ||
isTrustedToken(clientId, clientIp, token) { | ||
if (this.config.env.type !== 'production') { | ||
return true; | ||
} else { | ||
for (let client of this._trustedClients) { | ||
if (client.id === clientId && client.token === token) { | ||
// check that given token is consistent with client ip and id | ||
const { id, ip } = decryptData(client.token); | ||
if (clientId === id && clientIp === ip) { | ||
return true; | ||
} | ||
} | ||
} | ||
return false; | ||
} | ||
} | ||
} | ||
export default Server; |
@@ -5,5 +5,5 @@ import { Worker } from 'node:worker_threads'; | ||
import querystring from 'querystring'; | ||
import { WebSocketServer } from 'ws'; | ||
import WebSocket from 'ws'; | ||
import querystring from 'querystring'; | ||
@@ -73,3 +73,3 @@ import Socket from './Socket.js'; | ||
this._wss = new WebSocketServer({ | ||
server: server.httpServer, | ||
noServer: true, | ||
path: `/${config.path}`, // @note - update according to existing config files (aka cosima-apps) | ||
@@ -80,3 +80,3 @@ }); | ||
const queryString = querystring.decode(req.url.split('?')[1]); | ||
const { role, key } = queryString; | ||
const { role, key, token } = queryString; | ||
const binary = !!(parseInt(queryString.binary)); | ||
@@ -102,5 +102,26 @@ | ||
onConnectionCallback(role, socket); | ||
onConnectionCallback(role, socket, token); | ||
} | ||
}); | ||
// check if client can connect | ||
server.httpServer.on('upgrade', async (req, socket, head) => { | ||
const queryString = querystring.decode(req.url.split('?')[1]); | ||
const { role, token } = queryString; | ||
if (server.isProtected(role)) { | ||
// we don't have any ip in the upgrade request, so we just check the | ||
// connection token is pending | ||
const allowed = server.isValidConnectionToken(token); | ||
if (!allowed) { | ||
socket.destroy('not allowed'); | ||
} | ||
} | ||
this._wss.handleUpgrade(req, socket, head, (ws) => { | ||
this._wss.emit('connection', ws, req); | ||
}); | ||
}); | ||
} | ||
@@ -107,0 +128,0 @@ |
@@ -362,3 +362,5 @@ import EventEmitter from 'node:events'; | ||
this._observers.forEach(observer => { | ||
observer.transport.emit(OBSERVE_NOTIFICATION, [schemaName, stateId, nodeId]); | ||
if (observer.id !== nodeId) { | ||
observer.transport.emit(OBSERVE_NOTIFICATION, schemaName, stateId, nodeId); | ||
} | ||
}); | ||
@@ -432,3 +434,3 @@ } catch (err) { | ||
// (e.g. do not propagate infos about audit state) | ||
if (!PRIVATE_STATES.includes(schemaName)) { | ||
if (!PRIVATE_STATES.includes(schemaName) && _creatorId !== client.id) { | ||
statesInfos.push([schemaName, id, _creatorId]); | ||
@@ -435,0 +437,0 @@ } |
352109
109
9097
+ Addedcross-fetch@^3.1.5
+ Addedcross-fetch@3.1.8(transitive)
+ Addednode-fetch@2.7.0(transitive)
+ Addedtr46@0.0.3(transitive)
+ Addedwebidl-conversions@3.0.1(transitive)
+ Addedwhatwg-url@5.0.0(transitive)
- Removedserve-static@^1.15.0