xref: /expo/packages/@expo/server/src/vendor/netlify.ts (revision 57cc75f7)
1import type { HandlerEvent, HandlerResponse } from '@netlify/functions';
2import { Headers, readableStreamToString, RequestInit } from '@remix-run/node';
3import { AbortController } from 'abort-controller';
4
5import { createRequestHandler as createExpoHandler } from '..';
6import { ExpoRequest, ExpoResponse } from '../environment';
7
8export function createRequestHandler({ build }: { build: string }) {
9  const handleRequest = createExpoHandler(build);
10
11  return async (event: HandlerEvent) => {
12    const response = await handleRequest(convertRequest(event));
13
14    return respond(response);
15  };
16}
17
18export async function respond(res: ExpoResponse): Promise<HandlerResponse> {
19  const contentType = res.headers.get('Content-Type');
20  let body: string | undefined;
21  const isBase64Encoded = isBinaryType(contentType);
22
23  if (res.body) {
24    if (isBase64Encoded) {
25      body = await readableStreamToString(res.body, 'base64');
26    } else {
27      body = await res.text();
28    }
29  }
30
31  const multiValueHeaders = res.headers.raw();
32
33  return {
34    statusCode: res.status,
35    multiValueHeaders,
36    body,
37    isBase64Encoded,
38  };
39}
40
41export function createHeaders(requestHeaders: HandlerEvent['multiValueHeaders']): Headers {
42  const headers = new Headers();
43
44  for (const [key, values] of Object.entries(requestHeaders)) {
45    if (values) {
46      for (const value of values) {
47        headers.append(key, value);
48      }
49    }
50  }
51
52  return headers;
53}
54
55// `netlify dev` doesn't return the full url in the event.rawUrl, so we need to create it ourselves
56function getRawPath(event: HandlerEvent): string {
57  let rawPath = event.path;
58  const searchParams = new URLSearchParams();
59
60  if (!event.multiValueQueryStringParameters) {
61    return rawPath;
62  }
63
64  const paramKeys = Object.keys(event.multiValueQueryStringParameters);
65  for (const key of paramKeys) {
66    const values = event.multiValueQueryStringParameters[key];
67    if (!values) continue;
68    for (const val of values) {
69      searchParams.append(key, val);
70    }
71  }
72
73  const rawParams = searchParams.toString();
74
75  if (rawParams) rawPath += `?${rawParams}`;
76
77  return rawPath;
78}
79
80export function convertRequest(event: HandlerEvent): ExpoRequest {
81  let url: URL;
82
83  if (process.env.NODE_ENV !== 'development') {
84    url = new URL(event.rawUrl);
85  } else {
86    const origin = event.headers.host;
87    const rawPath = getRawPath(event);
88    url = new URL(`http://${origin}${rawPath}`);
89  }
90
91  // Note: No current way to abort these for Netlify, but our router expects
92  // requests to contain a signal so it can detect aborted requests
93  const controller = new AbortController();
94
95  const init: RequestInit = {
96    method: event.httpMethod,
97    headers: createHeaders(event.multiValueHeaders),
98    // Cast until reason/throwIfAborted added
99    // https://github.com/mysticatea/abort-controller/issues/36
100    signal: controller.signal as RequestInit['signal'],
101  };
102
103  if (event.httpMethod !== 'GET' && event.httpMethod !== 'HEAD' && event.body) {
104    const isFormData = event.headers['content-type']?.includes('multipart/form-data');
105    init.body = event.isBase64Encoded
106      ? isFormData
107        ? Buffer.from(event.body, 'base64')
108        : Buffer.from(event.body, 'base64').toString()
109      : event.body;
110  }
111
112  return new ExpoRequest(url.href, init);
113}
114
115/**
116 * Common binary MIME types
117 * @see https://github.com/architect/functions/blob/45254fc1936a1794c185aac07e9889b241a2e5c6/src/http/helpers/binary-types.js
118 */
119const binaryTypes = [
120  'application/octet-stream',
121  // Docs
122  'application/epub+zip',
123  'application/msword',
124  'application/pdf',
125  'application/rtf',
126  'application/vnd.amazon.ebook',
127  'application/vnd.ms-excel',
128  'application/vnd.ms-powerpoint',
129  'application/vnd.openxmlformats-officedocument.presentationml.presentation',
130  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
131  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
132  // Fonts
133  'font/otf',
134  'font/woff',
135  'font/woff2',
136  // Images
137  'image/avif',
138  'image/bmp',
139  'image/gif',
140  'image/jpeg',
141  'image/png',
142  'image/tiff',
143  'image/vnd.microsoft.icon',
144  'image/webp',
145  // Audio
146  'audio/3gpp',
147  'audio/aac',
148  'audio/basic',
149  'audio/mpeg',
150  'audio/ogg',
151  'audio/wav',
152  'audio/webm',
153  'audio/x-aiff',
154  'audio/x-midi',
155  'audio/x-wav',
156  // Video
157  'video/3gpp',
158  'video/mp2t',
159  'video/mpeg',
160  'video/ogg',
161  'video/quicktime',
162  'video/webm',
163  'video/x-msvideo',
164  // Archives
165  'application/java-archive',
166  'application/vnd.apple.installer+xml',
167  'application/x-7z-compressed',
168  'application/x-apple-diskimage',
169  'application/x-bzip',
170  'application/x-bzip2',
171  'application/x-gzip',
172  'application/x-java-archive',
173  'application/x-rar-compressed',
174  'application/x-tar',
175  'application/x-zip',
176  'application/zip',
177];
178
179export function isBinaryType(contentType: string | null | undefined) {
180  if (!contentType) return false;
181  const [test] = contentType.split(';');
182  return binaryTypes.includes(test);
183}
184