DEV Community

Cover image for Tuner is a flexible project configurator as code for Deno.
artpani
artpani

Posted on

Tuner is a flexible project configurator as code for Deno.

I think I’m not the only one who has a need to conveniently configure a project. And there are many ready-made solutions of varying degrees of complexity and freshness. In this topic I want to demonstrate a module for defining project settings and managing them as code. I’ve been using Deno not long ago, but a number of features about which many articles have already been written (comparison with Node.js and Bun) turned out to be very convenient, but the Deno community is not yet sufficiently developed and there are also few modules available.

Beginning of work

Let's create a /config directory in the project root. All the work with the configuration and its processing will be done here. The Tuner will automatically locate a folder with this specific name and gather the config object from there. The configuration file itself should end with .tuner.ts.

// config/myConfig.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune(
  {
    config: {
      field1: 'value1',
      field2: 100,
      field3: true,
      field4: ['minimalistic', 'convenient', 'dont you think?'],
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

Inside the tune function, the static analyzer will helpfully guide you that the configuration consists of two fields: env and config. In the file whose path is specified in deno.json, you can create a config object, and you can associate it with an alias like $global, for example:

// main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export const tuner = await Tuner.use.loadConfig();
console.log(tuner.config.field2); // 100
Enter fullscreen mode Exit fullscreen mode

After that, you can import {tuner} from "$global" in any file of the project and use it.

Upon execution, it is mandatory to have the env variable config, its value being the name of the config file without .tuner.ts extension. In this example, it is myConfig.

config=myConfig deno run --allow-all main.ts

In fact, this is the only required env variable that needs to be passed into the project (or set different values for the config variable in Doppler, if you use it).

Tuning secrets

In Tuner, you have the option to describe the types of environment variables and specify their behavior in case they are absent:

  • Default value
  • Process termination
  • Exception generation
  • Computation on-the-fly Here is a complete list of behaviors with different expected data types:
// config/myConfig.tuner.ts
import Tuner from 'https://deno.land/x/tuner/mod.ts';
export default Tuner.tune(
  {
    env: {
      // Use Default Value
      env1: Tuner.Env.getString.orDefault('default value1'),
      env2: Tuner.Env.getNumber.orDefault(100),
      env3: Tuner.Env.getBoolean.orDefault(true),
      // Ignore Absence of Variable
      env4: Tuner.Env.getString.orNothing(),
      env5: Tuner.Env.getNumber.orNothing(),
      env6: Tuner.Env.getBoolean.orNothing(),
      // Terminate Process
      env7: Tuner.Env.getString.orExit(
        'error message, optional',
      ),
      env8: Tuner.Env.getNumber.orExit(
        'will be printed to console before exit',
      ),
      env9: Tuner.Env.getBoolean.orExit(),
      // Generate Exception
      env10: Tuner.Env.getString.orThrow(new Error('error')),
      env11: Tuner.Env.getNumber.orThrow(new Error()),
      env12: Tuner.Env.getBoolean.orThrow(new Error()),
      // Compute Data Based on Provided Callback
      // (can be asynchronous, if data needs to be fetched from disk or remotely, for example)
      env13: Tuner.Env.getString.orCompute(() => 'computed value1'),
      env14: Tuner.Env.getNumber.orAsyncCompute(() =>
        new Promise(() => 100)
      ),
    },
    config: {
      field1: 'value1',
      field2: 100,
      field3: true,
      field4: ['minimalistic', 'convenient', 'isn\'t it?'],
    },
  },
);
Enter fullscreen mode Exit fullscreen mode

Of course, you can simply specify a primitive value, for example, env1: 100.

We're building a hierarchy of configs

Sometimes a config consists of data that is not equally important and will need to be updated at different frequencies. To separate the 'core' data from the 'secondary', I recommend splitting the config into several mini-configs, creating a kind of hierarchy from them.

Tuner allows you to 'assemble' a config using other configs, you just need to build a chain:

  • The current config will be supplemented with all the fields of the parent, while retaining its values.

  • The current config will be supplemented with all the fields of the child, with matching fields being overwritten by values from the child config.

Function values used to describe env variables also follow these rules.

An example of how inheritance works in Tuner

Let's see how to do this in the code:

// config/develop.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  child: Tuner.Load.local.configDir('a.tuner.ts'),
  parent: Tuner.Load.local.configDir('base.tuner.ts'),
  config: {
    a: 300,
    b: 301,
  },
});


//config/base.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  config: { a: 400, b: 401, c: 402 },
});


//config/a.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
child: Tuner.Load.local.configDir('b.tuner.ts'),
  config: {
    b: 200,
    e: 201,
  },
});


//config/b.tuner.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export default Tuner.tune({
  config: { a: 100, d: 101 },
});


//main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
export const tuner = await Tuner.use.loadConfig();
console.log(tuner);
//{ config: { a: 100, b: 200, c: 402, e: 201, d: 101 }, env: {} }
Enter fullscreen mode Exit fullscreen mode

Tuner.Load provides several options for defining the source of the config. You can connect them locally, import them remotely, or request them through a provided callback

Tuner.Load offers both local and remote ways to connect a config.

Tuner.Load.local

Function Returns the config object from the file by...
absolutePath(path:string) ...specifying the full path to it.
configDir(path:string) ...specifying the path relative to the directory named "config".
cwd(path:string) ...specifying the path relative to the project directory.

Tuner.Load.remote

Function Description Example (assuming the config file is located at http://some_server/b.tuner.ts)
import(path:string) Works like a regular import. child: Tuner.Load.remote.import(”http://some_server/b.tuner.ts”)
callbackReturnModule(cb: () ⇒ Promise<{default: ITunerConfig}>) Accepts a callback that returns a promise with the imported module. child: Tuner.Load.remote.callbackReturnModule(() ⇒ import(”http://some_server/b.tuner.ts”))
callbackReturnString((cb: () => Promise)) Accepts a callback that returns a promise with the module's code as a string (retrieve the config code from forms, blocks in Notion, etc.) child: Tuner.Load.remote.callbackReturnString(() ⇒ someFetchingFunctionStringReturned(options: {…}))

Additionally, Tuner.Load.remote comes with built-in integrations with various services through Tuner.Load.remote.providers:

  • notion(key:string, blockUrl:string) - provide the authorization key (use Tuner.getEnv to find the env variable in the environment or .env file) and the link to the block in Notion where the configuration module is described.
  • github(key: string, owner: string, repo: string, filePath: string) - key, repository owner's username, repository name, and file path. Some may find the idea of configuring a project through Notion strange... I understand that. But it was more convenient for my project, so this integration is available. If you have any suggestions, feel free to let me know, adding them is not difficult :)

And the cherry on top - the config schema.

Working with the config would be unpleasant without a schema and displayed types.

For convenient development with the config object, it is recommended to generate a type for the object.

Tuner.use.generateSchema(obj: ObjectType, variableName: string, filePath: string) will create a file at the filePath with the schema of the obj object and export a type with the name variableName, capitalizing the first letter.

You can separate the schema generation process into a separate task, so that, for example, deno task schema would be responsible solely for updating the schema.

Example:

const tuner = await Tuner.use.loadConfig();
Tuner.use.generateSchema(
  tuner,
  'config',
  'config/configSchema.ts',
);
Enter fullscreen mode Exit fullscreen mode

We will see:

//config/configSchema.ts
import { z } from '<https://deno.land/x/zod/mod.ts>';
export const configSchema = z.object({
config: z.object({
a: z.number(),
b: z.number(),
c: z.number(),
e: z.number(),
d: z.number(),
}),
env: z.object({}),
});
export type Config = z.infer<typeof configSchema>;
//├─ config
//│  ├─ a
//│  ├─ b
//│  ├─ c
//│  ├─ e
//│  └─ d
//└─ env
Enter fullscreen mode Exit fullscreen mode

Now we can complement the code snippet with the initialization of the config type for Tuner, and we will have a powerful and informative way to interact with our config:

// main.ts
import Tuner from '<https://deno.land/x/tuner/mod.ts>';
import {Config} from "config/configSchema.ts"
export const tuner = (await Tuner.use.loadConfig()) as Config;
Enter fullscreen mode Exit fullscreen mode

Conclusion

Of course, this is just an example of how to organize working with relatively constant configuration data. There is room for growth and expansion.

A feature is already prepared in the form of a change observer (if a field in one of the configs is changed, an event or callback is triggered). However, in this version of Tuner (which I haven't released yet), there is a memory leak. Once I fix it, I'll release it to the Deno community. I would be grateful for any observations, constructive criticism, recommendations, and suggestions.

deno.land/x/tuner

Top comments (0)