/** * @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> { 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 = {} 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 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 = [ '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, } }