From c5934ef24466aabf95724993ca62e26a06794459 Mon Sep 17 00:00:00 2001 From: Derrick Hammer Date: Thu, 21 Jul 2022 12:47:53 -0400 Subject: [PATCH] *Initial version --- build.js | 15 ++ package.json | 67 +++++++++ polyfill.js | 118 ++++++++++++++++ src-build/build.ts | 337 ++++++++++++++++++++++++++++++++++++++++++++ src/common.ts | 6 + src/index.ts | 24 ++++ src/relays.ts | 64 +++++++++ src/ws.ts | 74 ++++++++++ tsconfig.build.json | 13 ++ tsconfig.json | 12 ++ 10 files changed, 730 insertions(+) create mode 100644 build.js create mode 100644 package.json create mode 100644 polyfill.js create mode 100644 src-build/build.ts create mode 100644 src/common.ts create mode 100644 src/index.ts create mode 100644 src/relays.ts create mode 100644 src/ws.ts create mode 100644 tsconfig.build.json create mode 100644 tsconfig.json diff --git a/build.js b/build.js new file mode 100644 index 0000000..818328d --- /dev/null +++ b/build.js @@ -0,0 +1,15 @@ +import esbuild from "esbuild" + +esbuild.buildSync({ + entryPoints: ['src/index.ts'], + outfile: 'dist/index.js', + format: 'esm', + inject: ["./polyfill.js"], + bundle: true, + legalComments: 'external', + // minify: true + define: { + 'global': 'self' + }, + external: ["libkernel"] +}) diff --git a/package.json b/package.json new file mode 100644 index 0000000..270a027 --- /dev/null +++ b/package.json @@ -0,0 +1,67 @@ +{ + "name": "@lumeweb/kernel-dns", + "version": "0.1.0", + "description": "DNS module for Skynet Kernel", + "main": "dist/index.js", + "scripts": { + "cleanhack": "rimraf node_modules/crypto-browserify/node_modules && rimraf node_modules/@types/web", + "build-script": "tsc --project tsconfig.build.json", + "compile": "npm run cleanhack && node build.js && npm run build-script", + "build": "npm run compile && node ./dist-build/build.js dev", + "deploy": "npm run compile && node ./dist-build/build.js prod" + }, + "author": { + "name" : "Hammer Technologies LLC", + "email" : "contact@lumeweb.com" + }, + "license": "MIT", + "type": "module", + "devDependencies": { + "@lumeweb/js-sha3-browser": "^0.8.1", + "@lumeweb/kernel-tester": "https://github.com/LumeWeb/kernel-tester.git", + "@lumeweb/randombytes-browser": "https://github.com/LumeWeb/randombytes-browser.git", + "@lumeweb/safe-buffer": "^5.2.1", + "@rollup/plugin-json": "^4.1.0", + "@rollup/plugin-node-resolve": "^13.3.0", + "@skynetlabs/skynet-nodejs": "^2.6.0", + "@types/read": "^0.0.29", + "@typescript-eslint/eslint-plugin": "^5.18.0", + "assert": "^2.0.0", + "buffer-browserify": "^0.2.5", + "buffer-fill": "^1.0.0", + "cpy-cli": "^4.1.0", + "crypto-browserify": "^3.12.0", + "esbuild": "^0.14.47", + "eslint": "^8.13.0", + "events": "^3.3.0", + "https-browserify": "^1.0.0", + "libskynet": "^0.0.43", + "libskynetnode": "^0.1.2", + "os-browserify": "^0.3.0", + "path-browserify": "^1.0.1", + "prettier": "^2.6.2", + "read": "^1.0.7", + "rollup": "^2.75.6", + "rollup-plugin-terser": "^7.0.2", + "stream-browserify": "^3.0.0", + "timers-browserify": "^2.0.12", + "typescript": "^4.6.3", + "whatwg-fetch": "^3.6.2" + }, + "dependencies": { + "@lumeweb/kernel-dht-client": "https://github.com/LumeWeb/kernel-dht-client.git", + "@lumeweb/kernel-rpc-client": "https://github.com/LumeWeb/kernel-rpc-client.git", + "@lumeweb/kernel-utils": "https://github.com/LumeWeb/kernel-utils.git", + "@lumeweb/resolver": "https://github.com/LumeWeb/resolver.git#hypercore", + "libkmodule": "^0.2.11", + "randombytes": "https://github.com/LumeWeb/randombytes-browser.git", + "randomfill": "https://github.com/LumeWeb/randomfill.git" + }, + "browser": { + "crypto": "crypto-browserify", + "timers": "timers-browserify", + "os": "os-browserify", + "stream": "os-browserify", + "libskynetnode": false + } +} diff --git a/polyfill.js b/polyfill.js new file mode 100644 index 0000000..4da1c39 --- /dev/null +++ b/polyfill.js @@ -0,0 +1,118 @@ +export var Buffer = require("buffer-browserify").Buffer; +var bufferFill = require("buffer-fill"); + +export const process = { + cwd: () => "", + env: {}, +}; + +function allocUnsafe(size) { + if (typeof size !== "number") { + throw new TypeError('"size" argument must be a number'); + } + + if (size < 0) { + throw new RangeError('"size" argument must not be negative'); + } + + return new Uint32Array(size); +} + +var toString = Object.prototype.toString; + +function isArrayBuffer(input) { + return toString.call(input).slice(8, -1) === "ArrayBuffer"; +} + +function fromArrayBuffer(obj, byteOffset, length) { + byteOffset >>>= 0; + + var maxLength = obj.byteLength - byteOffset; + + if (maxLength < 0) { + throw new RangeError("'offset' is out of bounds"); + } + + if (length === undefined) { + length = maxLength; + } else { + length >>>= 0; + + if (length > maxLength) { + throw new RangeError("'length' is out of bounds"); + } + } + + return new Buffer(new Uint8Array(obj.slice(byteOffset, byteOffset + length))); +} + +function fromString(string, encoding) { + if (typeof encoding !== "string" || encoding === "") { + encoding = "utf8"; + } + + if (!Buffer.isEncoding(encoding)) { + throw new TypeError('"encoding" must be a valid string encoding'); + } + + return new Buffer(string, encoding); +} + +function bufferFrom(value, encodingOrOffset, length) { + if (typeof value === "number") { + throw new TypeError('"value" argument must not be a number'); + } + + if (isArrayBuffer(value)) { + return fromArrayBuffer(value, encodingOrOffset, length); + } + + if (typeof value === "string") { + return fromString(value, encodingOrOffset); + } + + return new Buffer(value); +} + +Buffer.alloc = function alloc(size, fill, encoding) { + if (typeof size !== "number") { + throw new TypeError('"size" argument must be a number'); + } + + if (size < 0) { + throw new RangeError('"size" argument must not be negative'); + } + + var buffer = allocUnsafe(size); + + if (size === 0) { + return buffer; + } + + if (fill === undefined) { + return bufferFill(buffer, 0); + } + + if (typeof encoding !== "string") { + encoding = undefined; + } + + return bufferFill(buffer, fill, encoding); +}; +Buffer.allocUnsafe = allocUnsafe; +Buffer.from = bufferFrom; +Object.defineProperty(Buffer.prototype, "buffer", { + get: function myProperty() { + return this; + }, +}); +Object.defineProperty(Buffer.prototype, "byteOffset", { + get: function myProperty() { + return this.offset; + }, +}); +Object.defineProperty(Buffer.prototype, "byteLength", { + get: function myProperty() { + return this.length; + }, +}); diff --git a/src-build/build.ts b/src-build/build.ts new file mode 100644 index 0000000..8adbca2 --- /dev/null +++ b/src-build/build.ts @@ -0,0 +1,337 @@ +// This is the standard build script for a kernel module. + +import * as fs from "fs" +import { + addContextToErr, + b64ToBuf, + bufToHex, + deriveRegistryEntryID, + generateSeedPhraseDeterministic, + resolverLink, + sha512, + taggedRegistryEntryKeys, + validSeedPhrase, +} from "libskynet" +import { generateSeedPhraseRandom, overwriteRegistryEntry, upload } from "libskynetnode" +import read from "read" +// @ts-ignore +import {SkynetClient} from "@skynetlabs/skynet-nodejs" + +// Helper variables to make it easier to return empty values alongside errors. +const nu8 = new Uint8Array(0) +const nkp = { + publicKey: nu8, + secretKey: nu8, +} + +// readFile is a wrapper for fs.readFileSync that handles the try-catch for the +// caller. +function readFile(fileName: string): [string, string | null] { + try { + let data = fs.readFileSync(fileName, "utf8") + return [data, null] + } catch (err) { + return ["", "unable to read file: " + JSON.stringify(err)] + } +} + +// readFileBinary is a wrapper for fs.readFileSync that handles the try-catch +// for the caller. +function readFileBinary(fileName: string): [Uint8Array, string | null] { + try { + let data = fs.readFileSync(fileName, null) + return [data, null] + } catch (err) { + return [nu8, "unable to read file: " + JSON.stringify(err)] + } +} + +// writeFile is a wrapper for fs.writeFileSync which handles the try-catch in a +// non-exception way. +function writeFile(fileName: string, fileData: string): string | null { + try { + fs.writeFileSync(fileName, fileData) + return null + } catch (err) { + return "unable to write file: " + JSON.stringify(err) + } +} + +// hardenedSeedPhrase will take a password, harden it with 100,000 iterations +// of hashing, and then turn it into a seed phrase. +function hardenedSeedPhrase(password: string): [string, string | null] { + let pw = password + // Add some hashing iterations to the password to make it stronger. + for(let i = 0; i < 1000000; i++) { + let passU8 = new TextEncoder().encode(password) + let hashIter = sha512(passU8) + password = bufToHex(hashIter) + } + return generateSeedPhraseDeterministic(password) +} + +// seedPhraseToRegistryKeys will convert a seed phrase to the set of registry +// keys that govern the registry entry where the module is published. +function seedPhraseToRegistryKeys(seedPhrase: string): [any, Uint8Array, string | null] { + let [seed, errVSP] = validSeedPhrase(seedPhrase) + if (errVSP !== null) { + return [nkp, nu8, addContextToErr(errVSP, "unable to compute seed phrase")] + } + let [keypair, datakey, errTREK] = taggedRegistryEntryKeys(seed, "module-build", "module-key") + if (errTREK !== null) { + return [nkp, nu8, addContextToErr(errTREK, "unable to compute registry entry keys")] + } + return [keypair, datakey, null] +} + +// seedPhraseToRegistryLink will take a seedPhrase as input and convert it to +// the registry link for the module. +function seedPhraseToRegistryLink(seedPhrase: string): [string, string | null] { + let [keypair, datakey, errSPTRK] = seedPhraseToRegistryKeys(seedPhrase) + if (errSPTRK !== null) { + return ["", addContextToErr(errSPTRK, "unable to compute registry keys")] + } + let [entryID, errDREID] = deriveRegistryEntryID(keypair.publicKey, datakey) + if (errDREID !== null) { + return ["", addContextToErr(errDREID, "unable to compute registry entry id")] + } + let [registryLink, errRL] = resolverLink(entryID) + if (errRL !== null) { + return ["", addContextToErr(errRL, "unable to compute registry link")] + } + return [registryLink, null] +} + +// handlePass handles all portions of the script that occur after the password +// has been requested. If no password needs to be requested, handlePass will be +// called with a null input. We need to structure the code this way because the +// password reader is async and we can only access the password when using a +// callback. +function handlePass(password: string) { + try { + // If we are running prod and the seed file does not exist, we + // need to confirm the password and also warn the user to use a + // secure password. + if (!fs.existsSync(seedFile) && process.argv[2] === "prod") { + // The file does not exist, we need to confirm the + // password. + console.log() + console.log("No production entry found for module. Creating new production module...") + console.log("If someone can guess the password, they can push arbitrary changes to your module.") + console.log("Please use a secure password.") + console.log() + read({ prompt: "Confirm Password: ", silent: true }, function (err: any, confirmPassword: string) { + if (err) { + console.error("unable to fetch password:", err) + process.exit(1) + } + if (password !== confirmPassword) { + console.error("passwords do not match") + process.exit(1) + } + password = password + moduleSalt + handlePassConfirm(password) + }) + } else { + // If the seed file does exist, or if we are using dev, + // there's no need to confirm the password but we do + // need to pass the logic off to the handlePassConfirm + // callback. + password = password + moduleSalt + handlePassConfirm(password) + } + } catch (err) { + console.error("Unable to read seedFile:", err) + process.exit(1) + } +} + +// handlePassConfirm handles the full script after the confirmation password +// has been provided. If not confirmation password is needed, this function +// will be called anyway using the unconfirmed password as input. +function handlePassConfirm(password: string) { + // Create the seedFile if it does not exist. For dev we just save the + // seed to disk outright, because this is a dev build and therefore not + // security sensitive. Also the dev seed does not get pushed to the + // github repo. + // + // For prod, we use the seed to create a new seed (called the shield) + // which allows us to verify that the developer has provided the right + // password when deploying the module. The shield does get pushed to + // the github repo so that the production module is the same on all + // devices. + if (!fs.existsSync(seedFile) && process.argv[2] !== "prod") { + // Generate the seed phrase and write it to the file. + let [seedPhrase, errGSP] = generateSeedPhraseRandom() + if (errGSP !== null) { + console.error("Unable to generate seed phrase:", errGSP) + process.exit(1) + } + let errWF = writeFile(seedFile, seedPhrase) + if (errWF !== null) { + console.error("unable to write file:", errWF) + process.exit(1) + } + } else if (!fs.existsSync(seedFile) && process.argv[2] === "prod") { + // Generate the seed phrase. + let [seedPhrase, errGSP] = hardenedSeedPhrase(password) + if (errGSP !== null) { + console.error("Unable to generate seed phrase:", errGSP) + process.exit(1) + } + let [registryLink, errSPTRL] = seedPhraseToRegistryLink(seedPhrase) + if (errSPTRL !== null) { + console.error("Unable to generate registry link:", errSPTRL) + process.exit(1) + } + + // Write the registry link to the file. + let errWF = writeFile(seedFile, registryLink) + if (errWF !== null) { + console.error("unable to write registry link file:", errWF) + process.exit(1) + } + } + + // Load or verify the seed. If this is prod, the password is used to + // create and verify the seed. If this is dev, we just load the seed + // with no password. + let seedPhrase: string + let registryLink: string + if (process.argv[2] === "prod") { + // Generate the seed phrase from the password. + let [sp, errGSP] = hardenedSeedPhrase(password) + if (errGSP !== null) { + console.error("Unable to generate seed phrase: ", errGSP) + process.exit(1) + } + let [rl, errSPTRL] = seedPhraseToRegistryLink(sp) + registryLink = rl + if (errSPTRL !== null) { + console.error("Unable to generate registry link:", errSPTRL) + process.exit(1) + } + let [registryLinkVerify, errRF] = readFile(seedFile) + if (errRF !== null) { + console.error("unable to read seedFile") + process.exit(1) + } + registryLinkVerify = registryLinkVerify.replace(/\n$/, "") + if (registryLink !== registryLinkVerify) { + console.error("Incorrect password") + process.exit(1) + } + seedPhrase = sp + } else { + let [sp, errRF] = readFile(seedFile) + if (errRF !== null) { + console.error("unable to read seed phrase for dev command from disk") + process.exit(1) + } + let [rl, errSPTRL] = seedPhraseToRegistryLink(sp) + registryLink = rl + if (errSPTRL !== null) { + console.error("Unable to generate registry link:", errSPTRL) + process.exit(1) + } + // Write the registry link to the module skylink dev file. + let errWF = writeFile("build/module-skylink-dev", registryLink) + if (errWF !== null) { + console.error("unable to write registry link file:", errWF) + process.exit(1) + } + seedPhrase = sp + } + + console.log("Uploading module...") + const client = new SkynetClient("https://web3portal.com"); + client.uploadFile("dist/index.js") + .then((result:any) => { + console.log("Updating module's registry entry...") + // Update the v2 skylink. + let [keypair, datakey, errSPTRK] = seedPhraseToRegistryKeys(seedPhrase) + if (errSPTRK !== null) { + return ["", addContextToErr(errSPTRK, "unable to compute registry keys")] + } + let [bufLink, errBTB] = b64ToBuf(result.replace("sia://","")) + if (errBTB !== null) { + return ["", addContextToErr(errBTB, "unable to decode skylink")] + } + overwriteRegistryEntry(keypair, datakey, bufLink) + .then(() => { + console.log("registry entry is updated") + console.log("Immutable Link for Module:", result) + console.log("Resolver Link for Module:", registryLink) + }) + .catch((err: any) => { + console.log("unable to update registry entry:", err) + }) + }) + .catch((err) => { + console.error("unable to upload file", err) + process.exit(1) + }) +} + +// Add a newline for readability. +console.log() + +// Check for a 'dev' or 'prod' input to the script. +if (process.argv.length !== 3) { + console.error("need to provide either 'dev' or 'prod' as an input") + process.exit(1) +} + +// Create the build folder if it does not exist. +if (!fs.existsSync("build")) { + fs.mkdirSync("build") +} + +// Determine the seed file. +let seedFile: string +if (process.argv[2] === "prod") { + seedFile = "module-skylink" +} else if (process.argv[2] === "dev") { + seedFile = "build/dev-seed" +} else { + console.error("need to provide either 'dev' or 'prod' as an input") + process.exit(1) +} + +// If doing a prod deployment, check whether the salt file exists. If it does +// not, create it. +let moduleSalt: string +if (!fs.existsSync(".module-salt")) { + let [ms, errGSPR] = generateSeedPhraseRandom() + if (errGSPR !== null) { + console.error("unable to generate module salt:", errGSPR) + process.exit(1) + } + moduleSalt = ms + let errWF = writeFile(".module-salt", moduleSalt) + if (errWF !== null) { + console.error("unable to write module salt file:", errWF) + process.exit(1) + } +} else { + let [ms, errRF] = readFile(".module-salt") + if (errRF !== null) { + console.error("unable to read moduleSalt") + process.exit(1) + } + ms = ms.replace(/\n$/, "") + moduleSalt = ms +} + +// Need to get a password if this is a prod build. +if (process.argv[2] === "prod") { + read({ prompt: "Password: ", silent: true }, function (err: any, password: string) { + if (err) { + console.error("unable to fetch password:", err) + process.exit(1) + } + handlePass(password) + }) +} else { + handlePass("") +} diff --git a/src/common.ts b/src/common.ts new file mode 100644 index 0000000..a3f9fc5 --- /dev/null +++ b/src/common.ts @@ -0,0 +1,6 @@ +import resolvers from "@lumeweb/resolver" +import { RpcNetwork } from "@lumeweb/kernel-rpc-client" + +const network = new RpcNetwork() + +export const resolver = resolvers.createDefaultResolver(network as any) diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..2b486c0 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,24 @@ +import {addHandler, handleMessage} from "libkmodule" +import type {ActiveQuery} from "libkmodule" +import {resolver} from "./common.js" + +import {relayReady, setupRelayListSubscription} from "./relays.js" + +setupRelayListSubscription() + +addHandler("resolve", resolveHandler) +onmessage = handleMessage + +async function resolveHandler(aq: ActiveQuery) { + const query = aq.callerInput + + if (!("domain" in query)) { + aq.reject("domain required") + return + } + + await relayReady(); + aq.respond(await resolver.resolve(aq.callerInput.domain, aq.callerInput.params ?? {}, aq.callerInput.force || false)) +} + + diff --git a/src/relays.ts b/src/relays.ts new file mode 100644 index 0000000..1bfdf25 --- /dev/null +++ b/src/relays.ts @@ -0,0 +1,64 @@ +import WSReconnect from "./ws.js" +import {resolver} from "./common.js" +import {hashDataKey} from "@lumeweb/kernel-utils"; +import {bufToHex} from "libskynet"; +import {deriveRegistryEntryID, downloadSkylink} from "libskynet"; +import {entryIDToSkylink} from "libskynet"; +import {RpcNetwork} from "@lumeweb/kernel-rpc-client"; + +const relayListName = "lumeweb-relays" +const relayListOwner = Buffer.from("86c7421160eb5cb4a39495fc3e3ae25a60b330fff717e06aab978ad353722014", "hex"); + +/* + TODO: Use kernel code to use many different portals and not hard code a portal + */ +let portalConnection: WSReconnect + +let relayPromiseResolve: any = () => true; +let relayPromise: Promise; + +export function relayReady(): Promise { + return relayPromise; +} + +function resetRelayPromise() { + relayPromise = new Promise((resolve) => { + relayPromiseResolve = resolve; + }) +} + +async function fetchRelays() { +/* const [data, err] = await downloadSkylink(entryIDToSkylink(deriveRegistryEntryID(relayListOwner, hashDataKey(relayListName))[0])) + if (err) { + return; + }*/ + // const list: string[] = JSON.parse(Buffer.from(data).toString()); + const list: string[] = ["ef92890bec753c86cfecbe1adea632a8aa130d479bab69ec999c0ea28afd48cf"]; + resolver.rpcNetwork.clearRelays(); + list.forEach((item) => { + resolver.rpcNetwork.addRelay(item); + }); + await (resolver.rpcNetwork as unknown as RpcNetwork).processQueue() + relayPromiseResolve() + // resetRelayPromise(); +} + +export function setupRelayListSubscription() { +/* portalConnection = new WSReconnect("wss://web3portal.com/skynet/registry/subscription") + // @ts-ignore + portalConnection.on("connect", () => { + portalConnection.send( + JSON.stringify({ + action: "subscribe", + pubkey: `ed25519:${relayListOwner}`, + datakey: bufToHex(hashDataKey(relayListName)), + }) + ) + }) + + // @ts-ignore + portalConnection.on("message", fetchRelays) + portalConnection.start()*/ + resetRelayPromise(); + fetchRelays() +} diff --git a/src/ws.ts b/src/ws.ts new file mode 100644 index 0000000..a873fff --- /dev/null +++ b/src/ws.ts @@ -0,0 +1,74 @@ +import EventEmitter from "events" + +interface Options { + retryCount?: number + reconnectInterval?: number +} + +/* + Ported from https://github.com/ofirattia/ws-reconnect + */ +export default class WSReconnect extends EventEmitter { + private url: string + private options: Options + private retryCount: number + private _retryCount: number + private reconnectInterval: number + private shouldAttemptReconnect: boolean + private isConnected = false + private socket: WebSocket | undefined + private reconnectTimeoutId: NodeJS.Timeout | undefined + + constructor(url: string, options: Options = {}) { + super() + this.url = url + this.options = options + this.retryCount = this.options.retryCount || -1 + this._retryCount = this.retryCount + this.reconnectInterval = this.options?.reconnectInterval ?? 5 + this.shouldAttemptReconnect = !!this.reconnectInterval + } + + start() { + this.shouldAttemptReconnect = !!this.reconnectInterval + this.isConnected = false + this.socket = new WebSocket(this.url) + this.socket.onmessage = this.onMessage.bind(this) + this.socket.onopen = this.onOpen.bind(this) + this.socket.onclose = this.onClose.bind(this) + } + + destroy() { + clearTimeout(this.reconnectTimeoutId as NodeJS.Timeout) + this.shouldAttemptReconnect = false + this.socket?.close() + } + + onOpen() { + this.isConnected = true + this.emit("connect") + // set again the retry count + this.retryCount = this._retryCount + } + + onClose() { + if (this.shouldAttemptReconnect && (this.retryCount > 0 || this.retryCount == -1)) { + if (this.retryCount !== -1) this.retryCount-- + clearTimeout(this.reconnectTimeoutId as NodeJS.Timeout) + this.reconnectTimeoutId = setTimeout(() => { + this.emit("reconnect") + this.start() + }, this.reconnectInterval * 1000) + } else { + this.emit("destroyed") + } + } + + onMessage(message: MessageEvent) { + this.emit("message", message.data) + } + + send(message: string | ArrayBufferLike | Blob | ArrayBufferView) { + this.socket?.send(message) + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..82c8e4f --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "esnext", + "module": "esnext", + "moduleResolution": "node", + "allowSyntheticDefaultImports": true, + "declaration": true, + "outDir": "./dist-build", + "strict": false, + }, + "include": ["src-build"], + "exclude": ["node_modules", "**/__tests__/*"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..4119fc0 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,12 @@ +{ + "compilerOptions": { + "target": "esnext", + "declaration": true, + "moduleResolution": "node", + "outDir": "./build", + "strict": true, + "allowSyntheticDefaultImports": true + }, + "include": ["src"], + "exclude": ["node_modules", "**/__tests__/*"] +}