0.53.3 • Published 2 days ago

@effect/platform v0.53.3

Weekly downloads
-
License
MIT
Repository
github
Last release
2 days ago

Introduction

Welcome to the documentation for @effect/platform, a library designed for creating platform-independent abstractions (Node.js, Bun, browsers).

With @effect/platform, you can incorporate abstract services like Terminal or FileSystem into your program. Later, during the assembly of the final application, you can provide specific layers for the target platform using the corresponding packages: platform-node, platform-bun, and platform-browser.

This package empowers you to perform various operations, such as:

OperationDescription
TerminalReading and writing from/to standard input/output
CommandCreating and running a command with the specified process name and an optional list of arguments
FileSystemReading and writing from/to the file system
HTTP ClientSending HTTP requests and receiving responses
HTTP ServerCreating HTTP servers to handle incoming requests
HTTP RouterRouting HTTP requests to specific handlers
KeyValueStoreStoring and retrieving key-value pairs
PlatformLoggerCreating a logger that writes to a specified file from another string logger

By utilizing @effect/platform, you can write code that remains platform-agnostic, ensuring compatibility across different environments.

Terminal

The @effect/platform/Terminal module exports a single Terminal tag, which serves as the entry point to reading from and writing to standard input and standard output.

Writing to standard output

import { Terminal } from "@effect/platform";
import { NodeRuntime, NodeTerminal } from "@effect/platform-node";
import { Effect } from "effect";

// const displayMessage: Effect.Effect<void, PlatformError, Terminal.Terminal>
const displayMessage = Effect.gen(function* (_) {
  const terminal = yield* _(Terminal.Terminal);
  yield* _(terminal.display("a message\n"));
});

NodeRuntime.runMain(displayMessage.pipe(Effect.provide(NodeTerminal.layer)));
// Output: "a message"

Reading from standard input

import { Terminal } from "@effect/platform";
import { NodeRuntime, NodeTerminal } from "@effect/platform-node";
import { Console, Effect } from "effect";

// const readLine: Effect.Effect<void, Terminal.QuitException, Terminal.Terminal>
const readLine = Effect.gen(function* (_) {
  const terminal = yield* _(Terminal.Terminal);
  const input = yield* _(terminal.readLine);
  yield* _(Console.log(`input: ${input}`));
});

NodeRuntime.runMain(readLine.pipe(Effect.provide(NodeTerminal.layer)));
// Input: "hello"
// Output: "input: hello"

These simple examples illustrate how to utilize the Terminal module for handling standard input and output in your programs. Let's use this knowledge to build a number guessing game:

import { Terminal } from "@effect/platform";
import type { PlatformError } from "@effect/platform/Error";
import { Effect, Option, Random } from "effect";

export const secret = Random.nextIntBetween(1, 100);

const parseGuess = (input: string) => {
  const n = parseInt(input, 10);
  return isNaN(n) || n < 1 || n > 100 ? Option.none() : Option.some(n);
};

const display = (message: string) =>
  Effect.gen(function* (_) {
    const terminal = yield* _(Terminal.Terminal);
    yield* _(terminal.display(`${message}\n`));
  });

const prompt = Effect.gen(function* (_) {
  const terminal = yield* _(Terminal.Terminal);
  yield* _(terminal.display("Enter a guess: "));
  return yield* _(terminal.readLine);
});

const answer: Effect.Effect<
  number,
  Terminal.QuitException | PlatformError,
  Terminal.Terminal
> = Effect.gen(function* (_) {
  const input = yield* _(prompt);
  const guess = parseGuess(input);
  if (Option.isNone(guess)) {
    yield* _(display("You must enter an integer from 1 to 100"));
    return yield* _(answer);
  }
  return guess.value;
});

const check = <A, E, R>(
  secret: number,
  guess: number,
  ok: Effect.Effect<A, E, R>,
  ko: Effect.Effect<A, E, R>
): Effect.Effect<A, E | PlatformError, R | Terminal.Terminal> =>
  Effect.gen(function* (_) {
    if (guess > secret) {
      yield* _(display("Too high"));
      return yield* _(ko);
    } else if (guess < secret) {
      yield* _(display("Too low"));
      return yield* _(ko);
    } else {
      return yield* _(ok);
    }
  });

const end = display("You guessed it!");

const loop = (
  secret: number
): Effect.Effect<
  void,
  Terminal.QuitException | PlatformError,
  Terminal.Terminal
> =>
  Effect.gen(function* (_) {
    const guess = yield* _(answer);
    return yield* _(
      check(
        secret,
        guess,
        end,
        Effect.suspend(() => loop(secret))
      )
    );
  });

export const game = Effect.gen(function* (_) {
  yield* _(
    display(
      "We have selected a random number between 1 and 100. See if you can guess it in 10 turns or fewer. We'll tell you if your guess was too high or too low."
    )
  );
  yield* _(loop(yield* _(secret)));
});

Let's run the game in Node.js:

import { NodeRuntime, NodeTerminal } from "@effect/platform-node";
import * as Effect from "effect/Effect";
import { game } from "./game.js";

NodeRuntime.runMain(game.pipe(Effect.provide(NodeTerminal.layer)));

Let's run the game in Bun:

import { BunRuntime, BunTerminal } from "@effect/platform-bun";
import * as Effect from "effect/Effect";
import { game } from "./game.js";

BunRuntime.runMain(game.pipe(Effect.provide(BunTerminal.layer)));

Command

As an example of using the @effect/platform/Command module, let's see how to run the TypeScript compiler tsc:

import { Command, CommandExecutor } from "@effect/platform";
import {
  NodeCommandExecutor,
  NodeFileSystem,
  NodeRuntime,
} from "@effect/platform-node";
import { Effect } from "effect";

// const program: Effect.Effect<string, PlatformError, CommandExecutor.CommandExecutor>
const program = Effect.gen(function* (_) {
  const executor = yield* _(CommandExecutor.CommandExecutor);

  // Creating a command to run the TypeScript compiler
  const command = Command.make("tsc", "--noEmit");
  console.log("Running tsc...");

  // Executing the command and capturing the output
  const output = yield* _(executor.string(command));
  console.log(output);
  return output;
});

// Running the program with the necessary runtime and executor layers
NodeRuntime.runMain(
  program.pipe(
    Effect.provide(NodeCommandExecutor.layer),
    Effect.provide(NodeFileSystem.layer)
  )
);

Obtaining Information About the Running Process

Here, we'll explore how to retrieve information about a running process.

import { Command, CommandExecutor } from "@effect/platform";
import {
  NodeCommandExecutor,
  NodeFileSystem,
  NodeRuntime,
} from "@effect/platform-node";
import { Effect, Stream, String } from "effect";

const runString = <E, R>(
  stream: Stream.Stream<Uint8Array, E, R>
): Effect.Effect<string, E, R> =>
  stream.pipe(Stream.decodeText(), Stream.runFold(String.empty, String.concat));

const program = Effect.gen(function* (_) {
  const executor = yield* _(CommandExecutor.CommandExecutor);

  const command = Command.make("ls");

  const [exitCode, stdout, stderr] = yield* _(
    // Start running the command and return a handle to the running process.
    executor.start(command),
    Effect.flatMap((process) =>
      Effect.all(
        [
          // Waits for the process to exit and returns the ExitCode of the command that was run.
          process.exitCode,
          // The standard output stream of the process.
          runString(process.stdout),
          // The standard error stream of the process.
          runString(process.stderr),
        ],
        { concurrency: 3 }
      )
    )
  );
  console.log({ exitCode, stdout, stderr });
});

NodeRuntime.runMain(
  Effect.scoped(program).pipe(
    Effect.provide(NodeCommandExecutor.layer),
    Effect.provide(NodeFileSystem.layer)
  )
);

FileSystem

The @effect/platform/FileSystem module provides a single FileSystem tag, which acts as the gateway for interacting with the filesystem.

Here's a list of operations that can be performed using the FileSystem tag:

NameArgumentsReturnDescription
accesspath: string, options?: AccessFileOptionsEffect<void, PlatformError>Check if a file can be accessed. You can optionally specify the level of access to check for.
copyfromPath: string, toPath: string, options?: CopyOptionsEffect<void, PlatformError>Copy a file or directory from fromPath to toPath. Equivalent to cp -r.
copyFilefromPath: string, toPath: stringEffect<void, PlatformError>Copy a file from fromPath to toPath.
chmodpath: string, mode: numberEffect<void, PlatformError>Change the permissions of a file.
chownpath: string, uid: number, gid: numberEffect<void, PlatformError>Change the owner and group of a file.
existspath: stringEffect<boolean, PlatformError>Check if a path exists.
linkfromPath: string, toPath: stringEffect<void, PlatformError>Create a hard link from fromPath to toPath.
makeDirectorypath: string, options?: MakeDirectoryOptionsEffect<void, PlatformError>Create a directory at path. You can optionally specify the mode and whether to recursively create nested directories.
makeTempDirectoryoptions?: MakeTempDirectoryOptionsEffect<string, PlatformError>Create a temporary directory. By default, the directory will be created inside the system's default temporary directory.
makeTempDirectoryScopedoptions?: MakeTempDirectoryOptionsEffect<string, PlatformError, Scope>Create a temporary directory inside a scope. Functionally equivalent to makeTempDirectory, but the directory will be automatically deleted when the scope is closed.
makeTempFileoptions?: MakeTempFileOptionsEffect<string, PlatformError>Create a temporary file. The directory creation is functionally equivalent to makeTempDirectory. The file name will be a randomly generated string.
makeTempFileScopedoptions?: MakeTempFileOptionsEffect<string, PlatformError, Scope>Create a temporary file inside a scope. Functionally equivalent to makeTempFile, but the file will be automatically deleted when the scope is closed.
openpath: string, options?: OpenFileOptionsEffect<File, PlatformError, Scope>Open a file at path with the specified options. The file handle will be automatically closed when the scope is closed.
readDirectorypath: string, options?: ReadDirectoryOptionsEffect<ReadonlyArray<string>, PlatformError>List the contents of a directory. You can recursively list the contents of nested directories by setting the recursive option.
readFilepath: stringEffect<Uint8Array, PlatformError>Read the contents of a file.
readFileStringpath: string, encoding?: stringEffect<string, PlatformError>Read the contents of a file as a string.
readLinkpath: stringEffect<string, PlatformError>Read the destination of a symbolic link.
realPathpath: stringEffect<string, PlatformError>Resolve a path to its canonicalized absolute pathname.
removepath: string, options?: RemoveOptionsEffect<void, PlatformError>Remove a file or directory. By setting the recursive option to true, you can recursively remove nested directories.
renameoldPath: string, newPath: stringEffect<void, PlatformError>Rename a file or directory.
sinkpath: string, options?: SinkOptionsSink<void, Uint8Array, never, PlatformError>Create a writable Sink for the specified path.
statpath: stringEffect<File.Info, PlatformError>Get information about a file at path.
streampath: string, options?: StreamOptionsStream<Uint8Array, PlatformError>Create a readable Stream for the specified path.
symlinkfromPath: string, toPath: stringEffect<void, PlatformError>Create a symbolic link from fromPath to toPath.
truncatepath: string, length?: SizeInputEffect<void, PlatformError>Truncate a file to a specified length. If the length is not specified, the file will be truncated to length 0.
utimespath: string, atime: Date \| number, mtime: Date \| numberEffect<void, PlatformError>Change the file system timestamps of the file at path.
watchpath: stringStream<WatchEvent, PlatformError>Watch a directory or file for changes.

Let's explore a simple example using readFileString:

import { FileSystem } from "@effect/platform";
import { NodeFileSystem, NodeRuntime } from "@effect/platform-node";
import { Effect } from "effect";

// const readFileString: Effect.Effect<void, PlatformError, FileSystem.FileSystem>
const readFileString = Effect.gen(function* (_) {
  const fs = yield* _(FileSystem.FileSystem);

  // Reading the content of the same file where this code is written
  const content = yield* _(fs.readFileString("./index.ts", "utf8"));
  console.log(content);
});

NodeRuntime.runMain(readFileString.pipe(Effect.provide(NodeFileSystem.layer)));

HTTP Client

Retrieving Data (GET)

In this section, we'll explore how to retrieve data using the HttpClient module from @effect/platform.

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Console, Effect } from "effect";

const getPostAsJson = Http.request
  .get("https://jsonplaceholder.typicode.com/posts/1")
  .pipe(Http.client.fetch, Http.response.json);

NodeRuntime.runMain(
  getPostAsJson.pipe(Effect.andThen((post) => Console.log(typeof post, post)))
);
/*
Output:
object {
  userId: 1,
  id: 1,
  title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
  body: 'quia et suscipit\n' +
    'suscipit recusandae consequuntur expedita et cum\n' +
    'reprehenderit molestiae ut ut quas totam\n' +
    'nostrum rerum est autem sunt rem eveniet architecto'
}
*/

If you want a response in a different format other than JSON, you can utilize other APIs provided by Http.response.

In the following example, we fetch the post as text:

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Console, Effect } from "effect";

const getPostAsText = Http.request
  .get("https://jsonplaceholder.typicode.com/posts/1")
  .pipe(Http.client.fetch, Http.response.text);

NodeRuntime.runMain(
  getPostAsText.pipe(Effect.andThen((post) => Console.log(typeof post, post)))
);
/*
Output:
string {
  userId: 1,
  id: 1,
  title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit',
  body: 'quia et suscipit\n' +
    'suscipit recusandae consequuntur expedita et cum\n' +
    'reprehenderit molestiae ut ut quas totam\n' +
    'nostrum rerum est autem sunt rem eveniet architecto'
}
*/

Here are some APIs you can use to convert the response:

APIDescription
Http.response.arrayBufferConvert to ArrayBuffer
Http.response.formDataConvert to FormData
Http.response.jsonConvert to JSON
Http.response.streamConvert to a Stream of Uint8Array
Http.response.textConvert to text
Http.response.urlParamsBodyConvert to Http.urlParams.UrlParams

Setting Headers

When making HTTP requests, sometimes you need to include additional information in the request headers. You can set headers using the setHeader function for a single header or setHeaders for multiple headers simultaneously.

import * as Http from "@effect/platform/HttpClient";

const getPost = Http.request
  .get("https://jsonplaceholder.typicode.com/posts/1")
  .pipe(
    // Setting a single header
    Http.request.setHeader("Content-type", "application/json; charset=UTF-8"),
    // Setting multiple headers
    Http.request.setHeaders({
      "Content-type": "application/json; charset=UTF-8",
      Foo: "Bar",
    }),
    Http.client.fetch
  );

Decoding Data with Schemas

A common use case when fetching data is to validate the received format. For this purpose, the HttpClient module is integrated with @effect/schema.

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Schema } from "@effect/schema";
import { Console, Effect } from "effect";

const Post = Schema.struct({
  id: Schema.number,
  title: Schema.string,
});

/*
const getPostAndValidate: Effect.Effect<{
    readonly id: number;
    readonly title: string;
}, Http.error.HttpClientError | ParseError, never>
*/
const getPostAndValidate = Http.request
  .get("https://jsonplaceholder.typicode.com/posts/1")
  .pipe(
    Http.client.fetch,
    Effect.andThen(Http.response.schemaBodyJson(Post)),
    Effect.scoped
  );

NodeRuntime.runMain(getPostAndValidate.pipe(Effect.andThen(Console.log)));
/*
Output:
{
  id: 1,
  title: 'sunt aut facere repellat provident occaecati excepturi optio reprehenderit'
}
*/

In this example, we define a schema for a post object with properties id and title. Then, we fetch the data and validate it against this schema using Http.response.schemaBodyJson. Finally, we log the validated post object.

Note that we use Effect.scoped after consuming the response. This ensures that any resources associated with the HTTP request are properly cleaned up once we're done processing the response.

Filtering And Error Handling

It's important to note that Http.client.fetch doesn't consider non-200 status codes as errors by default. This design choice allows for flexibility in handling different response scenarios. For instance, you might have a schema union where the status code serves as the discriminator, enabling you to define a schema that encompasses all possible response cases.

You can use Http.client.filterStatusOk, or Http.client.fetchOk to ensure only 2xx responses are treated as successes.

In this example, we attempt to fetch a non-existent page and don't receive any error:

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Console, Effect } from "effect";

const getText = Http.request
  .get("https://jsonplaceholder.typicode.com/non-existing-page")
  .pipe(Http.client.fetch, Http.response.text);

NodeRuntime.runMain(getText.pipe(Effect.andThen(Console.log)));
/*
Output:
{}
*/

However, if we use Http.client.filterStatusOk, an error is logged:

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Console, Effect } from "effect";

const getText = Http.request
  .get("https://jsonplaceholder.typicode.com/non-existing-page")
  .pipe(Http.client.filterStatusOk(Http.client.fetch), Http.response.text);

NodeRuntime.runMain(getText.pipe(Effect.andThen(Console.log)));
/*
Output:
timestamp=2024-03-25T10:21:16.972Z level=ERROR fiber=#0 cause="ResponseError: StatusCode error (404 GET https://jsonplaceholder.typicode.com/non-existing-page): non 2xx status code
*/

Note that you can use Http.client.fetchOk as a shortcut for Http.client.filterStatusOk(Http.client.fetch):

const getText = Http.request
  .get("https://jsonplaceholder.typicode.com/non-existing-page")
  .pipe(Http.client.fetchOk, Http.response.text);

You can also create your own status-based filters. In fact, Http.client.filterStatusOk is just a shortcut for the following filter:

const getText = Http.request
  .get("https://jsonplaceholder.typicode.com/non-existing-page")
  .pipe(
    Http.client.filterStatus(
      Http.client.fetch,
      (status) => status >= 200 && status < 300
    ),
    Http.response.text
  );

POST

To make a POST request, you can use the Http.request.post function provided by the HttpClient module. Here's an example of how to create and send a POST request:

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Console, Effect } from "effect";

const addPost = Http.request
  .post("https://jsonplaceholder.typicode.com/posts")
  .pipe(
    Http.request.jsonBody({
      title: "foo",
      body: "bar",
      userId: 1,
    }),
    Effect.andThen(Http.client.fetch),
    Http.response.json
  );

NodeRuntime.runMain(addPost.pipe(Effect.andThen(Console.log)));
/*
Output:
{ title: 'foo', body: 'bar', userId: 1, id: 101 }
*/

If you need to send data in a format other than JSON, such as plain text, you can use different APIs provided by Http.request.

In the following example, we send the data as text:

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Console, Effect } from "effect";

const addPost = Http.request
  .post("https://jsonplaceholder.typicode.com/posts")
  .pipe(
    Http.request.textBody(
      JSON.stringify({
        title: "foo",
        body: "bar",
        userId: 1,
      }),
      "application/json; charset=UTF-8"
    ),
    Http.client.fetch,
    Http.response.json
  );

NodeRuntime.runMain(Effect.andThen(addPost, Console.log));
/*
Output:
{ title: 'foo', body: 'bar', userId: 1, id: 101 }
*/

Decoding Data with Schemas

A common use case when fetching data is to validate the received format. For this purpose, the HttpClient module is integrated with @effect/schema.

import { NodeRuntime } from "@effect/platform-node";
import * as Http from "@effect/platform/HttpClient";
import { Schema } from "@effect/schema";
import { Console, Effect } from "effect";

const Post = Schema.struct({
  id: Schema.number,
  title: Schema.string,
});

const addPost = Http.request
  .post("https://jsonplaceholder.typicode.com/posts")
  .pipe(
    Http.request.jsonBody({
      title: "foo",
      body: "bar",
      userId: 1,
    }),
    Effect.andThen(Http.client.fetch),
    Effect.andThen(Http.response.schemaBodyJson(Post)),
    Effect.scoped
  );

NodeRuntime.runMain(addPost.pipe(Effect.andThen(Console.log)));
/*
Output:
{ id: 101, title: 'foo' }
*/
0.53.3

2 days ago

0.53.2

4 days ago

0.53.1

5 days ago

0.53.0

5 days ago

0.52.3

7 days ago

0.52.2

10 days ago

0.52.1

11 days ago

0.52.0

11 days ago

0.51.0

12 days ago

0.50.8

13 days ago

0.50.7

15 days ago

0.50.6

16 days ago

0.50.5

17 days ago

0.50.4

18 days ago

0.50.3

20 days ago

0.50.2

22 days ago

0.49.4

24 days ago

0.50.1

23 days ago

0.50.0

23 days ago

0.49.1

24 days ago

0.49.2

24 days ago

0.49.3

24 days ago

0.49.0

26 days ago

0.48.29

27 days ago

0.48.28

27 days ago

0.48.27

1 month ago

0.48.26

1 month ago

0.48.25

1 month ago

0.48.24

1 month ago

0.48.23

1 month ago

0.48.22

1 month ago

0.48.21

2 months ago

0.48.19

2 months ago

0.48.18

2 months ago

0.48.20

2 months ago

0.48.17

2 months ago

0.48.16

2 months ago

0.48.15

2 months ago

0.48.14

2 months ago

0.48.13

2 months ago

0.48.12

2 months ago

0.48.11

2 months ago

0.48.10

2 months ago

0.48.9

2 months ago

0.48.8

2 months ago

0.48.6

2 months ago

0.48.7

2 months ago

0.48.5

2 months ago

0.48.4

2 months ago

0.48.2

2 months ago

0.48.3

2 months ago

0.48.0

2 months ago

0.48.1

2 months ago

0.47.1

2 months ago

0.47.0

2 months ago

0.46.3

2 months ago

0.46.2

2 months ago

0.46.1

3 months ago

0.46.0

3 months ago

0.45.6

3 months ago

0.45.5

3 months ago

0.45.4

3 months ago

0.45.3

3 months ago

0.45.2

3 months ago

0.44.7

3 months ago

0.45.1

3 months ago

0.45.0

3 months ago

0.44.6

3 months ago

0.44.4

3 months ago

0.44.5

3 months ago

0.44.3

3 months ago

0.44.2

3 months ago

0.44.1

3 months ago

0.44.0

3 months ago

0.43.11

3 months ago

0.43.10

3 months ago

0.43.8

3 months ago

0.43.9

3 months ago

0.43.7

3 months ago

0.43.5

3 months ago

0.43.6

3 months ago

0.43.4

3 months ago

0.43.3

4 months ago

0.43.1

4 months ago

0.43.2

4 months ago

0.43.0

4 months ago

0.42.7

4 months ago

0.42.6

4 months ago

0.42.5

4 months ago

0.42.4

4 months ago

0.42.2

4 months ago

0.42.3

4 months ago

0.42.0

4 months ago

0.42.1

4 months ago

0.41.0

4 months ago

0.40.4

4 months ago

0.40.2

4 months ago

0.40.3

4 months ago

0.40.1

4 months ago

0.40.0

4 months ago

0.39.0

5 months ago

0.37.8

5 months ago

0.37.7

5 months ago

0.38.0

5 months ago

0.37.6

5 months ago

0.37.5

5 months ago

0.37.4

5 months ago

0.37.3

5 months ago

0.37.2

5 months ago

0.37.1

5 months ago

0.36.0

5 months ago

0.37.0

5 months ago

0.35.0

5 months ago

0.34.0

5 months ago

0.33.1

5 months ago

0.33.0

5 months ago

0.20.0

7 months ago

0.13.6

9 months ago

0.13.7

9 months ago

0.13.8

9 months ago

0.13.9

9 months ago

0.32.2

5 months ago

0.32.1

5 months ago

0.13.0

9 months ago

0.13.1

9 months ago

0.13.2

9 months ago

0.13.3

9 months ago

0.17.0

8 months ago

0.13.4

9 months ago

0.17.1

8 months ago

0.13.5

9 months ago

0.32.0

5 months ago

0.29.0

6 months ago

0.3.0

10 months ago

0.25.1

7 months ago

0.25.0

7 months ago

0.7.0

10 months ago

0.29.1

6 months ago

0.21.0

7 months ago

0.18.1

8 months ago

0.18.2

8 months ago

0.18.3

7 months ago

0.18.4

7 months ago

0.18.5

7 months ago

0.18.6

7 months ago

0.18.7

7 months ago

0.10.1

9 months ago

0.10.2

9 months ago

0.10.3

9 months ago

0.14.0

8 months ago

0.10.4

9 months ago

0.14.1

8 months ago

0.18.0

8 months ago

0.10.0

10 months ago

0.26.3

6 months ago

0.26.2

7 months ago

0.26.1

7 months ago

0.26.0

7 months ago

0.22.1

7 months ago

0.22.0

7 months ago

0.8.0

10 months ago

0.26.7

6 months ago

0.26.6

6 months ago

0.4.0

10 months ago

0.26.5

6 months ago

0.26.4

6 months ago

0.19.0

7 months ago

0.30.6

6 months ago

0.30.5

6 months ago

0.30.4

6 months ago

0.30.3

6 months ago

0.11.0

9 months ago

0.11.1

9 months ago

0.11.2

9 months ago

0.11.3

9 months ago

0.15.0

8 months ago

0.11.4

9 months ago

0.15.1

8 months ago

0.11.5

9 months ago

0.15.2

8 months ago

0.13.12

9 months ago

0.13.11

9 months ago

0.13.10

9 months ago

0.30.2

6 months ago

0.30.1

6 months ago

0.30.0

6 months ago

0.27.2

6 months ago

0.27.1

6 months ago

0.27.0

6 months ago

0.23.1

7 months ago

0.23.0

7 months ago

0.13.16

9 months ago

0.9.0

10 months ago

0.13.15

9 months ago

0.13.14

9 months ago

0.13.13

9 months ago

0.5.0

10 months ago

0.27.4

6 months ago

0.27.3

6 months ago

0.31.2

6 months ago

0.12.0

9 months ago

0.12.1

9 months ago

0.16.0

8 months ago

0.16.1

8 months ago

0.31.1

6 months ago

0.31.0

6 months ago

0.28.1

6 months ago

0.28.0

6 months ago

0.2.0

10 months ago

0.24.0

7 months ago

0.28.4

6 months ago

0.28.3

6 months ago

0.28.2

6 months ago

0.6.0

10 months ago

0.1.0

11 months ago

0.0.0

11 months ago