-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1 from Avaq/avaq/everything
Add everything
- Loading branch information
Showing
21 changed files
with
1,466 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
name: PR | ||
|
||
on: | ||
pull_request: | ||
|
||
jobs: | ||
test: | ||
name: Test | ||
runs-on: ubuntu-latest | ||
env: | ||
node-version: 20.x | ||
|
||
steps: | ||
- name: Checkout Code | ||
uses: actions/checkout@v3 | ||
|
||
- name: Install NodeJS | ||
uses: actions/setup-node@v3 | ||
with: | ||
node-version: ${{ env.node-version }} | ||
|
||
- name: Cache Node Modules | ||
uses: actions/cache@v3 | ||
id: cache-node-modules | ||
with: | ||
path: node_modules | ||
key: ${{ runner.OS }}-node${{ env.node-version }}-ci-${{ hashFiles('**/package-lock.json') }} | ||
|
||
- name: Install Dependencies | ||
if: steps.cache-node-modules.outputs.cache-hit != 'true' | ||
run: npm install --ignore-scripts | ||
|
||
- name: Run unit tests | ||
run: npm run test |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
/lib/ | ||
/node_modules/ |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
Copyright 2023 Aldwin Vlasblom | ||
|
||
Permission to use, copy, modify, and/or distribute this software for any purpose | ||
with or without fee is hereby granted, provided that the above copyright notice | ||
and this permission notice appear in all copies. | ||
|
||
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH | ||
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND | ||
FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, | ||
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS | ||
OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER | ||
TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF | ||
THIS SOFTWARE. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1 +1,238 @@ | ||
# FP-TS Bootstrap | ||
|
||
This is a module aimed at application bootstrapping using types from [fp-ts][]. | ||
Its ideas and most of the code were ported from the [fluture-hooks][] library. | ||
|
||
This module mainly provides a [Bracket type](#bracket) with accompanying type | ||
class instances. The Bracket type is a drop-in replacement for the Cont type | ||
from [fp-ts-cont][], but specialized in returning `TaskEither`. This solves the | ||
problem stipulated at the end of [application bootstrapping with fp-ts][] by | ||
allowing the return type to be threaded through the program. Furthermore, it | ||
makes the `ApplicativePar` instance possible, which allows for parallel | ||
composition of bracketed resources. | ||
|
||
Besides the Bracket type, this module also provides a [Service type](#service) | ||
which is a small layer on top for managing dependencies through the Reader monad. | ||
|
||
[fp-ts]: https://gcanti.github.io/fp-ts/ | ||
[fluture-hooks]: https://github.com/fluture-js/fluture-hooks | ||
[fp-ts-cont]: https://github.com/joshburgess/fp-ts-cont | ||
[application bootstrapping with fp-ts]: https://dev.to/avaq/application-bootstrapping-with-fp-ts-59b5 | ||
|
||
## Example | ||
|
||
Define your service. See the full example in | ||
[`./example/services/server.ts`](./example/services/server.ts). | ||
|
||
```ts | ||
export const withServer: Service.Service<Error, Dependencies, HTTP.Server> = ( | ||
({port, app}) => Bracket.bracket( | ||
() => new Promise(resolve => { | ||
const server = HTTP.createServer(app); | ||
server.listen(port, () => resolve(E.right(server))); | ||
}), | ||
server => () => new Promise(resolve => { | ||
server.close((e: unknown) => resolve( | ||
e instanceof Error ? E.left(e) : E.right(undefined) | ||
)); | ||
}), | ||
) | ||
); | ||
``` | ||
|
||
Combine multiple such services with ease using Do notation. See the full example | ||
in [`./example/services/index.ts`](./example/services/index.ts). | ||
|
||
```ts | ||
export const withServices = pipe( | ||
withEnv, | ||
Bracket.bindTo('env'), | ||
Bracket.bind('logger', ({env}) => withLogger({level: env.LOG_LEVEL})), | ||
Bracket.bind('database', ({env, logger}) => withDatabase({ | ||
url: env.DATABASE_URL, | ||
logger: logger | ||
})), | ||
Bracket.bind('app', ({database}) => withApp({database})), | ||
Bracket.bind('server', ({env, app}) => withServer({ | ||
port: env.PORT, | ||
app: app, | ||
})), | ||
); | ||
``` | ||
|
||
Consume your service. See the full example in [`./example/index.ts`](./example/index.ts). | ||
|
||
```ts | ||
const program = withServices(({server, logger}) => pipe( | ||
TE.fromIO(logger.info(`Server listening on ${JSON.stringify(server.address())}`)), | ||
TE.apSecond(TE.fromTask(() => new Promise(resolve => { | ||
process.once('SIGINT', resolve); | ||
}))), | ||
TE.chain(() => TE.fromIO(logger.info('Shutting down app'))), | ||
)); | ||
``` | ||
|
||
And finally, run your program: | ||
|
||
```ts | ||
program().then(E.fold(console.error, console.log), console.error); | ||
``` | ||
|
||
## Types | ||
|
||
### Bracket | ||
|
||
```ts | ||
import {Bracket} from 'fp-ts-bootstrap'; | ||
``` | ||
|
||
```ts | ||
type Bracket<E, R> = ( | ||
<T>(consume: (resource: R) => TaskEither<E, T>) => TaskEither<E, T> | ||
); | ||
``` | ||
|
||
The Bracket type aliases the structure that's encountered when using a curried | ||
variant of [fp-ts' `TaskEither.bracket` function][]. This curried variant is | ||
also exported from the Bracket module as `bracket`. It models a bracketed | ||
resource for which the consumption hasn't been specified yet. | ||
|
||
[fp-ts' `TaskEither.bracket` function]: https://gcanti.github.io/fp-ts/modules/TaskEither.ts.html#bracket | ||
|
||
The Bracket module defines various type class instances for `Bracket` that allow | ||
you to compose and combine multiple bracketed resources. From most instances, | ||
some derivative functions are exported as well. | ||
|
||
- Pointed: `of`, `Do` | ||
- Functor: `map`, `flap`, `bindTo`, `let` | ||
- Apply: `ap`, `apFirst`, `apSecond`, `apS`, `getApplySemigroup`, `sequenceT`, `sequenceS` | ||
- Applicative: Pointed Apply | ||
- Chain: `chain`, `chainFirst`, `bind` | ||
- Monad: Pointed Chain | ||
- ApplyPar: `apPar`, `apFirstPar`, `apSecondPar`, `apSPar`, `getApplySemigroupPar`, `sequenceTPar`, `sequenceSPar` | ||
- ApplicativePar: Pointed ApplyPar | ||
|
||
### Service | ||
|
||
```ts | ||
import {Service} from 'fp-ts-bootstrap'; | ||
``` | ||
|
||
```ts | ||
type Service<E, D, S> = Reader<D, Bracket<E, S>>; | ||
``` | ||
|
||
The Service type is a small layer on top of Reader that formalizes the | ||
type of a Bracket with dependencies. The Service type can also be composed and | ||
combined using the utilities provided by `ReaderT<Bracket>`. These utilities | ||
are re-exported from [the Service module](./src/Service.ts). | ||
|
||
## Cookbook | ||
|
||
### Defining a service with acquisition and disposal | ||
|
||
```ts | ||
import * as FS from 'fs/promises'; | ||
import * as TE from 'fp-ts/TaskEither'; | ||
import * as E from 'fp-ts/Either'; | ||
import {Bracket} from 'fp-ts-bootstrap'; | ||
|
||
const acquireFileHandle = (url: string) => ( | ||
TE.tryCatch(() => FS.open(url, 'a'), E.toError) | ||
); | ||
|
||
const disposeFileHandle = (file: FS.FileHandle) => ( | ||
TE.tryCatch(() => file.close(), E.toError) | ||
); | ||
|
||
const withMyFile = Bracket.bracket( | ||
acquireFileHandle('/tmp/my-file.txt'), | ||
disposeFileHandle, | ||
); | ||
``` | ||
|
||
### Defining a service with dependencies | ||
|
||
This recipe builds on the previous one by adding dependencies to the service. | ||
|
||
```ts | ||
import {Service} from 'fp-ts-bootstrap/lib/Service'; | ||
|
||
type Dependencies = { | ||
url: string; | ||
}; | ||
|
||
const withMyFile: Service<Error, Dependencies, FS.FileHandle> = ( | ||
({url}) => Bracket.bracket( | ||
acquireFileHandle(url), | ||
disposeFileHandle, | ||
) | ||
); | ||
``` | ||
|
||
### Combining services in parallel | ||
|
||
The Bracket type has a sequential `Applicative` instance that it uses by | ||
default, but there's also a parallel `ApplicativePar` instance that you can use | ||
to combine services in parallel\*. Two very useful derivative function using | ||
`ApplicativePar` are | ||
|
||
- `sequenceSPar` for building a Struct of resources from a Struct of Brackets; and | ||
- `apSPar` for adding another property to an existing Struct of services: | ||
|
||
```ts | ||
import {pipe} from 'fp-ts/function'; | ||
import {Bracket} from 'fp-ts-bootstrap'; | ||
|
||
const withServices = pipe( | ||
Bracket.sequenceSPar({ | ||
env: withEnv, | ||
logger: withLogger({level: 'info'}), | ||
}), | ||
Bracket.apSPar('database', withDatabase({url: 'postgres://localhost:5432'})) | ||
); | ||
|
||
const program = withServices(({env, logger, database}) => pipe( | ||
// ... | ||
)); | ||
``` | ||
|
||
\* By "in parallel" we mean that the services are *acquired* in parallel, but | ||
disposed in sequence. This is a technical limitation that exists to ensure that | ||
the `ApplyPar` instance is lawful. | ||
|
||
### Threading dependencies during service composition | ||
|
||
```ts | ||
import {pipe} from 'fp-ts/function'; | ||
import {Bracket} from 'fp-ts-bootstrap'; | ||
|
||
const withServices = pipe( | ||
withEnv, | ||
Bracket.bindTo('env'), | ||
Bracket.bind('logger', ({env}) => withLogger({level: env.LOG_LEVEL})), | ||
Bracket.bind('database', ({env, logger}) => withDatabase({ | ||
url: env.DATABASE_URL, | ||
logger: logger | ||
})), | ||
Bracket.bind('server', ({env, database}) => withServer({ | ||
port: env.PORT, | ||
app: app, | ||
database: database, | ||
})), | ||
); | ||
``` | ||
|
||
### Creating a full-fledged program by composing services | ||
|
||
There's a fully working example app in the [`./example`](./example) directory. | ||
To run it, clone this repo and run the following commands: | ||
|
||
```console | ||
$ npm install | ||
$ ./node_modules/.bin/ts-node ./example/index.ts | ||
``` | ||
|
||
You should now be able to visit http://localhost:3000/arbitrary/path, | ||
which should give you a Hello World response, and log your request URL | ||
to `./database.txt`. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,15 @@ | ||
import {pipe} from 'fp-ts/function'; | ||
import * as TE from 'fp-ts/TaskEither'; | ||
import * as E from 'fp-ts/Either'; | ||
|
||
import {withServices} from './services'; | ||
|
||
const program = withServices(({server, logger}) => pipe( | ||
TE.fromIO(logger.info(`Server listening on ${JSON.stringify(server.address())}`)), | ||
TE.apSecond(TE.fromTask(() => new Promise(resolve => { | ||
process.once('SIGINT', resolve); | ||
}))), | ||
TE.chain(() => TE.fromIO(logger.info('Shutting down app'))), | ||
)); | ||
|
||
program().then(E.fold(console.error, console.log), console.error); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
import * as HTTP from 'node:http'; | ||
|
||
import {pipe} from 'fp-ts/function'; | ||
import * as TE from 'fp-ts/TaskEither'; | ||
import * as E from 'fp-ts/Either'; | ||
|
||
import * as Bracket from '../../src/Bracket'; | ||
import * as Service from '../../src/Service'; | ||
|
||
import {Database} from './database'; | ||
|
||
/*\ | ||
* | ||
* This service provides an HTTP Request Listener that logs the request URL | ||
* and the current time to the database, and returns a "Hello, world!" response. | ||
* | ||
* Its purpose is to demonstrate how to use the Bracket module to create a | ||
* service that depends on other services. | ||
* | ||
\*/ | ||
|
||
export type Dependencies = { | ||
database: Database; | ||
}; | ||
|
||
export const withApp: Service.Service<Error, Dependencies, HTTP.RequestListener> = ( | ||
({database}) => Bracket.of((req, res) => { | ||
const task = pipe( | ||
TE.fromIO(() => new Date()), | ||
TE.chain(now => database.save(`Visit to ${req.url} at ${now.toISOString()}`)), | ||
TE.map(() => 'Hello, world!'), | ||
); | ||
|
||
task().then(E.fold( | ||
e => { | ||
res.writeHead(500, {'Content-Type': 'text/plain'}); | ||
res.end(e.message); | ||
}, | ||
data => { | ||
res.writeHead(200, {'Content-Type': 'text/plain'}); | ||
res.end(data); | ||
}, | ||
)) | ||
}) | ||
); | ||
|
||
export type App = Service.ResourceOf<typeof withApp>; |
Oops, something went wrong.