@data-fair/lib-node 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/axios-auth.d.ts +12 -0
- package/axios-auth.js +34 -0
- package/axios.d.ts +4 -0
- package/axios.js +53 -0
- package/http-agents.d.ts +3 -0
- package/http-agents.js +55 -0
- package/locks.d.ts +5 -0
- package/locks.js +72 -0
- package/mongo-types.d.ts +1 -0
- package/mongo-types.js +1 -0
- package/mongo.d.ts +17 -0
- package/mongo.js +63 -0
- package/observer.d.ts +5 -0
- package/observer.js +83 -0
- package/package.json +37 -0
- package/test-module-hooks.d.ts +1 -0
- package/test-module-hooks.js +16 -0
- package/test-spies.d.ts +3 -0
- package/test-spies.js +28 -0
- package/upgrade-scripts.d.ts +7 -0
- package/upgrade-scripts.js +68 -0
- package/validation.d.ts +8 -0
- package/validation.js +44 -0
- package/ws-client.d.ts +32 -0
- package/ws-client.js +97 -0
- package/ws-emitter.d.ts +4 -0
- package/ws-emitter.js +18 -0
package/axios-auth.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { AxiosInstance } from 'axios';
|
|
2
|
+
export interface AxiosAuthOptions {
|
|
3
|
+
email: string;
|
|
4
|
+
password: string;
|
|
5
|
+
org?: string;
|
|
6
|
+
dep?: string;
|
|
7
|
+
adminMode?: boolean;
|
|
8
|
+
directoryUrl?: string;
|
|
9
|
+
axiosOpts?: any;
|
|
10
|
+
}
|
|
11
|
+
export declare function axiosAuth(opts: AxiosAuthOptions): Promise<AxiosInstance>;
|
|
12
|
+
export default axiosAuth;
|
package/axios-auth.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// build axios instances with sessions on a simple-directory instance
|
|
2
|
+
// used for integration testing
|
|
3
|
+
import { Agent } from 'node:http';
|
|
4
|
+
import { axiosBuilder, axiosInstance } from './axios.js';
|
|
5
|
+
export async function axiosAuth(opts) {
|
|
6
|
+
const body = { email: opts.email, password: opts.password };
|
|
7
|
+
if (opts.org)
|
|
8
|
+
body.org = opts.org;
|
|
9
|
+
if (opts.dep)
|
|
10
|
+
body.dep = opts.dep;
|
|
11
|
+
if (opts.adminMode)
|
|
12
|
+
body.adminMode = opts.adminMode;
|
|
13
|
+
const axiosOpts = {
|
|
14
|
+
httpAgent: new Agent({ keepAlive: false }),
|
|
15
|
+
...opts.axiosOpts
|
|
16
|
+
};
|
|
17
|
+
const directoryUrl = opts.directoryUrl ?? 'http://localhost:8080';
|
|
18
|
+
let callbackUrl = (await axiosInstance.post(directoryUrl + '/api/auth/password', body, { params: { redirect: directoryUrl }, maxRedirects: 0 })).data;
|
|
19
|
+
if (callbackUrl.startsWith(directoryUrl + '/simple-directory')) {
|
|
20
|
+
callbackUrl = callbackUrl.replace(directoryUrl + '/simple-directory', directoryUrl);
|
|
21
|
+
}
|
|
22
|
+
try {
|
|
23
|
+
await axiosInstance.get(callbackUrl, { maxRedirects: 0 });
|
|
24
|
+
}
|
|
25
|
+
catch (err) {
|
|
26
|
+
if (err.status !== 302)
|
|
27
|
+
throw err;
|
|
28
|
+
axiosOpts.headers = axiosOpts.headers ?? {};
|
|
29
|
+
axiosOpts.headers.common = axiosOpts.headers.common ?? {};
|
|
30
|
+
axiosOpts.headers.common.Cookie = err.headers['set-cookie'].map((s) => s.split(';')[0]).join(';');
|
|
31
|
+
}
|
|
32
|
+
return axiosBuilder(axiosOpts);
|
|
33
|
+
}
|
|
34
|
+
export default axiosAuth;
|
package/axios.d.ts
ADDED
package/axios.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// prepare an axios instance with improved error management and some performance tuning for nodejs usage
|
|
2
|
+
import axios from 'axios';
|
|
3
|
+
import { httpAgent, httpsAgent } from './http-agents.js';
|
|
4
|
+
const { stackTraceLimit } = Error;
|
|
5
|
+
class AxiosRequestError extends Error {
|
|
6
|
+
constructor() {
|
|
7
|
+
super();
|
|
8
|
+
this.name = 'AxiosRequestError';
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
const requestInterceptor = (config) => {
|
|
12
|
+
// better stack traces
|
|
13
|
+
// see https://github.com/axios/axios/issues/2387#issuecomment-652242713
|
|
14
|
+
Error.stackTraceLimit = 0;
|
|
15
|
+
const errorContext = new AxiosRequestError();
|
|
16
|
+
Error.stackTraceLimit = stackTraceLimit;
|
|
17
|
+
Error.captureStackTrace(errorContext, requestInterceptor);
|
|
18
|
+
// @ts-ignore
|
|
19
|
+
config.errorContext = errorContext;
|
|
20
|
+
return config;
|
|
21
|
+
};
|
|
22
|
+
export function axiosBuilder(opts = {}) {
|
|
23
|
+
const ax = axios.create({
|
|
24
|
+
httpAgent,
|
|
25
|
+
httpsAgent,
|
|
26
|
+
...opts
|
|
27
|
+
});
|
|
28
|
+
ax.interceptors.request.use(requestInterceptor);
|
|
29
|
+
// shorter stack traces
|
|
30
|
+
ax.interceptors.response.use(response => response, error => {
|
|
31
|
+
if (!error.response) {
|
|
32
|
+
if (error.config?.errorContext?.stack)
|
|
33
|
+
error.stack += '\nRequest context:\n' + error.config?.errorContext?.stack;
|
|
34
|
+
return Promise.reject(error);
|
|
35
|
+
}
|
|
36
|
+
delete error.response.request;
|
|
37
|
+
error.response.config = { method: error.response.config.method, url: error.response.config.url, data: error.response.config.data };
|
|
38
|
+
if (error.response.config.data && error.response.config.data._writableState)
|
|
39
|
+
delete error.response.config.data;
|
|
40
|
+
if (error.response.data && error.response.data._readableState)
|
|
41
|
+
delete error.response.data;
|
|
42
|
+
let messageText = error.response.statusText;
|
|
43
|
+
if (error.response.data) {
|
|
44
|
+
messageText = typeof error.response.data === 'string' ? error.response.data : JSON.stringify(error.response.data);
|
|
45
|
+
}
|
|
46
|
+
error.response.message = `${error.response.status} - ${messageText}`;
|
|
47
|
+
Object.assign(error.config.errorContext, error.response);
|
|
48
|
+
return Promise.reject(error.config.errorContext);
|
|
49
|
+
});
|
|
50
|
+
return ax;
|
|
51
|
+
}
|
|
52
|
+
export const axiosInstance = axiosBuilder();
|
|
53
|
+
export default axiosInstance;
|
package/http-agents.d.ts
ADDED
package/http-agents.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
// use better DNS lookup than nodejs default and try to reduce number of socket openings
|
|
2
|
+
import CacheableLookup from 'cacheable-lookup';
|
|
3
|
+
import HttpAgent, { HttpsAgent } from 'agentkeepalive';
|
|
4
|
+
import { Counter, Gauge } from 'prom-client';
|
|
5
|
+
// not very high number of sockets but we don't want to saturate our reverse proxies
|
|
6
|
+
// if a higher number is needed the service should probably be scaled anyway
|
|
7
|
+
const keepaliveOpts = {
|
|
8
|
+
maxSockets: 8,
|
|
9
|
+
maxFreeSockets: 8,
|
|
10
|
+
timeout: 60000,
|
|
11
|
+
freeSocketTimeout: 30000
|
|
12
|
+
};
|
|
13
|
+
export const httpAgent = new HttpAgent(keepaliveOpts);
|
|
14
|
+
export const httpsAgent = new HttpsAgent(keepaliveOpts);
|
|
15
|
+
const cacheableLookup = new CacheableLookup();
|
|
16
|
+
cacheableLookup.install(httpAgent);
|
|
17
|
+
cacheableLookup.install(httpsAgent);
|
|
18
|
+
// monitor agent statuses
|
|
19
|
+
const agents = { http: httpAgent, https: httpsAgent };
|
|
20
|
+
const socketsGauge = new Gauge({
|
|
21
|
+
name: 'df_http_agent_sockets',
|
|
22
|
+
help: 'Number of open sockets in the http agent',
|
|
23
|
+
labelNames: ['protocol', 'host']
|
|
24
|
+
});
|
|
25
|
+
const freeSocketsGauge = new Gauge({
|
|
26
|
+
name: 'df_http_agent_free_sockets',
|
|
27
|
+
help: 'Number of free sockets in the http agent',
|
|
28
|
+
labelNames: ['protocol', 'host']
|
|
29
|
+
});
|
|
30
|
+
const createSocketCounter = new Counter({
|
|
31
|
+
name: 'df_http_agent_create_socket_total',
|
|
32
|
+
help: 'Total number of sockets created by the http agent',
|
|
33
|
+
labelNames: ['protocol']
|
|
34
|
+
});
|
|
35
|
+
const requestCounter = new Counter({
|
|
36
|
+
name: 'df_http_agent_request_total',
|
|
37
|
+
help: 'Total number of requests created by the http agent',
|
|
38
|
+
labelNames: ['protocol']
|
|
39
|
+
});
|
|
40
|
+
setInterval(() => {
|
|
41
|
+
for (const _protocol of ['http', 'https']) {
|
|
42
|
+
const protocol = _protocol;
|
|
43
|
+
const agentStatus = agents[protocol].getCurrentStatus();
|
|
44
|
+
for (const host in agentStatus.sockets) {
|
|
45
|
+
socketsGauge.set({ protocol, host }, agentStatus.sockets[host]);
|
|
46
|
+
}
|
|
47
|
+
for (const host in agentStatus.freeSockets) {
|
|
48
|
+
freeSocketsGauge.set({ protocol, host }, agentStatus.freeSockets[host]);
|
|
49
|
+
}
|
|
50
|
+
createSocketCounter.remove({ protocol });
|
|
51
|
+
createSocketCounter.inc({ protocol }, agentStatus.createSocketCount);
|
|
52
|
+
requestCounter.remove({ protocol });
|
|
53
|
+
requestCounter.inc({ protocol }, agentStatus.requestCount);
|
|
54
|
+
}
|
|
55
|
+
}, 60000);
|
package/locks.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import type { Db } from 'mongodb';
|
|
2
|
+
export declare const init: (db: Db, ttl?: number) => Promise<void>;
|
|
3
|
+
export declare const stop: () => Promise<void>;
|
|
4
|
+
export declare function acquire(_id: string, origin?: string): Promise<boolean>;
|
|
5
|
+
export declare const release: (_id: string, delay?: number) => Promise<void>;
|
package/locks.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { hostname } from 'node:os';
|
|
2
|
+
import { randomBytes } from 'node:crypto';
|
|
3
|
+
import Debug from 'debug';
|
|
4
|
+
const debug = Debug('locks');
|
|
5
|
+
const pid = randomBytes(8).toString('hex');
|
|
6
|
+
debug('locks with pid', pid);
|
|
7
|
+
let interval;
|
|
8
|
+
let _db;
|
|
9
|
+
const collection = () => {
|
|
10
|
+
if (!_db)
|
|
11
|
+
throw new Error('locks utils was not initialized');
|
|
12
|
+
return _db?.collection('locks');
|
|
13
|
+
};
|
|
14
|
+
export const init = async (db, ttl = 60) => {
|
|
15
|
+
_db = db;
|
|
16
|
+
const locks = collection();
|
|
17
|
+
await locks.createIndex({ pid: 1 });
|
|
18
|
+
try {
|
|
19
|
+
await locks.createIndex({ updatedAt: 1 }, { expireAfterSeconds: ttl });
|
|
20
|
+
}
|
|
21
|
+
catch (err) {
|
|
22
|
+
console.log('Failure to create TTL index. Probably because the value changed. Try to update it.');
|
|
23
|
+
db.command({ collMod: 'locks', index: { keyPattern: { updatedAt: 1 }, expireAfterSeconds: ttl } });
|
|
24
|
+
}
|
|
25
|
+
// prolongate lock acquired by this process while it is still active
|
|
26
|
+
interval = setInterval(() => {
|
|
27
|
+
locks.updateMany({ pid }, { $currentDate: { updatedAt: true } });
|
|
28
|
+
}, (ttl / 2) * 1000);
|
|
29
|
+
};
|
|
30
|
+
export const stop = async () => {
|
|
31
|
+
clearInterval(interval);
|
|
32
|
+
if (_db)
|
|
33
|
+
await collection().deleteMany({ pid });
|
|
34
|
+
};
|
|
35
|
+
export async function acquire(_id, origin) {
|
|
36
|
+
if (!_db)
|
|
37
|
+
throw new Error('locks not initialized');
|
|
38
|
+
debug('acquire', _id, origin);
|
|
39
|
+
const locks = collection();
|
|
40
|
+
try {
|
|
41
|
+
await locks.insertOne({ _id, pid, origin, hostname: hostname(), createdAt: new Date() });
|
|
42
|
+
try {
|
|
43
|
+
await locks.updateOne({ _id }, { $currentDate: { updatedAt: true } });
|
|
44
|
+
}
|
|
45
|
+
catch (err) {
|
|
46
|
+
await locks.deleteOne({ _id, pid });
|
|
47
|
+
throw err;
|
|
48
|
+
}
|
|
49
|
+
debug('acquire ok', _id);
|
|
50
|
+
return true;
|
|
51
|
+
}
|
|
52
|
+
catch (err) {
|
|
53
|
+
if (err.code !== 11000)
|
|
54
|
+
throw err;
|
|
55
|
+
// duplicate means the lock was already acquired
|
|
56
|
+
debug('acquire ko', _id);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
export const release = async (_id, delay = 0) => {
|
|
61
|
+
if (!_db)
|
|
62
|
+
throw new Error('locks not initialized');
|
|
63
|
+
debug('release', _id);
|
|
64
|
+
const locks = collection();
|
|
65
|
+
if (delay) {
|
|
66
|
+
const date = new Date((new Date()).getTime() + delay);
|
|
67
|
+
await locks.updateOne({ _id, pid }, { $unset: { pid: 1 }, $set: { delayed: true, updatedAt: date } });
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
await locks.deleteOne({ _id, pid });
|
|
71
|
+
}
|
|
72
|
+
};
|
package/mongo-types.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/mongo-types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/mongo.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { IndexSpecification, CreateIndexesOptions, MongoClientOptions } from 'mongodb';
|
|
2
|
+
import { MongoClient } from 'mongodb';
|
|
3
|
+
export type IndexDefinitions = Record<string, Record<string, IndexSpecification | [IndexSpecification, CreateIndexesOptions]>>;
|
|
4
|
+
export declare class Mongo {
|
|
5
|
+
private _client;
|
|
6
|
+
get client(): MongoClient;
|
|
7
|
+
get db(): import("mongodb").Db;
|
|
8
|
+
connect: (mongoUrl: string, options?: MongoClientOptions) => Promise<void>;
|
|
9
|
+
ensureIndex: (collection: string, key: IndexSpecification, options?: CreateIndexesOptions) => Promise<void>;
|
|
10
|
+
/**
|
|
11
|
+
* create a bunch of indexes and overtwrite previous definitions
|
|
12
|
+
* the structure forces giving names to indexes which is better for managing them properly
|
|
13
|
+
*/
|
|
14
|
+
configure: (indexDefinitions: IndexDefinitions) => Promise<void>;
|
|
15
|
+
}
|
|
16
|
+
declare const mongo: Mongo;
|
|
17
|
+
export default mongo;
|
package/mongo.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { MongoClient, MongoError } from 'mongodb';
|
|
2
|
+
export class Mongo {
|
|
3
|
+
_client;
|
|
4
|
+
get client() {
|
|
5
|
+
if (!this._client)
|
|
6
|
+
throw new Error('db was not connected');
|
|
7
|
+
return this._client;
|
|
8
|
+
}
|
|
9
|
+
get db() {
|
|
10
|
+
return this.client.db();
|
|
11
|
+
}
|
|
12
|
+
connect = async (mongoUrl, options = {}) => {
|
|
13
|
+
if (this._client) {
|
|
14
|
+
console.warn('db already connected');
|
|
15
|
+
return;
|
|
16
|
+
}
|
|
17
|
+
console.log('connecting to mongodb...');
|
|
18
|
+
this._client = await MongoClient.connect(mongoUrl, { maxPoolSize: 5, ignoreUndefined: true, ...options });
|
|
19
|
+
console.log('...ok');
|
|
20
|
+
};
|
|
21
|
+
// Create an index if it does not exist and manage overwriting existing and conflicting index definitions
|
|
22
|
+
ensureIndex = async (collection, key, options = {}) => {
|
|
23
|
+
try {
|
|
24
|
+
await this.db.collection(collection).createIndex(key, options);
|
|
25
|
+
}
|
|
26
|
+
catch (err) {
|
|
27
|
+
if (options.name && err instanceof MongoError && (err.code === 85 || err.code === 86)) {
|
|
28
|
+
// if the error is a conflict on keys or params of the index we automatically
|
|
29
|
+
// delete then recreate the index
|
|
30
|
+
console.log(`drop then recreate index ${collection}/${options.name}`);
|
|
31
|
+
await this.db.collection(collection).dropIndex(options.name);
|
|
32
|
+
await this.db.collection(collection).createIndex(key, options);
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
throw err;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
/**
|
|
40
|
+
* create a bunch of indexes and overtwrite previous definitions
|
|
41
|
+
* the structure forces giving names to indexes which is better for managing them properly
|
|
42
|
+
*/
|
|
43
|
+
configure = async (indexDefinitions) => {
|
|
44
|
+
for (const collectionName in indexDefinitions) {
|
|
45
|
+
for (const indexName in indexDefinitions[collectionName]) {
|
|
46
|
+
let key, options;
|
|
47
|
+
if (Array.isArray(indexDefinitions[collectionName][indexName])) {
|
|
48
|
+
[key, options] = indexDefinitions[collectionName][indexName];
|
|
49
|
+
if (options.name && options.name !== indexName)
|
|
50
|
+
throw new Error(`inconsistent index name ${indexName} or ${options.name}`);
|
|
51
|
+
options.name = indexName;
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
key = indexDefinitions[collectionName][indexName];
|
|
55
|
+
options = { name: indexName };
|
|
56
|
+
}
|
|
57
|
+
await this.ensureIndex(collectionName, key, options);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const mongo = new Mongo();
|
|
63
|
+
export default mongo;
|
package/observer.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { Registry } from 'prom-client';
|
|
2
|
+
export declare const servicePromRegistry: Registry<"text/plain; version=0.0.4; charset=utf-8">;
|
|
3
|
+
export declare const internalError: (errorCode: string, error: any, ...optionalParams: any) => void;
|
|
4
|
+
export declare const startObserver: (port?: number) => Promise<void>;
|
|
5
|
+
export declare const stopObserver: () => Promise<void>;
|
package/observer.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
// very light web server to expose prometheus metrics, export a cpu profile and maybe other incoming obervability features
|
|
2
|
+
import { hostname } from 'node:os';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { Registry, Counter, register, collectDefaultMetrics } from 'prom-client';
|
|
5
|
+
import eventPromise from '@data-fair/lib-utils/event-promise.js';
|
|
6
|
+
// a special registry for metrics that are global
|
|
7
|
+
// not based on this specific process state but instead on the content of db for example
|
|
8
|
+
export const servicePromRegistry = new Registry();
|
|
9
|
+
collectDefaultMetrics();
|
|
10
|
+
// local metrics incremented throughout the code
|
|
11
|
+
const internalErrorCounter = new Counter({
|
|
12
|
+
name: 'df_internal_error',
|
|
13
|
+
help: 'A counter of errors from any service, worker, etc. Do not use for client errors, only for anomalies that should trigger alerts. Each increment should be accompanied by an error log with matching code.',
|
|
14
|
+
labelNames: ['errorCode']
|
|
15
|
+
});
|
|
16
|
+
export const internalError = (errorCode, error, ...optionalParams) => {
|
|
17
|
+
internalErrorCounter.inc({ errorCode });
|
|
18
|
+
const message = error.message || error;
|
|
19
|
+
console.error(`[${errorCode}] ${message}`, ...optionalParams);
|
|
20
|
+
if (error.stack)
|
|
21
|
+
console.error(error.stack);
|
|
22
|
+
};
|
|
23
|
+
/**
|
|
24
|
+
* @param {import('node:http').ServerResponse} res
|
|
25
|
+
* @param {any} err
|
|
26
|
+
*/
|
|
27
|
+
const httpErrorHandler = (res, err) => {
|
|
28
|
+
console.error('failed to server prometheus /metrics', err);
|
|
29
|
+
res.writeHead(500);
|
|
30
|
+
res.end();
|
|
31
|
+
};
|
|
32
|
+
const serveRegistry = async (res, registry) => {
|
|
33
|
+
const metrics = await registry.metrics();
|
|
34
|
+
res.setHeader('Content-Type', registry.contentType);
|
|
35
|
+
res.writeHead(200);
|
|
36
|
+
res.write(metrics);
|
|
37
|
+
res.end();
|
|
38
|
+
};
|
|
39
|
+
// live CPU performance inspection
|
|
40
|
+
const serveCPUProfile = async (res, duration = 2000) => {
|
|
41
|
+
// @ts-ignore
|
|
42
|
+
const { Session } = await import('node:inspector/promises');
|
|
43
|
+
const session = new Session();
|
|
44
|
+
session.connect();
|
|
45
|
+
await session.post('Profiler.enable');
|
|
46
|
+
await session.post('Profiler.start');
|
|
47
|
+
await new Promise(resolve => setTimeout(resolve, duration));
|
|
48
|
+
const { profile } = await session.post('Profiler.stop');
|
|
49
|
+
session.disconnect();
|
|
50
|
+
res.setHeader('Content-Disposition', `attachment; filename="data-fair-${hostname()}-${new Date().toISOString()}.cpuprofile"`);
|
|
51
|
+
res.write(JSON.stringify(profile));
|
|
52
|
+
res.end();
|
|
53
|
+
};
|
|
54
|
+
let server;
|
|
55
|
+
export const startObserver = async (port = 9090) => {
|
|
56
|
+
server = createServer((req, res) => {
|
|
57
|
+
if (req.method === 'GET' && req.url === '/metrics') {
|
|
58
|
+
serveRegistry(res, register).catch(err => httpErrorHandler(res, err));
|
|
59
|
+
}
|
|
60
|
+
else if (req.method === 'GET' && (req.url === '/global-metrics' || req.url === '/service-metrics')) {
|
|
61
|
+
serveRegistry(res, servicePromRegistry).catch(err => httpErrorHandler(res, err));
|
|
62
|
+
}
|
|
63
|
+
else if (req.method === 'GET' && req.url === '/cpu-profile') {
|
|
64
|
+
// TODO: duration
|
|
65
|
+
serveCPUProfile(res).catch(err => httpErrorHandler(res, err));
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
res.writeHead(404);
|
|
69
|
+
res.end();
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
server.listen(port);
|
|
73
|
+
await eventPromise(server, 'listening');
|
|
74
|
+
console.log(`Observer server available on http://localhost:${port}
|
|
75
|
+
GET /metrics -> get prometheus metrics for this instance
|
|
76
|
+
GET /service-metrics -> get prometheus metrics shared accross all instances
|
|
77
|
+
GET /cpu-profile -> generate and fetch a CPU profile
|
|
78
|
+
`);
|
|
79
|
+
};
|
|
80
|
+
export const stopObserver = async () => {
|
|
81
|
+
if (server)
|
|
82
|
+
server.close();
|
|
83
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@data-fair/lib-node",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Small nodejs related utility functions for the data-fair stack.",
|
|
5
|
+
"main": "index.js",
|
|
6
|
+
"files": [
|
|
7
|
+
"**/*.js",
|
|
8
|
+
"**/*.d.ts"
|
|
9
|
+
],
|
|
10
|
+
"author": "",
|
|
11
|
+
"license": "MIT",
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@data-fair/lib-utils": "0.2.0",
|
|
14
|
+
"agentkeepalive": "^4.5.0",
|
|
15
|
+
"ajv-i18n": "^4.2.0",
|
|
16
|
+
"axios": "^1.7.7",
|
|
17
|
+
"cacheable-lookup": "^7.0.0",
|
|
18
|
+
"semver": "^7.6.3"
|
|
19
|
+
},
|
|
20
|
+
"peerDependencies": {
|
|
21
|
+
"mongodb": "^6.9.0",
|
|
22
|
+
"prom-client": "^15.1.3",
|
|
23
|
+
"ws": "^8.18.0"
|
|
24
|
+
},
|
|
25
|
+
"peerDependenciesMeta": {
|
|
26
|
+
"mongodb": {
|
|
27
|
+
"optional": true
|
|
28
|
+
},
|
|
29
|
+
"prom-client": {
|
|
30
|
+
"optional": true
|
|
31
|
+
},
|
|
32
|
+
"ws": {
|
|
33
|
+
"optional": true
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"type": "module"
|
|
37
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function load(url: any, context: any, nextLoad: any): Promise<any>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// @ts-nocheck
|
|
2
|
+
// cf https://nodejs.org/api/module.html#customization-hooks
|
|
3
|
+
const regexp = /\/\/ @test:spy\((.*)\)\n/g;
|
|
4
|
+
export async function load(url, context, nextLoad) {
|
|
5
|
+
const result = await nextLoad(url, context);
|
|
6
|
+
if (result.format === 'module' || result.format === 'module-typescript') {
|
|
7
|
+
let source = result.source.toString();
|
|
8
|
+
const matches = source.match(regexp);
|
|
9
|
+
if (matches) {
|
|
10
|
+
source = 'import { emit as __emitTestSpy } from "@data-fair/lib/node/test-spies.js"\n' + source;
|
|
11
|
+
source = source.replace(regexp, '__emitTestSpy($1)\n');
|
|
12
|
+
result.source = Buffer.from(source, 'utf-8');
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
return result;
|
|
16
|
+
}
|
package/test-spies.d.ts
ADDED
package/test-spies.js
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { register } from 'node:module';
|
|
2
|
+
export function registerModuleHooks() {
|
|
3
|
+
register('@data-fair/lib/node/test-module-hooks.js', { parentURL: import.meta.url });
|
|
4
|
+
}
|
|
5
|
+
const pendingPromises = {};
|
|
6
|
+
let i = 0;
|
|
7
|
+
export function waitFor(eventName, timeout = 2000) {
|
|
8
|
+
i++;
|
|
9
|
+
const id = i;
|
|
10
|
+
const p = new Promise((resolve, reject) => {
|
|
11
|
+
pendingPromises[eventName] = pendingPromises[eventName] ?? [];
|
|
12
|
+
pendingPromises[eventName].push({ resolve, id });
|
|
13
|
+
setTimeout(() => reject(new Error(`Timeout waiting for test spy ${eventName}`)), timeout);
|
|
14
|
+
});
|
|
15
|
+
p.finally(() => {
|
|
16
|
+
pendingPromises[eventName] = pendingPromises[eventName].filter(p => p.id !== id);
|
|
17
|
+
if (pendingPromises[eventName].length === 0)
|
|
18
|
+
delete pendingPromises[eventName];
|
|
19
|
+
});
|
|
20
|
+
return p;
|
|
21
|
+
}
|
|
22
|
+
export function emit(eventName, data) {
|
|
23
|
+
if (!pendingPromises[eventName])
|
|
24
|
+
return;
|
|
25
|
+
for (const p of pendingPromises[eventName]) {
|
|
26
|
+
p.resolve(data);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import semver from 'semver';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import { readdirSync, readFileSync } from 'fs';
|
|
4
|
+
import Debug from 'debug';
|
|
5
|
+
import { acquire, release } from './locks.js';
|
|
6
|
+
const debug = Debug('upgrade');
|
|
7
|
+
// chose the proper scripts to execute, then run them
|
|
8
|
+
export default async function (db, basePath = './') {
|
|
9
|
+
const ack = await acquire('upgrade');
|
|
10
|
+
if (!ack) {
|
|
11
|
+
console.warn('upgrade scripts lock is already acquired, skip them');
|
|
12
|
+
// IMPORTANT: this behaviour of running the process when the upgrade scripts are still running on another one
|
|
13
|
+
// implies that they cannot be considered a pre-requisite..
|
|
14
|
+
// if we want to consider the upgrade scripts as a pre-requisite we should implement a wait on all
|
|
15
|
+
// containers for the scripts that are running in only 1 (while loop on "acquire" ?) and a healthcheck
|
|
16
|
+
// are not considered "up" and the previous versions keep running in the mean time
|
|
17
|
+
}
|
|
18
|
+
else {
|
|
19
|
+
await runScripts(db, basePath);
|
|
20
|
+
await release('upgrade');
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
async function runScripts(db, basePath) {
|
|
24
|
+
const dir = path.resolve(basePath);
|
|
25
|
+
const pjsonPath = path.join(dir, 'package.json');
|
|
26
|
+
debug('read service info from ' + pjsonPath);
|
|
27
|
+
const pjson = JSON.parse(readFileSync(pjsonPath, 'utf8'));
|
|
28
|
+
const scriptsRoot = path.join(dir, 'upgrade');
|
|
29
|
+
debug(`service=${pjson.name} version=${pjson.version}`);
|
|
30
|
+
const services = db.collection('services');
|
|
31
|
+
const service = await services.findOne({ id: pjson.name });
|
|
32
|
+
const version = service?.version;
|
|
33
|
+
debug('list scripts from ' + scriptsRoot);
|
|
34
|
+
let scripts = await listScripts(scriptsRoot);
|
|
35
|
+
if (!version) {
|
|
36
|
+
debug('No service version found in database, this is probably a fresh install');
|
|
37
|
+
scripts = scripts.filter(scriptDef => scriptDef.version === 'init');
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
debug(`Current service version from database : ${version}`);
|
|
41
|
+
scripts = scripts.filter(scriptDef => scriptDef.version !== 'init');
|
|
42
|
+
}
|
|
43
|
+
for (const scriptDef of scripts) {
|
|
44
|
+
if (semver.gte(scriptDef.version, version)) {
|
|
45
|
+
for (const scriptName of scriptDef.names) {
|
|
46
|
+
const script = (await import(path.join(scriptsRoot, scriptDef.version, scriptName))).default;
|
|
47
|
+
debug('Apply script %s/%s : %s', scriptDef.version, scriptName, script.description);
|
|
48
|
+
await script.exec(db, Debug(`upgrade:${scriptDef.version}:${scriptName}`));
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const coercedVersion = semver.coerce(pjson.version);
|
|
53
|
+
if (!coercedVersion)
|
|
54
|
+
throw new Error(`Invalid version number in package.json ${pjson.version}`);
|
|
55
|
+
const newService = { id: pjson.name, version: coercedVersion.version };
|
|
56
|
+
debug(`Upgrade scripts are over, save current version number ${newService.version}`);
|
|
57
|
+
await services.updateOne({ id: pjson.name }, { $set: newService }, { upsert: true });
|
|
58
|
+
}
|
|
59
|
+
// Walk the scripts directories
|
|
60
|
+
async function listScripts(scriptsRoot) {
|
|
61
|
+
const dirs = readdirSync(scriptsRoot).sort();
|
|
62
|
+
return dirs.map(dir => {
|
|
63
|
+
return {
|
|
64
|
+
version: dir,
|
|
65
|
+
names: readdirSync(path.join(scriptsRoot, dir)).sort()
|
|
66
|
+
};
|
|
67
|
+
});
|
|
68
|
+
}
|
package/validation.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { ErrorObject, ValidateFunction } from 'ajv';
|
|
2
|
+
export declare const errorsText: (errors: ErrorObject[] | null | undefined, varName?: string) => string;
|
|
3
|
+
export type AssertValidOptions = {
|
|
4
|
+
lang?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
internal?: boolean;
|
|
7
|
+
};
|
|
8
|
+
export declare function assertValid<T>(validate: ValidateFunction, data: any, options?: AssertValidOptions): asserts data is T;
|
package/validation.js
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import ajvFrModule from 'ajv-i18n/localize/fr/index.js';
|
|
2
|
+
import ajvEnModule from 'ajv-i18n/localize/en/index.js';
|
|
3
|
+
import { httpError } from '@data-fair/lib-utils/http-errors.js';
|
|
4
|
+
const ajvFr = ajvFrModule;
|
|
5
|
+
const ajvEn = ajvEnModule;
|
|
6
|
+
const localize = {
|
|
7
|
+
fr: ajvFr,
|
|
8
|
+
en: ajvEn
|
|
9
|
+
};
|
|
10
|
+
// cf https://github.com/ajv-validator/ajv/blob/b3e0cb17d0e095b5c883042b2306571be5ec86b7/lib/core.ts#L650
|
|
11
|
+
export const errorsText = (errors, varName = 'data') => {
|
|
12
|
+
if (!errors || errors.length === 0)
|
|
13
|
+
return 'No errors';
|
|
14
|
+
return errors
|
|
15
|
+
.map((e) => {
|
|
16
|
+
let msg = `${varName}${e.instancePath} ${e.message}`.trim();
|
|
17
|
+
const paramKeys = Object.keys(e.params || {}).filter(key => {
|
|
18
|
+
if (key === 'error')
|
|
19
|
+
return false;
|
|
20
|
+
if (e.keyword === 'type' && key === 'type')
|
|
21
|
+
return false;
|
|
22
|
+
return true;
|
|
23
|
+
});
|
|
24
|
+
const params = paramKeys
|
|
25
|
+
.map(key => paramKeys.length > 1 ? `${key}=${e.params[key]}` : e.params[key])
|
|
26
|
+
.join(', ');
|
|
27
|
+
if (params)
|
|
28
|
+
msg += ` (${params})`;
|
|
29
|
+
return msg;
|
|
30
|
+
})
|
|
31
|
+
.reduce((text, msg) => text + ', ' + msg);
|
|
32
|
+
};
|
|
33
|
+
export function assertValid(validate, data, options = {}) {
|
|
34
|
+
const lang = options.lang ?? 'fr';
|
|
35
|
+
const name = options.name ?? 'data';
|
|
36
|
+
if (!validate(data)) {
|
|
37
|
+
(localize[lang] || localize.fr)(validate.errors);
|
|
38
|
+
const message = errorsText(validate.errors, name);
|
|
39
|
+
if (options.internal)
|
|
40
|
+
throw new Error(message);
|
|
41
|
+
else
|
|
42
|
+
throw httpError(400, message);
|
|
43
|
+
}
|
|
44
|
+
}
|
package/ws-client.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { type Message } from '@data-fair/lib-common-types/ws.js';
|
|
2
|
+
import { type Account } from '@data-fair/lib-common-types/session/index.js';
|
|
3
|
+
import EventEmitter from 'node:events';
|
|
4
|
+
type logFn = (msg: string, ...args: any[]) => void;
|
|
5
|
+
export interface WsClientOpts {
|
|
6
|
+
log?: {
|
|
7
|
+
info: logFn;
|
|
8
|
+
error: logFn;
|
|
9
|
+
debug: logFn;
|
|
10
|
+
};
|
|
11
|
+
url: string;
|
|
12
|
+
headers?: Record<string, string>;
|
|
13
|
+
apiKey?: string;
|
|
14
|
+
adminMode?: boolean;
|
|
15
|
+
account?: Account;
|
|
16
|
+
}
|
|
17
|
+
export type FullWsClientOpts = WsClientOpts & Required<Pick<WsClientOpts, 'log'>>;
|
|
18
|
+
export declare class WsClient extends EventEmitter {
|
|
19
|
+
private _channels;
|
|
20
|
+
private _ws;
|
|
21
|
+
opts: FullWsClientOpts;
|
|
22
|
+
constructor(opts: WsClientOpts);
|
|
23
|
+
private _connect;
|
|
24
|
+
private _reconnect;
|
|
25
|
+
subscribe(channel: string, force?: boolean, timeout?: number): Promise<void>;
|
|
26
|
+
waitFor(channel: string, filter?: (message: Message) => boolean, timeout?: number, skipSubscribe?: boolean, fullMessage?: boolean): Promise<Message>;
|
|
27
|
+
close(): void;
|
|
28
|
+
}
|
|
29
|
+
export declare class DataFairWsClient extends WsClient {
|
|
30
|
+
waitForJournal(datasetId: string, eventType: string, timeout?: number): Promise<Message>;
|
|
31
|
+
}
|
|
32
|
+
export {};
|
package/ws-client.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// a wrapper around a websocket connection with auto reconnect
|
|
2
|
+
// JSON parsing and channel subscriptions
|
|
3
|
+
import EventEmitter from 'node:events';
|
|
4
|
+
import WebSocket from 'ws';
|
|
5
|
+
import Debug from 'debug';
|
|
6
|
+
const debug = Debug('ws');
|
|
7
|
+
export class WsClient extends EventEmitter {
|
|
8
|
+
_channels;
|
|
9
|
+
_ws;
|
|
10
|
+
opts;
|
|
11
|
+
constructor(opts) {
|
|
12
|
+
super();
|
|
13
|
+
this._channels = [];
|
|
14
|
+
this.opts = { log: console, ...opts };
|
|
15
|
+
}
|
|
16
|
+
async _connect() {
|
|
17
|
+
return await new Promise((resolve, reject) => {
|
|
18
|
+
const wsUrl = this.opts.url.replace('http://', 'ws://').replace('https://', 'wss://') + '/';
|
|
19
|
+
debug(`connect Web Socket to ${wsUrl}`);
|
|
20
|
+
const ws = new WebSocket(wsUrl, { headers: this.opts.headers ?? {} });
|
|
21
|
+
this._ws = ws;
|
|
22
|
+
ws.on('error', (err) => {
|
|
23
|
+
debug('WS encountered an error', err.message);
|
|
24
|
+
this._reconnect();
|
|
25
|
+
reject(err);
|
|
26
|
+
});
|
|
27
|
+
ws.once('open', () => {
|
|
28
|
+
debug('WS is opened');
|
|
29
|
+
resolve(ws);
|
|
30
|
+
});
|
|
31
|
+
ws.on('message', (msg) => {
|
|
32
|
+
const message = JSON.parse(msg);
|
|
33
|
+
debug('received message', message);
|
|
34
|
+
this.emit('message', message);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
async _reconnect() {
|
|
39
|
+
if (!this._ws)
|
|
40
|
+
return;
|
|
41
|
+
debug('reconnect');
|
|
42
|
+
this._ws.terminate();
|
|
43
|
+
await this._connect();
|
|
44
|
+
for (const channel of this._channels) {
|
|
45
|
+
await this.subscribe(channel, true);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
async subscribe(channel, force = false, timeout = 2000) {
|
|
49
|
+
if (this._channels.includes(channel) && !force)
|
|
50
|
+
return;
|
|
51
|
+
const ws = this._ws ?? await this._connect();
|
|
52
|
+
debug('subscribe to channel', channel);
|
|
53
|
+
const subscribeMessage = { type: 'subscribe', channel };
|
|
54
|
+
if (this.opts.apiKey)
|
|
55
|
+
subscribeMessage.apiKey = this.opts.apiKey;
|
|
56
|
+
if (this.opts.adminMode && this.opts.account)
|
|
57
|
+
subscribeMessage.account = JSON.stringify(this.opts.account);
|
|
58
|
+
ws.send(JSON.stringify(subscribeMessage));
|
|
59
|
+
const event = await this.waitFor(channel, (e) => {
|
|
60
|
+
return e.type === 'subscribe-confirm' || e.type === 'error';
|
|
61
|
+
}, timeout, true, true);
|
|
62
|
+
if (event.type === 'error')
|
|
63
|
+
throw new Error(event.data);
|
|
64
|
+
if (this._channels.includes(channel))
|
|
65
|
+
this._channels.push(channel);
|
|
66
|
+
}
|
|
67
|
+
async waitFor(channel, filter, timeout = 300000, skipSubscribe = false, fullMessage = false) {
|
|
68
|
+
if (!skipSubscribe)
|
|
69
|
+
await this.subscribe(channel);
|
|
70
|
+
return await new Promise((resolve, reject) => {
|
|
71
|
+
const _timeout = setTimeout(() => { reject(new Error('timeout')); }, timeout);
|
|
72
|
+
const messageCb = (message) => {
|
|
73
|
+
if (message.channel === channel && (!filter || filter(fullMessage ? message : message.data))) {
|
|
74
|
+
clearTimeout(_timeout);
|
|
75
|
+
this.off('message', messageCb);
|
|
76
|
+
resolve(fullMessage ? message : message.data);
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
this.on('message', messageCb);
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
close() {
|
|
83
|
+
if (this._ws)
|
|
84
|
+
this._ws.terminate();
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
export class DataFairWsClient extends WsClient {
|
|
88
|
+
async waitForJournal(datasetId, eventType, timeout = 300000) {
|
|
89
|
+
await this.opts.log.info(`wait for event "${eventType}" on dataset "${datasetId}"`);
|
|
90
|
+
const event = await this.waitFor(`datasets/${datasetId}/journal`, (e) => {
|
|
91
|
+
return e.type === eventType || e.type === 'error';
|
|
92
|
+
}, timeout);
|
|
93
|
+
if (event.type === 'error')
|
|
94
|
+
throw new Error(event.data);
|
|
95
|
+
return event;
|
|
96
|
+
}
|
|
97
|
+
}
|
package/ws-emitter.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Db, Collection } from 'mongodb';
|
|
2
|
+
export declare const initMessagesCollection: (db: Db) => Promise<Collection<import("bson").Document>>;
|
|
3
|
+
export declare const init: (db: Db) => Promise<void>;
|
|
4
|
+
export declare const emit: (channel: string, data: any) => Promise<void>;
|
package/ws-emitter.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// write messages to a mongodb collection/queue for publication by ws-server.js and consumption by ws.js
|
|
2
|
+
// return a capped mongodb collection that acts as a simple queue
|
|
3
|
+
export const initMessagesCollection = async (db) => {
|
|
4
|
+
const collection = (await db.listCollections({ name: 'ws-messages' }).toArray())[0];
|
|
5
|
+
if (!collection)
|
|
6
|
+
await db.createCollection('ws-messages', { capped: true, size: 100000, max: 1000 });
|
|
7
|
+
return db.collection('ws-messages');
|
|
8
|
+
};
|
|
9
|
+
let messagesCollection;
|
|
10
|
+
export const init = async (db) => {
|
|
11
|
+
messagesCollection = await initMessagesCollection(db);
|
|
12
|
+
await messagesCollection.insertOne({ type: 'init' });
|
|
13
|
+
};
|
|
14
|
+
export const emit = async (channel, data) => {
|
|
15
|
+
if (!messagesCollection)
|
|
16
|
+
throw new Error('wsEmitter.init was not called before sending data');
|
|
17
|
+
await messagesCollection.insertOne({ type: 'message', channel, data, date: new Date().toISOString() });
|
|
18
|
+
};
|