feat: add socker connection between client and server

This commit is contained in:
Ahmed Bouhuolia
2024-02-24 00:18:48 +02:00
parent 1fd8a53ed1
commit 2d3544fe37
16 changed files with 357 additions and 27 deletions

View File

@@ -98,6 +98,7 @@
"reflect-metadata": "^0.1.13",
"rtl-detect": "^1.0.4",
"source-map-loader": "^4.0.1",
"socket.io": "^4.7.4",
"tmp-promise": "^3.0.3",
"ts-transformer-keys": "^0.4.2",
"tsyringe": "^4.3.0",

View File

@@ -3,6 +3,7 @@ import { PlaidApplication } from '@/services/Banking/Plaid/PlaidApplication';
import { Request, Response } from 'express';
import { Inject, Service } from 'typedi';
import BaseController from '../BaseController';
import { PlaidWebhookTenantBootMiddleware } from '@/services/Banking/Plaid/PlaidWebhookTenantBootMiddleware';
@Service()
export class Webhooks extends BaseController {
@@ -15,6 +16,7 @@ export class Webhooks extends BaseController {
router() {
const router = Router();
router.use(PlaidWebhookTenantBootMiddleware);
router.post('/plaid', this.plaidWebhooks.bind(this));
return router;
@@ -34,8 +36,6 @@ export class Webhooks extends BaseController {
item_id: plaidItemId,
} = req.body;
console.log(req.body, 'triggered');
await this.plaidApp.webhooks(
tenantId,
plaidItemId,

View File

@@ -5,6 +5,8 @@ import boom from 'express-boom';
import errorHandler from 'errorhandler';
import bodyParser from 'body-parser';
import fileUpload from 'express-fileupload';
import { Server } from 'socket.io';
import Container from 'typedi';
import routes from 'api';
import LoggerMiddleware from '@/api/middleware/LoggerMiddleware';
import AgendashController from '@/api/controllers/Agendash';
@@ -72,4 +74,32 @@ export default ({ app }) => {
app.use((req: Request, res: Response, next: NextFunction) => {
return res.boom.notFound();
});
const server = app.listen(app.get('port'), (err) => {
if (err) {
console.log(err);
process.exit(1);
return;
}
console.log(`
################################################
Server listening on port: ${app.get('port')}
################################################
`);
});
const io = new Server(server, {});
// Set socket.io listeners.
io.on('connection', (socket) => {
console.log('SOCKET CONNECTED');
socket.on('disconnect', () => {
console.log('SOCKET DISCONNECTED');
});
});
// Middleware to pass socket to each request object.
app.use((req: Request, res: Response, next: NextFunction) => {
req.io = io;
next();
});
Container.set('socket', io);
};

View File

@@ -10,19 +10,6 @@ async function startServer() {
// Intiialize all registered loaders.
await loadersFactory({ expressApp: app });
app.listen(app.get('port'), (err) => {
if (err) {
console.log(err);
process.exit(1);
return;
}
console.log(`
################################################
Server listening on port: ${app.get('port')}
################################################
`);
});
}
startServer();

View File

@@ -25,11 +25,15 @@ export class PlaidFetchTransactionsJob {
const plaidFetchTransactionsService = Container.get(
PlaidUpdateTransactions
);
const io = Container.get('socket');
try {
await plaidFetchTransactionsService.updateTransactions(
tenantId,
plaidItemId
);
// Notify the frontend to reflect the new transactions changes.
io.emit('NEW_TRANSACTIONS_DATA', { plaidItemId });
done();
} catch (error) {
console.log(error);

View File

@@ -7,6 +7,7 @@ import {
IPlaidItemCreatedEventPayload,
PlaidItemDTO,
} from '@/interfaces/Plaid';
import SystemPlaidItem from '@/system/models/SystemPlaidItem';
@Service()
export class PlaidItemService {
@@ -29,19 +30,23 @@ export class PlaidItemService {
const plaidInstance = new PlaidClientWrapper();
// exchange the public token for a private access token and store with the item.
// Exchange the public token for a private access token and store with the item.
const response = await plaidInstance.itemPublicTokenExchange({
public_token: publicToken,
});
const plaidAccessToken = response.data.access_token;
const plaidItemId = response.data.item_id;
// Store the Plaid item metadata on tenant scope.
const plaidItem = await PlaidItem.query().insertAndFetch({
tenantId,
plaidAccessToken,
plaidItemId,
plaidInstitutionId: institutionId,
});
// Stores the Plaid item id on system scope.
await SystemPlaidItem.query().insert({ tenantId, plaidItemId });
// Triggers `onPlaidItemCreated` event.
await this.eventPublisher.emitAsync(events.plaid.onItemCreated, {
tenantId,

View File

@@ -0,0 +1,32 @@
import { Request, Response, NextFunction } from 'express';
import { SystemPlaidItem, Tenant } from '@/system/models';
import tenantDependencyInjection from '@/api/middleware/TenantDependencyInjection';
export const PlaidWebhookTenantBootMiddleware = async (
req: Request,
res: Response,
next: NextFunction
) => {
const { item_id: plaidItemId } = req.body;
const plaidItem = await SystemPlaidItem.query().findOne({ plaidItemId });
const notFoundOrganization = () => {
return res.boom.unauthorized('Organization identication not found.', {
errors: [{ type: 'ORGANIZATION.ID.NOT.FOUND', code: 100 }],
});
};
// In case the given organization not found.
if (!plaidItem) {
return notFoundOrganization();
}
const tenant = await Tenant.query()
.findById(plaidItem.tenantId)
.withGraphFetched('metadata');
// When the given organization id not found on the system storage.
if (!tenant) {
return notFoundOrganization();
}
tenantDependencyInjection(req, tenant);
next();
};

View File

@@ -8,17 +8,17 @@ export class PlaidWebooks {
/**
* Listens to Plaid webhooks
* @param {number} tenantId
* @param {string} webhookType
* @param {string} plaidItemId
* @param {string} webhookCode
* @param {number} tenantId - Tenant Id.
* @param {string} webhookType - Webhook type.
* @param {string} plaidItemId - Plaid item Id.
* @param {string} webhookCode - webhook code.
*/
public async webhooks(
tenantId: number,
plaidItemId: string,
webhookType: string,
webhookCode: string
) {
): Promise<void> {
const _webhookType = webhookType.toLowerCase();
// There are five types of webhooks: AUTH, TRANSACTIONS, ITEM, INCOME, and ASSETS.
@@ -43,7 +43,7 @@ export class PlaidWebooks {
webhookType: string,
webhookCode: string,
plaidItemId: string
) {
): Promise<void> {
console.log(
`UNHANDLED ${webhookType} WEBHOOK: ${webhookCode}: Plaid item id ${plaidItemId}: unhandled webhook type received.`
);
@@ -59,7 +59,7 @@ export class PlaidWebooks {
additionalInfo: string,
webhookCode: string,
plaidItemId: string
) {
): void {
console.log(
`WEBHOOK: TRANSACTIONS: ${webhookCode}: Plaid_item_id ${plaidItemId}: ${additionalInfo}`
);

View File

@@ -0,0 +1,15 @@
exports.up = function (knex) {
return knex.schema.createTable('plaid_items', (table) => {
table.bigIncrements('id');
table
.bigInteger('tenant_id')
.unsigned()
.index()
.references('id')
.inTable('tenants');
table.string('plaid_item_id');
table.timestamps();
});
};
exports.down = (knex) => {};

View File

@@ -0,0 +1,49 @@
import { Model } from 'objection';
import SystemModel from '@/system/models/SystemModel';
export default class SystemPlaidItem extends SystemModel {
tenantId: number;
plaidItemId: string;
/**
* Table name.
*/
static get tableName() {
return 'plaid_items';
}
/**
* Timestamps columns.
*/
get timestamps() {
return ['createdAt', 'updatedAt'];
}
/**
* Virtual attributes.
*/
static get virtualAttributes() {
return [];
}
/**
* Relationship mapping.
*/
static get relationMappings() {
const Tenant = require('system/models/Tenant');
return {
/**
* System user may belongs to tenant model.
*/
tenant: {
relation: Model.BelongsToOneRelation,
modelClass: Tenant.default,
join: {
from: 'users.tenantId',
to: 'tenants.id',
},
},
};
}
}

View File

@@ -3,5 +3,13 @@ import TenantMetadata from './TenantMetadata';
import SystemUser from './SystemUser';
import PasswordReset from './PasswordReset';
import Invite from './Invite';
import SystemPlaidItem from './SystemPlaidItem';
export { Tenant, TenantMetadata, SystemUser, PasswordReset, Invite };
export {
Tenant,
TenantMetadata,
SystemUser,
PasswordReset,
Invite,
SystemPlaidItem,
};

View File

@@ -40,6 +40,7 @@
"@types/react-transition-group": "^4.4.5",
"@types/styled-components": "^5.1.25",
"@types/yup": "^0.29.13",
"@types/socket.io-client": "^3.0.0",
"@typescript-eslint/eslint-plugin": "^2.10.0",
"@typescript-eslint/parser": "^2.10.0",
"@welldone-software/why-did-you-render": "^6.0.0-rc.1",
@@ -114,6 +115,7 @@
"style-loader": "0.23.1",
"styled-components": "^5.3.1",
"stylis-rtlcss": "^2.1.1",
"socket.io-client": "^4.7.4",
"typescript": "^4.8.3",
"yup": "^0.28.1"
},

View File

@@ -14,6 +14,7 @@ import GlobalHotkeys from './GlobalHotkeys';
import DashboardProvider from './DashboardProvider';
import DrawersContainer from '@/components/DrawersContainer';
import AlertsContainer from '@/containers/AlertsContainer';
import { DashboardSockets } from './DashboardSockets';
/**
* Dashboard preferences.
@@ -50,6 +51,7 @@ export default function Dashboard() {
<Route path="/" component={DashboardAnyPage} />
</Switch>
<DashboardSockets />
<DashboardUniversalSearch />
<GlobalHotkeys />
<DialogsContainer />

View File

@@ -0,0 +1,31 @@
import { useEffect, useRef } from 'react';
import { useQueryClient } from 'react-query';
import { io } from 'socket.io-client';
import t from '@/hooks/query/types';
import { AppToaster } from '@/components';
import { Intent } from '@blueprintjs/core';
export function DashboardSockets() {
const socket = useRef<any>();
const client = useQueryClient();
useEffect(() => {
socket.current = io('ws://localhost:4000');
socket.current.on('NEW_TRANSACTIONS_DATA', () => {
client.invalidateQueries(t.ACCOUNTS);
client.invalidateQueries(t.ACCOUNT_TRANSACTION);
client.invalidateQueries(t.CASH_FLOW_ACCOUNTS);
client.invalidateQueries(t.CASH_FLOW_TRANSACTIONS);
AppToaster.show({
message: 'The Plaid connected accounts have been updated.',
intent: Intent.SUCCESS,
});
});
return () => {
socket.current.removeAllListeners();
socket.current.close();
};
}, []);
}

View File

@@ -34,7 +34,10 @@ import {
transformFormValuesToRequest,
resetFormState,
} from './utils';
import { ReceiptSyncAutoExRateToForm, ReceiptSyncIncrementSettingsToForm } from './components';
import {
ReceiptSyncAutoExRateToForm,
ReceiptSyncIncrementSettingsToForm,
} from './components';
/**
* Receipt form.

165
pnpm-lock.yaml generated
View File

@@ -263,6 +263,9 @@ importers:
rtl-detect:
specifier: ^1.0.4
version: 1.0.4
socket.io:
specifier: ^4.7.4
version: 4.7.4
source-map-loader:
specifier: ^4.0.1
version: 4.0.1(webpack@5.76.0)
@@ -528,6 +531,9 @@ importers:
'@types/react-transition-group':
specifier: ^4.4.5
version: 4.4.5
'@types/socket.io-client':
specifier: ^3.0.0
version: 3.0.0
'@types/styled-components':
specifier: ^5.1.25
version: 5.1.26
@@ -747,6 +753,9 @@ importers:
semver:
specifier: 6.3.0
version: 6.3.0
socket.io-client:
specifier: ^4.7.4
version: 4.7.4
style-loader:
specifier: 0.23.1
version: 0.23.1
@@ -5908,6 +5917,10 @@ packages:
resolution: {integrity: sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==}
dev: true
/@socket.io/component-emitter@3.1.0:
resolution: {integrity: sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==}
dev: false
/@surma/rollup-plugin-off-main-thread@2.2.3:
resolution: {integrity: sha512-lR8q/9W7hZpMWweNiAKU7NQerBnzQQLvi8qnTDU/fxItPhtZVMbPV3lbCwjhIlNBe9Bbr5V+KHshvWmVSG9cxQ==}
dependencies:
@@ -6460,10 +6473,20 @@ packages:
'@types/node': 14.18.36
dev: false
/@types/cookie@0.4.1:
resolution: {integrity: sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==}
dev: false
/@types/cookiejar@2.1.2:
resolution: {integrity: sha512-t73xJJrvdTjXrn4jLS9VSGRbz0nUY3cl2DMGDU48lKl+HR9dbbjW2A9r3g40VA++mQpy6uuHg33gy7du2BKpog==}
dev: true
/@types/cors@2.8.17:
resolution: {integrity: sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==}
dependencies:
'@types/node': 14.18.36
dev: false
/@types/dom4@2.0.2:
resolution: {integrity: sha512-Rt4IC1T7xkCWa0OG1oSsPa0iqnxlDeQqKXZAHrQGLb7wFGncWm85MaxKUjAGejOrUynOgWlFi4c6S6IyJwoK4g==}
dev: false
@@ -6601,7 +6624,7 @@ packages:
/@types/keyv@3.1.4:
resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==}
dependencies:
'@types/node': 18.13.0
'@types/node': 14.18.36
dev: false
/@types/knex@0.16.1(mysql2@1.7.0)(mysql@2.18.1):
@@ -6781,7 +6804,7 @@ packages:
/@types/responselike@1.0.1:
resolution: {integrity: sha512-TiGnitEDxj2X0j+98Eqk5lv/Cij8oHd32bU4D/Yw6AOq7vvTk0gSD2GPj0G/HkvhMoVsdlhYF4yqqlyPBTM6Sg==}
dependencies:
'@types/node': 18.13.0
'@types/node': 14.18.36
dev: false
/@types/retry@0.12.0:
@@ -6816,6 +6839,17 @@ packages:
'@types/node': 14.18.36
dev: false
/@types/socket.io-client@3.0.0:
resolution: {integrity: sha512-s+IPvFoEIjKA3RdJz/Z2dGR4gLgysKi8owcnrVwNjgvc01Lk68LJDDsG2GRqegFITcxmvCMYM7bhMpwEMlHmDg==}
deprecated: This is a stub types definition. socket.io-client provides its own type definitions, so you do not need this installed.
dependencies:
socket.io-client: 4.7.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/@types/sockjs@0.3.34:
resolution: {integrity: sha512-R+n7qBFnm/6jinlteC9DBL5dGiDGjWAvjo4viUanpnc/dG1y7uDoacXPIQ/PQEg1fI912SMHIa014ZjRpvDw4g==}
dependencies:
@@ -8604,6 +8638,11 @@ packages:
/base64-js@1.5.1:
resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==}
/base64id@2.0.0:
resolution: {integrity: sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==}
engines: {node: ^4.5.0 || >= 5.9}
dev: false
/base@0.11.2:
resolution: {integrity: sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==}
engines: {node: '>=0.10.0'}
@@ -10041,6 +10080,11 @@ packages:
engines: {node: '>= 0.6'}
dev: false
/cookie@0.4.2:
resolution: {integrity: sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==}
engines: {node: '>= 0.6'}
dev: false
/cookie@0.5.0:
resolution: {integrity: sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==}
engines: {node: '>= 0.6'}
@@ -10103,6 +10147,14 @@ packages:
/core-util-is@1.0.3:
resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==}
/cors@2.8.5:
resolution: {integrity: sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==}
engines: {node: '>= 0.10'}
dependencies:
object-assign: 4.1.1
vary: 1.1.2
dev: false
/cosmiconfig-typescript-loader@4.3.0(@types/node@18.13.0)(cosmiconfig@8.0.0)(ts-node@10.9.1)(typescript@4.9.5):
resolution: {integrity: sha512-NTxV1MFfZDLPiBMjxbHRwSh5LaLcPMwNdCutmnHJCKoVnlvldPWlllonKwrsRJ5pYZBIBGRWWU2tfvzxgeSW5Q==}
engines: {node: '>=12', npm: '>=6'}
@@ -11407,6 +11459,45 @@ packages:
dependencies:
once: 1.4.0
/engine.io-client@6.5.3:
resolution: {integrity: sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4(supports-color@5.5.0)
engine.io-parser: 5.2.2
ws: 8.11.0
xmlhttprequest-ssl: 2.0.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/engine.io-parser@5.2.2:
resolution: {integrity: sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==}
engines: {node: '>=10.0.0'}
dev: false
/engine.io@6.5.4:
resolution: {integrity: sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==}
engines: {node: '>=10.2.0'}
dependencies:
'@types/cookie': 0.4.1
'@types/cors': 2.8.17
'@types/node': 14.18.36
accepts: 1.3.8
base64id: 2.0.0
cookie: 0.4.2
cors: 2.8.5
debug: 4.3.4(supports-color@5.5.0)
engine.io-parser: 5.2.2
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/enhanced-resolve@0.9.1:
resolution: {integrity: sha512-kxpoMgrdtkXZ5h0SeraBS1iRntpTpQ3R8ussdb38+UAFnMGX5DDyJXePm+OCHOcoXvHDw7mc2erbJBpDnl7TPw==}
engines: {node: '>=0.6'}
@@ -22943,6 +23034,58 @@ packages:
- supports-color
dev: false
/socket.io-adapter@2.5.4:
resolution: {integrity: sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==}
dependencies:
debug: 4.3.4(supports-color@5.5.0)
ws: 8.11.0
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/socket.io-client@4.7.4:
resolution: {integrity: sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==}
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4(supports-color@5.5.0)
engine.io-client: 6.5.3
socket.io-parser: 4.2.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/socket.io-parser@4.2.4:
resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==}
engines: {node: '>=10.0.0'}
dependencies:
'@socket.io/component-emitter': 3.1.0
debug: 4.3.4(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
dev: false
/socket.io@4.7.4:
resolution: {integrity: sha512-DcotgfP1Zg9iP/dH9zvAQcWrE0TtbMVwXmlV4T4mqsvY+gw+LqUGPfx2AoVyRk0FLME+GQhufDMyacFmw7ksqw==}
engines: {node: '>=10.2.0'}
dependencies:
accepts: 1.3.8
base64id: 2.0.0
cors: 2.8.5
debug: 4.3.4(supports-color@5.5.0)
engine.io: 6.5.4
socket.io-adapter: 2.5.4
socket.io-parser: 4.2.4
transitivePeerDependencies:
- bufferutil
- supports-color
- utf-8-validate
dev: false
/sockjs@0.3.24:
resolution: {integrity: sha512-GJgLTZ7vYb/JtPSSZ10hsOYIvEYsjbNU+zPdIHcUaWVNUEPivzxku31865sSSud0Da0W4lEeOPlmw93zLQchuQ==}
dependencies:
@@ -25815,6 +25958,19 @@ packages:
optional: true
dev: false
/ws@8.11.0:
resolution: {integrity: sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==}
engines: {node: '>=10.0.0'}
peerDependencies:
bufferutil: ^4.0.1
utf-8-validate: ^5.0.2
peerDependenciesMeta:
bufferutil:
optional: true
utf-8-validate:
optional: true
dev: false
/ws@8.14.2:
resolution: {integrity: sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==}
engines: {node: '>=10.0.0'}
@@ -25860,6 +26016,11 @@ packages:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
dev: false
/xmlhttprequest-ssl@2.0.0:
resolution: {integrity: sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==}
engines: {node: '>=0.4.0'}
dev: false
/xtend@4.0.2:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}