@typed/fp
Version:
Data Structures and Resources for fp-ts
483 lines (385 loc) • 10 kB
text/typescript
/**
* @typed/fp/node is a place to place implementations of environment from other modules that require or
* are best used with implementations specifically for node.js.
* @since 0.9.4
*/
import * as Ei from 'fp-ts/Either'
import * as fs from 'fs'
import fetch from 'node-fetch'
import * as D from './Disposable'
import { HistoryEnv, LocationEnv } from './dom'
import * as E from './Env'
import { fromPromiseK } from './EnvEither'
import * as http from './http'
import * as R from './Resume'
/**
* @category Environment
* @since 0.9.4
*/
export const HttpEnv: http.HttpEnv = { http: E.fromResumeK(httpFetchRequest) }
function httpFetchRequest(
uri: string,
options: http.HttpOptions = {},
): R.Resume<Ei.Either<Error, http.HttpResponse>> {
return R.async((cb) => {
const { method = 'GET', headers = {}, body } = options
const disposable = D.settable()
const abortController = new AbortController()
disposable.addDisposable({
dispose: () => abortController.abort(),
})
const init = {
method,
headers: Object.entries(headers).map(([key, value = '']) => [key, value]),
body: body ?? undefined,
credentials: 'include',
signal: abortController.signal,
}
async function makeRequest() {
const response = await fetch(uri, init)
const headers: Record<string, string | undefined> = {}
response.headers.forEach((value, key) => {
headers[key] = value
})
const httpResponse: http.HttpResponse = {
status: response.status,
body: await response.json().catch(() => response.text()),
headers,
}
if (!disposable.isDisposed()) {
disposable.addDisposable(cb(Ei.right(httpResponse)))
}
}
makeRequest().catch((error) => {
if (!disposable.isDisposed()) {
disposable.addDisposable(cb(Ei.left(error)))
}
})
return disposable
})
}
/**
* @category FS
* @since 0.13.1
*/
export const chmod = fromPromiseK(fs.promises.chmod)
/**
* @category FS
* @since 0.13.1
*/
export const copyFile = fromPromiseK(fs.promises.copyFile)
/**
* @category FS
* @since 0.13.1
*/
export const link = fromPromiseK(fs.promises.link)
/**
* @category FS
* @since 0.13.1
*/
export const mkdir = fromPromiseK(fs.promises.mkdir)
/**
* @category FS
* @since 0.13.1
*/
export const read = fromPromiseK(fs.promises.read)
/**
* @category FS
* @since 0.13.1
*/
export const readFile = fromPromiseK(fs.promises.readFile)
/**
* @category FS
* @since 0.13.1
*/
export const readdir = fromPromiseK(fs.promises.readdir)
/**
* @category FS
* @since 0.13.1
*/
export const rm = fromPromiseK(fs.promises.rm)
/**
* @category FS
* @since 0.13.1
*/
export const rmdir = fromPromiseK(fs.promises.rmdir)
/**
* @category FS
* @since 0.13.1
*/
export const stat = fromPromiseK(fs.promises.stat)
/**
* @category FS
* @since 0.13.1
*/
export const symlink = fromPromiseK(fs.promises.symlink)
/**
* @category FS
* @since 0.13.1
*/
export const unlink = fromPromiseK(fs.promises.unlink)
/**
* @category FS
* @since 0.13.1
*/
export const write = fromPromiseK(fs.promises.write)
/**
* @category FS
* @since 0.13.1
*/
export const writeFile = fromPromiseK(fs.promises.writeFile)
/**
* An in-memory implementation of `History`.
* @category In-Memory Mock
* @since 0.13.2
* @internal
*/
class ServerHistory implements History {
// Does not affect behavior
public scrollRestoration: ScrollRestoration = 'auto'
// ServerHistory specific
// tslint:disable-next-line:variable-name
private _states: { state: any; url: string }[]
// tslint:disable-next-line:variable-name
private _index = 0
private location: Location
constructor(location: Location) {
this.location = location
this._states = [{ state: null, url: this.location.pathname }]
}
private set index(value: number) {
this._index = value
const { url } = this._states[this._index]
this.location.replace(url)
}
private get index(): number {
return this._index
}
get length(): number {
return this._states.length
}
get state(): any {
const { state } = this._states[this.index]
return state
}
public go(quanity = 0): void {
if (quanity === 0) {
return void 0
}
const minIndex = 0
const maxIndex = this.length - 1
this.index = Math.max(minIndex, Math.min(maxIndex, this.index + quanity))
}
public back(): void {
this.go(-1)
}
public forward(): void {
this.go(1)
}
public pushState(state: any, _: string | null, url: string) {
this._states = this._states.slice(0, this.index).concat({ state, url })
this.index = this._states.length - 1
}
public replaceState(state: any, _: string | null, url: string) {
this._states[this.index] = { state, url }
}
}
const HTTPS_PROTOCOL = 'https:'
const HTTPS_DEFAULT_PORT = '443'
const HTTP_DEFAULT_PORT = '80'
/**
* An in-memory implementation of `Location`.
* @category In-Memory Mock
* @since 0.13.2
* @internal
*/
class ServerLocation implements Location {
get ancestorOrigins(): DOMStringList {
return [] as any as DOMStringList
}
get hash(): string {
return parseValue('hash', this)
}
set hash(value: string) {
const hash = value.startsWith('#') ? value : '#' + value
replace('hash', hash, this)
}
get host(): string {
return parseValue('host', this)
}
set host(value: string) {
replace('host', value, this)
}
get hostname(): string {
return parseValue('hostname', this)
}
set hostname(value: string) {
replace('hostname', value, this)
}
get pathname(): string {
return parseValue('pathname', this)
}
set pathname(value: string) {
replace('pathname', value, this)
}
get port(): string {
const { href } = this
const { port, protocol } = parseHref(href)
if (port) {
return port
}
return protocol === HTTPS_PROTOCOL ? HTTPS_DEFAULT_PORT : HTTP_DEFAULT_PORT
}
set port(value: string) {
replace('port', value, this)
}
get protocol(): string {
return parseValue('protocol', this) || 'http:'
}
set protocol(value: string) {
replace('protocol', value, this)
}
get search(): string {
return parseValue('search', this)
}
set search(value: string) {
const search = value.startsWith('?') ? value : '?' + value
replace('search', search, this)
}
get origin(): string {
return this.protocol + '//' + this.host
}
public href!: string
public history?: History
constructor(url: string) {
this.updateHref(url)
}
public assign(url: string): void {
this.updateHref(url)
if (this.history) {
this.history.pushState(null, '', this.href)
}
}
public reload(): void {
// Does not have defined behavior outside of browser
}
public replace(url: string): void {
this.updateHref(url)
if (this.history) {
this.history.replaceState(null, '', this.href)
}
}
public toString(): string {
return this.href
}
// ServerLocation Specific
public setHistory(history: History) {
this.history = history
return this
}
private updateHref(url: string) {
const parsed = parseHref(url)
const { host, relative } = parsed
let href = parsed.href
if (this.host && !host) {
href = this.host + href
}
const { protocol } = parseHref(href)
if (href !== relative && this.protocol && !protocol) {
href = this.protocol + '//' + href
}
this.href = href
}
}
function replace(key: keyof ParsedHref, value: string, location: ServerLocation) {
const { href } = location
const currentValue = parseHref(href)[key]
const updateHref = href.replace(currentValue, value)
location.replace(updateHref)
if (location.history) {
location.history.pushState(null, '', location.href)
}
}
function parseValue(key: keyof ParsedHref, location: ServerLocation): string {
return parseHref(location.href)[key] as string
}
const HREF_REGEX =
/^(?:([^:/?#]+):)?(?:\/\/((?:(([^:@]*)(?::([^:@]*))?)?@)?([^:/?#]*)(?::(\d*))?))?((((?:[^?#/]*\/)*)([^?#]*))(?:\?([^#]*))?(?:#(.*))?)/
/**
* ParsedHref JSON data structure
* @name ParsedHref
* @category Model
* @since 0.13.2
*/
export type ParsedHref = {
readonly href: string
readonly protocol: string
readonly host: string
readonly userInfo: string
readonly username: string
readonly password: string
readonly hostname: string
readonly port: string
readonly relative: string
readonly pathname: string
readonly directory: string
readonly file: string
readonly search: string
readonly hash: string
}
/**
* Parses an href into JSON.
* @category Parser
* @since 0.13.2
* */
export function parseHref(href: string): ParsedHref {
const matches = HREF_REGEX.exec(href) as RegExpExecArray
const parsedHref = {} as Record<keyof ParsedHref, string>
for (let i = 0; i < keyCount; ++i) {
const key = keys[i]
let value = matches[i] || ''
if (key === 'search' && value) {
value = '?' + value
}
if (key === 'protocol' && value && !value.endsWith(':')) {
value = value + ':'
}
if (key === 'hash') {
value = '#' + value
}
parsedHref[key] = value
}
return parsedHref
}
const keys: ReadonlyArray<keyof ParsedHref> = [
'href',
'protocol',
'host',
'userInfo',
'username',
'password',
'hostname',
'port',
'relative',
'pathname',
'directory',
'file',
'search',
'hash',
]
const keyCount = keys.length
/**
* Create A History Environment that works in browser and non-browser environments
* @param href :: initial href to use
* @category Environment
* @since 0.13.2
*/
export function createHistoryEnv(href = '/'): HistoryEnv & LocationEnv {
const serverLocation = new ServerLocation(href)
const serverHistory = new ServerHistory(serverLocation)
serverLocation.setHistory(serverHistory)
return {
location: serverLocation,
history: serverHistory,
}
}