1.0.2 • Published 7 months ago

remix-response v1.0.2

Weekly downloads
-
License
MIT
Repository
github
Last release
7 months ago

remix-response

Semantic response helpers for your Remix app.

remix-response provides response helpers that wait on all promises to resolve before serializing the response.

Basic Usage

yarn add remix-response
import type { LoaderArgs } from "@remix-run/node";
import { ok } from 'remix-response';

const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);

export const loader = async ({ request, context }: LoaderArgs) => {
  const listings = fetchListings(request.url);
  const recommendations = fetchRecommendations(context.user);

  return ok({
    listings, // Promise<[]>
    recommendations, // Promise<[]>
  });
};

export default function MyRouteComponent() {
    const data = useLoaderData<typeof loader>(); // { listings: [], recommendations: [] }
    // ...
}

Don't go chasin' waterfalls

The simplest way fetch data in a remix loader is to use an async function and unwrap every promise with await.

import type { LoaderArgs } from "@remix-run/node";
import { json } from "@remix-run/node";

const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);

export const loader = async ({ request, context }: LoaderArgs) => {
  const listings = await fetchListings(request.url);
  const recommendations = await fetchRecommendations(context.user);

  return json({
    listings,
    recommendations,
  });
};

However, if we need to fetch data from multiple independent sources this can slow down the loader response since fetchRecommendations doesn't start until after the fetchListings request has been completed. A better approach would be to delay waiting until all the fetchs have been initiated.

export const loader = async ({ request, context }: LoaderArgs) => {
-  const listings = await fetchListings(request.url);
+  const listings = fetchListings(request.url);
-  const recommendations = await fetchRecommendations(context.user);
+  const recommendations = fetchRecommendations(context.user);

  return json({
-    listings,
+    listings: await listings,
-    recommendations,
+    recommendations: await recommendations,
  });
};

This change improves the time it takes to run the loader function because now all the fetches are run in parallel and we only need to wait for the longest fetch to complete.

remix-response can simplifiy things a bit further by automatically awaiting any promises provided to the top level object before serializing the response.

This is similar to the behavior of Promise.all but it preserves the object shape and keys similar to RSVP.hash or bluebird's Promise.props.

- import { json } from "@remix-run/node";
+ import { ok } from 'remix-response';

export const loader = async ({ request, context }: LoaderArgs) => {
  const listings = fetchListings(request.url);
  const recommendations = fetchRecommendations(context.user);

-  return json({
+  return ok({
-    listings: await listings,
+    listings,
-    recommendations: await recommendations,
+    recommendations,
  });
};

Errors

When returning a response, if any of the promises reject the response will have a 500 status code. The data object will contain all of the properites with an object similar to Promise.allSettled indicating if the promises are fulfilled or rejected and the value/reason. This object can be used in your ErrorBoundary component to render the appropriate error message.

import type { LoaderArgs } from "@remix-run/node";
import { ok } from 'remix-response';

const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);

export const loader = async ({ request, context }: LoaderArgs) => {
  const listings = fetchListings(request.url);
  const recommendations = fetchRecommendations(context.user);

  return ok({
    listings, // Promise<[]>
    recommendations, // Promise<[]>
    ohNo: Promise.reject('oops!'),
  });
};

export function ErrorBoundary() {
  const error = useRouteError();
  // {
  //   status: 500,
  //   statusText: 'Server Error',
  //   data: {
  //     listings: { status: 'fulfilled', value: [] },
  //     recommendations: { status: 'fulfilled', value: [] },
  //     ohNo: { status: 'rejected', reason: 'oops' },
  //   }
  // }

    return (
      <div>
        <h1>
          {error.status} {error.statusText}
        </h1>
        <pre>{JSON.stringify(error.data, null, 2)}</pre>
      </div>
    );
}

If a response is thrown in the loader this indicates an error. Thrown responses will always keep their original status even if a promise rejects. Unlike a returned response, thown responses always use a settled object format with the status and value/reason. This is to ensure the shape will always be consistent in the ErrorBoundary component.

import type { LoaderArgs } from "@remix-run/node";
import { notFound } from 'remix-response';

const wait = (delay: number) => new Promise((r) => setTimeout(r, delay));
const fetchListings = (search: string) => wait(600).then(() => []);
const fetchRecommendations = (user: unknown) => wait(300).then(() => []);

export const loader = async ({ request, context }: LoaderArgs) => {
  const listings = fetchListings(request.url);
  const recommendations = fetchRecommendations(context.user);

  throw notFound({
    listings, // Promise<[]>
    recommendations, // Promise<[]>
  });
};

export function ErrorBoundary() {
  const error = useRouteError();
  // {
  //   status: 404,
  //   statusText: 'Not Found',
  //   data: {
  //     listings: { status: 'fulfilled', value: [] },
  //     recommendations: { status: 'fulfilled', value: [] },
  //   }
  // }

  return null;
}

API

Members

import { created } from 'remix-response';
export const action = async () => {
  return created({
    status: 'new',
    id: Promise.resolve(1),
  });
};
import { created } from 'remix-response';
export const action = async () => {
  return noContent();
};
import { resetContent } from 'remix-response';
export const loader = async () => {
  return resetContent({
    form: {},
    id: Promise.resolve(1),
  });
};
import { partialContent } from 'remix-response';
export const loader = async () => {
  return partialContent({
    title: 'RFC 2616 - Hypertext Transfer Protocol -- HTTP/1.1',
    id: Promise.resolve(2616),
  });
};
import { movedPermanently } from 'remix-response';
export const loader = async () => {
  return movedPermanently('https://www.example.com/');
};
import { found } from 'remix-response';
export const action = async () => {
  return found('https://www.example.com/');
};
import { seeOther } from 'remix-response';
export const action = async () => {
  return seeOther('https://www.example.com/');
};
import { notModified } from 'remix-response';
export const loader = async ({ request }: LoaderArgs) => {
  if (request.headers.get('If-Modified-Since') === 'Wed, 21 Oct 2015 07:28:00 GMT') {
    return notModified(request.url);
  }
};
import { temporaryRedirect } from 'remix-response';
export const action = async () => {
  return temporaryRedirect('https://www.example.com/');
};
import { permanentRedirect } from 'remix-response';
export const action = async () => {
  return permanentRedirect('https://www.example.com/');
};
import type { ActionArgs } from &quot;@remix-run/node&quot;;
import { badRequest } from 'remix-response';
export async function action({ request }: ActionArgs) {
  return badRequest({
    form: request.formData(),
    errors: Promise.resolve({name: 'missing'}),
  });
};
import type { ActionArgs } from &quot;@remix-run/node&quot;;
import { unauthorized } from 'remix-response';
export async function action({ request }: ActionArgs) {
  return unauthorized({
    form: request.formData(),
    errors: Promise.resolve({user: 'missing'}),
  });
};
import type { ActionArgs } from &quot;@remix-run/node&quot;;
import { forbidden } from 'remix-response';
export async function action({ request }: ActionArgs) {
  return forbidden({
    form: request.formData(),
    errors: Promise.resolve({user: 'missing'}),
  });
};
import { notFound } from 'remix-response';
export async function loader() {
  return notFound({
    recommendations: []
    fromTheBlog: Promise.resolve([]),
  });
};
import { methodNotAllowed } from 'remix-response';
export async function action() {
  return methodNotAllowed({
    allowedMethods: Promise.resolve(['GET', 'POST']),
  });
};
import { notAcceptable } from 'remix-response';
export async function action() {
  return notAcceptable({
    allowedLanguage: Promise.resolve(['US_en', 'US_es']),
  });
};
import { conflict } from 'remix-response';
export async function action() {
  return conflict({
    error: Promise.resolve({ id: 'duplicate id' }),
  });
};
import { gone } from 'remix-response';
export async function action() {
  return gone({
    error: Promise.resolve('resource deleted'),
  });
};
import { preconditionFailed } from 'remix-response';
export async function action() {
  return preconditionFailed({
    modifiedSince: Promise.resolve(Date.now()),
  });
};
import { expectationFailed } from 'remix-response';
export async function action() {
  return expectationFailed({
    error: Promise.resolve('Content-Length is too large.'),
  });
};
import { teapot } from 'remix-response';
export async function action() {
  return teapot({
    error: Promise.resolve('🚫☕'),
  });
};
import { preconditionFailed } from 'remix-response';
export async function action() {
  return preconditionFailed({
    error: Promise.resolve('Missing If-Match header.'),
  });
};
import { tooManyRequests } from 'remix-response';
export async function action() {
  return tooManyRequests({
    retryIn: Promise.resolve(5 * 60 * 1000),
  });
};
import { serverError } from 'remix-response';
export async function loader() {
  throw serverError({
    error: Promise.resolve('Unable to load resouce.'),
  });
};
import { notImplemented } from 'remix-response';
export async function loader() {
  throw notImplemented({
    error: Promise.resolve('Unable to load resouce.'),
  }, {
    headers: { 'Retry-After': 300 }
  });
};
import { serviceUnavailable } from 'remix-response';
export async function loader() {
  throw serviceUnavailable({
    error: Promise.resolve('Unable to load resouce.'),
  }, {
    headers: { 'Retry-After': 300 }
  });
};

Constants

import { ok } from 'remix-response';
export const loader = async () => {
  return ok({
    hello: 'world',
    promise: Promise.resolve('result'),
  });
};

ok

import { created } from 'remix-response';
export const action = async () => {
  return created({
    status: 'new',
    id: Promise.resolve(1),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

created

import { created } from 'remix-response';
export const action = async () => {
  return noContent();
};

Kind: global variable

ParamDescription
init?An optional RequestInit configuration object.

noContent

import { resetContent } from 'remix-response';
export const loader = async () => {
  return resetContent({
    form: {},
    id: Promise.resolve(1),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

resetContent

import { partialContent } from 'remix-response';
export const loader = async () => {
  return partialContent({
    title: 'RFC 2616 - Hypertext Transfer Protocol -- HTTP/1.1',
    id: Promise.resolve(2616),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

partialContent

import { movedPermanently } from 'remix-response';
export const loader = async () => {
  return movedPermanently('https://www.example.com/');
};

Kind: global variable

ParamDescription
urlA url to redirect the request to

movedPermanently

import { found } from 'remix-response';
export const action = async () => {
  return found('https://www.example.com/');
};

Kind: global variable

ParamDescription
urlA url to redirect the request to

found

import { seeOther } from 'remix-response';
export const action = async () => {
  return seeOther('https://www.example.com/');
};

Kind: global variable

ParamDescription
urlA url to redirect the request to

seeOther

import { notModified } from 'remix-response';
export const loader = async ({ request }: LoaderArgs) => {
  if (request.headers.get('If-Modified-Since') === 'Wed, 21 Oct 2015 07:28:00 GMT') {
    return notModified(request.url);
  }
};

Kind: global variable

ParamDescription
urlA url to redirect the request to

notModified

import { temporaryRedirect } from 'remix-response';
export const action = async () => {
  return temporaryRedirect('https://www.example.com/');
};

Kind: global variable

ParamDescription
urlA url to redirect the request to

temporaryRedirect

import { permanentRedirect } from 'remix-response';
export const action = async () => {
  return permanentRedirect('https://www.example.com/');
};

Kind: global variable

ParamDescription
urlA url to redirect the request to

permanentRedirect

import type { ActionArgs } from &quot;@remix-run/node&quot;;
import { badRequest } from 'remix-response';
export async function action({ request }: ActionArgs) {
  return badRequest({
    form: request.formData(),
    errors: Promise.resolve({name: 'missing'}),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

badRequest

import type { ActionArgs } from &quot;@remix-run/node&quot;;
import { unauthorized } from 'remix-response';
export async function action({ request }: ActionArgs) {
  return unauthorized({
    form: request.formData(),
    errors: Promise.resolve({user: 'missing'}),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

unauthorized

import type { ActionArgs } from &quot;@remix-run/node&quot;;
import { forbidden } from 'remix-response';
export async function action({ request }: ActionArgs) {
  return forbidden({
    form: request.formData(),
    errors: Promise.resolve({user: 'missing'}),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

forbidden

import { notFound } from 'remix-response';
export async function loader() {
  return notFound({
    recommendations: []
    fromTheBlog: Promise.resolve([]),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

notFound

import { methodNotAllowed } from 'remix-response';
export async function action() {
  return methodNotAllowed({
    allowedMethods: Promise.resolve(['GET', 'POST']),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

methodNotAllowed

import { notAcceptable } from 'remix-response';
export async function action() {
  return notAcceptable({
    allowedLanguage: Promise.resolve(['US_en', 'US_es']),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

notAcceptable

import { conflict } from 'remix-response';
export async function action() {
  return conflict({
    error: Promise.resolve({ id: 'duplicate id' }),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

conflict

import { gone } from 'remix-response';
export async function action() {
  return gone({
    error: Promise.resolve('resource deleted'),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

gone

import { preconditionFailed } from 'remix-response';
export async function action() {
  return preconditionFailed({
    modifiedSince: Promise.resolve(Date.now()),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

preconditionFailed

import { expectationFailed } from 'remix-response';
export async function action() {
  return expectationFailed({
    error: Promise.resolve('Content-Length is too large.'),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

expectationFailed

import { teapot } from 'remix-response';
export async function action() {
  return teapot({
    error: Promise.resolve('🚫☕'),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

teapot

import { preconditionFailed } from 'remix-response';
export async function action() {
  return preconditionFailed({
    error: Promise.resolve('Missing If-Match header.'),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

preconditionRequired

import { tooManyRequests } from 'remix-response';
export async function action() {
  return tooManyRequests({
    retryIn: Promise.resolve(5 * 60 * 1000),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

tooManyRequests

import { serverError } from 'remix-response';
export async function loader() {
  throw serverError({
    error: Promise.resolve('Unable to load resouce.'),
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

serverError

import { notImplemented } from 'remix-response';
export async function loader() {
  throw notImplemented({
    error: Promise.resolve('Unable to load resouce.'),
  }, {
    headers: { 'Retry-After': 300 }
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

notImplemented

import { serviceUnavailable } from 'remix-response';
export async function loader() {
  throw serviceUnavailable({
    error: Promise.resolve('Unable to load resouce.'),
  }, {
    headers: { 'Retry-After': 300 }
  });
};

Kind: global variable

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.

ok

import { ok } from 'remix-response';
export const loader = async () => {
  return ok({
    hello: 'world',
    promise: Promise.resolve('result'),
  });
};

Kind: global constant

ParamDescription
dataA JavaScript object that will be serialized as JSON.
init?An optional RequestInit configuration object.
1.0.2

7 months ago

1.0.1

8 months ago

1.0.0

8 months ago