xref: /expo/packages/@expo/server/src/vendor/express.ts (revision 229dec0d)
1import {
2  AbortController,
3  Headers,
4  RequestInit,
5  Response,
6  writeReadableStreamToWritable,
7} from '@remix-run/node';
8import type * as express from 'express';
9
10import { createRequestHandler as createExpoHandler } from '..';
11import { ExpoRequest } from '../environment';
12
13export type RequestHandler = (
14  req: express.Request,
15  res: express.Response,
16  next: express.NextFunction
17) => Promise<void>;
18
19/**
20 * Returns a request handler for Express that serves the response using Remix.
21 */
22export function createRequestHandler(
23  { build }: { build: string },
24  setup?: Parameters<typeof createExpoHandler>[1]
25): RequestHandler {
26  const handleRequest = createExpoHandler(build, setup);
27
28  return async (req: express.Request, res: express.Response, next: express.NextFunction) => {
29    if (!req?.url || !req.method) {
30      return next();
31    }
32    try {
33      const request = convertRequest(req, res);
34
35      const response = await handleRequest(request);
36
37      await respond(res, response);
38    } catch (error: unknown) {
39      // Express doesn't support async functions, so we have to pass along the
40      // error manually using next().
41      next(error);
42    }
43  };
44}
45
46export function convertHeaders(requestHeaders: express.Request['headers']): Headers {
47  const headers = new Headers();
48
49  for (const [key, values] of Object.entries(requestHeaders)) {
50    if (values) {
51      if (Array.isArray(values)) {
52        for (const value of values) {
53          headers.append(key, value);
54        }
55      } else {
56        headers.set(key, values);
57      }
58    }
59  }
60
61  return headers;
62}
63
64export function convertRequest(req: express.Request, res: express.Response): ExpoRequest {
65  const url = new URL(`${req.protocol}://${req.get('host')}${req.url}`);
66
67  // Abort action/loaders once we can no longer write a response
68  const controller = new AbortController();
69  res.on('close', () => controller.abort());
70
71  const init: RequestInit = {
72    method: req.method,
73    headers: convertHeaders(req.headers),
74    // Cast until reason/throwIfAborted added
75    // https://github.com/mysticatea/abort-controller/issues/36
76    signal: controller.signal as RequestInit['signal'],
77  };
78
79  if (req.method !== 'GET' && req.method !== 'HEAD') {
80    init.body = req;
81  }
82
83  return new ExpoRequest(url.href, init);
84}
85
86export async function respond(res: express.Response, expoRes: Response): Promise<void> {
87  res.statusMessage = expoRes.statusText;
88  res.status(expoRes.status);
89
90  for (const [key, values] of Object.entries(expoRes.headers.raw())) {
91    for (const value of values) {
92      res.append(key, value);
93    }
94  }
95
96  if (expoRes.body) {
97    await writeReadableStreamToWritable(expoRes.body, res);
98  } else {
99    res.end();
100  }
101}
102