faasjs/faasjs

View on GitHub
packages/browser/src/index.ts

Summary

Maintainability
A
1 hr
Test Coverage
A
91%
/**
 * FaasJS browser client.
 *
 * [![License: MIT](https://img.shields.io/npm/l/@faasjs/browser.svg)](https://github.com/faasjs/faasjs/blob/main/packages/faasjs/browser/LICENSE)
 * [![NPM Version](https://img.shields.io/npm/v/@faasjs/browser.svg)](https://www.npmjs.com/package/@faasjs/browser)
 *
 * Browser plugin for FaasJS.
 *
 * ## Install
 *
 * ```sh
 * npm install @faasjs/browser
 * ```
 *
 * ## Usage
 *
 * ### Use directly
 *
 * ```ts
 * import { FaasBrowserClient } from '@faasjs/browser'
 *
 * const client = new FaasBrowserClient('/')
 *
 * await client.action('func', { key: 'value' })
 * ```
 *
 * ### Use with SWR
 *
 * ```ts
 * import { FaasBrowserClient } from '@faasjs/browser'
 * import useSWR from 'swr'
 *
 * const client = new FaasBrowserClient('/')
 *
 * const { data } = useSWR(['func', { key: 'value' }], client.action)
 * ```
 *
 * Reference: [Data Fetching - SWR](https://swr.vercel.app/docs/data-fetching)
 *
 * ### Use with React Query
 *
 * ```ts
 * import { FaasBrowserClient } from '@faasjs/browser'
 * import { QueryClient } from 'react-query'
 *
 * const client = new FaasBrowserClient('/')
 *
 * const queryClient = new QueryClient({
 *   defaultOptions: {
 *     queries: {
 *       queryFn: async ({ queryKey }) => client
 *         .action(queryKey[0] as string, queryKey[1] as any)
 *         .then(data => data.data),
 *     },
 *   },
 * })
 *
 * function App() {
 *   return (
 *     <QueryClientProvider client={queryClient}>
 *       <YourApp />
 *     </QueryClientProvider>
 *   )
 * }
 * ```
 *
 * ### Use with React
 *
 * Please use [@faasjs/react](https://faasjs.com/doc/react) for React.
 *
 * ### Use with Vue
 *
 * Please use [@faasjs/vue-plugin](https://faasjs.com/doc/vue-plugin) for Vue.
 *
 * @packageDocumentation
 */
import type { FaasAction, FaasData, FaasParams } from '@faasjs/types'

import { generateId } from './generateId'

export { generateId } from './generateId'

export type Options = RequestInit & {
  headers?: Record<string, string>
  /** trigger before request */
  beforeRequest?: ({
    action,
    params,
    options,
    headers,
  }: {
    action: string
    params: Record<string, any>
    options: Options
    headers: Record<string, string>
  }) => Promise<void>
  /** custom request */
  request?: <PathOrData extends FaasAction>(
    url: string,
    options: Options
  ) => Promise<Response<FaasData<PathOrData>>>
}

export type ResponseHeaders = {
  [key: string]: string
}

export type FaasBrowserClientAction = <PathOrData extends FaasAction>(
  action: PathOrData | string,
  params?: FaasParams<PathOrData>,
  options?: Options
) => Promise<Response<FaasData<PathOrData>>>

/**
 * Response class
 *
 * Example:
 * ```ts
 * new Response({
 *   status: 200,
 *   data: {
 *     name: 'FaasJS'
 *   }
 * })
 * ```
 */
export class Response<T = any> {
  public readonly status: number
  public readonly headers: ResponseHeaders
  public readonly body: any
  public readonly data: T

  constructor(props: {
    status?: number
    headers?: ResponseHeaders
    body?: any
    data?: T
  }) {
    this.status = props.status || 200
    this.headers = props.headers || {}
    this.body = props.body
    this.data = props.data

    if (props.data && !props.body) this.body = JSON.stringify(props.data)
  }
}

/**
 * ResponseError class
 *
 * Example:
 * ```ts
 * new ResponseError({
 *   status: 404,
 *   message: 'Not Found',
 * })
 * ```
 */
export class ResponseError extends Error {
  public readonly status: number
  public readonly headers: ResponseHeaders
  public readonly body: any

  constructor({
    message,
    status,
    headers,
    body,
  }: {
    message: string
    status: number
    headers: ResponseHeaders
    body: any
  }) {
    super(message)

    this.status = status
    this.headers = headers
    this.body = body
  }
}

export type MockHandler = (
  action: string,
  params: Record<string, any>,
  options: Options
) => Promise<Response<any>>

let mock: MockHandler

/**
 * Set mock handler for testing
 *
 * @param handler mock handler, set `undefined` to clear mock
 *
 * @example
 * ```ts
 * import { setMock } from '@faasjs/browser'
 *
 * setMock(async ({ action, params, options }) => {
 *   return new Response({
 *     status: 200,
 *     data: {
 *       name: 'FaasJS'
 *     }
 *   })
 * })
 *
 * const client = new FaasBrowserClient('/')
 *
 * const response = await client.action('path') // response.data.name === 'FaasJS'
 * ```
 */
export function setMock(handler: MockHandler) {
  mock = handler
}

/**
 * FaasJS browser client

 * ```ts
 * const client = new FaasBrowserClient('http://localhost:8080')
 *
 * await client.action('func', { key: 'value' })
 * ```
 */
export class FaasBrowserClient {
  public readonly id: string
  public host: string
  public defaultOptions: Options

  constructor(baseUrl: string, options?: Options) {
    if (!baseUrl) throw Error('[FaasJS] baseUrl required')

    this.id = `FBC-${generateId()}`
    this.host = baseUrl[baseUrl.length - 1] === '/' ? baseUrl : `${baseUrl}/`
    this.defaultOptions = options || Object.create(null)

    console.debug(`[FaasJS] Initialize with baseUrl: ${this.host}`)
  }

  /**
   * Request a FaasJS function
   * @param action function path
   * @param params function params
   * @param options request options
   * ```ts
   * await client.action('func', { key: 'value' })
   * ```
   */
  public async action<PathOrData extends FaasAction>(
    action: PathOrData | string,
    params?: FaasParams<PathOrData>,
    options?: Options
  ): Promise<Response<FaasData<PathOrData>>> {
    if (!action) throw Error('[FaasJS] action required')

    const id = `F-${generateId()}`

    const url = `${this.host + (action as string).toLowerCase()}?_=${id}`

    if (!params) params = Object.create(null)
    if (!options) options = Object.create(null)

    options = {
      method: 'POST',
      headers: { 'Content-Type': 'application/json; charset=UTF-8' },
      mode: 'cors',
      credentials: 'include',
      body: JSON.stringify(params),
      ...this.defaultOptions,
      ...options,
    }

    if (!options.headers['X-FaasJS-Request-Id'])
      options.headers['X-FaasJS-Request-Id'] = id

    if (options.beforeRequest)
      await options.beforeRequest({
        action: action as string,
        params,
        options,
        headers: options.headers,
      })

    if (options.request) return options.request(url, options)

    if (mock) return mock(action as string, params, options)

    return fetch(url, options).then(async response => {
      const headers: {
        [key: string]: string
      } = {}
      for (const values of response.headers) headers[values[0]] = values[1]

      return response.text().then(res => {
        if (response.status >= 200 && response.status < 300) {
          if (!res)
            return new Response({
              status: response.status,
              headers,
            })

          const body = JSON.parse(res)
          return new Response({
            status: response.status,
            headers,
            body,
            data: body.data,
          })
        }

        try {
          const body = JSON.parse(res)

          if (body.error?.message)
            return Promise.reject(
              new ResponseError({
                message: body.error.message,
                status: response.status,
                headers,
                body,
              })
            )

          return Promise.reject(
            new ResponseError({
              message: res,
              status: response.status,
              headers,
              body,
            })
          )
        } catch (_) {
          return Promise.reject(
            new ResponseError({
              message: res,
              status: response.status,
              headers,
              body: res,
            })
          )
        }
      })
    })
  }
}