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